Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions desktop/src-tauri/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,11 @@ pub struct Config {
/// 官方模式下 CSSwitch 只负责把你交回真实客户端,绝不碰/托管你的官方登录。
#[serde(default = "default_mode")]
pub mode: String,
/// CONNECT 隧道上游代理(host:port,如 "127.0.0.1:7890")。非空时非 Anthropic 域的
/// HTTPS CONNECT 隧道经此代理而非直连目标,解决 MCP 外部数据库(PubMed/ChEMBL 等)
/// 在国内直连不可达的问题(#11)。
#[serde(default)]
pub upstream_proxy: String,
#[serde(default)]
pub providers: BTreeMap<String, ProviderCfg>,
}
Expand All @@ -67,6 +72,7 @@ impl Default for Config {
sandbox_port: default_sandbox_port(),
secret: String::new(),
mode: default_mode(),
upstream_proxy: String::new(),
providers: BTreeMap::new(),
}
}
Expand Down
12 changes: 9 additions & 3 deletions desktop/src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -267,14 +267,20 @@ fn ensure_proxy(

let logf = open_log("proxy.log").map_err(|e| format!("建日志失败:{e}"))?;
let logf2 = logf.try_clone().map_err(|e| e.to_string())?;
let child = Command::new(&py)
.arg(&script)
let mut cmd = Command::new(&py);
cmd.arg(&script)
.arg("--provider")
.arg(&provider)
.arg("--port")
.arg(port.to_string())
.arg("--auth-token")
.arg(&secret)
.arg(&secret);
// 上游代理(修 #11):非空时传给代理的 --upstream-proxy,
// 使非 Anthropic 域的 CONNECT 隧道经此代理出墙(MCP 外部数据库连通)。
if !cfg.upstream_proxy.is_empty() {
cmd.arg("--upstream-proxy").arg(&cfg.upstream_proxy);
}
let child = cmd
// key 经环境变量注入,绝不作为命令行参数(避免 ps 泄露)。
.env(key_env(&provider), &key)
.stdout(Stdio::from(logf))
Expand Down
68 changes: 52 additions & 16 deletions proxy/csswitch_proxy.py
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,7 @@
LOG = None
PROV_NAME = None # 运行时设定;模块被 import 做测试时也要有定义,避免 handler NameError
AUTH_SECRET = None # 未设则不启用鉴权(保持旧行为)
UPSTREAM_PROXY = None # CONNECT 隧道上游代理(如 Clash 7890),解决 MCP 外部数据库在国内直连不通
_DATE_SUFFIX = re.compile(r"-\d{8}$")

# ---------- #3: targeted fast-fail(沙箱「Switching organization」卡死修复) ----------
Expand Down Expand Up @@ -418,19 +419,13 @@ def do_POST(self):
self._handle_openai(areq)

# ---- HTTP CONNECT 隧道:Anthropic 域名 fast-fail、其余透传(修 #3) ----
# 当指定了 --upstream-proxy 时,非 Anthropic 域的 CONNECT 隧道不再直连目标,
# 而是通过上游代理(如 Clash/FlClash 的 7890)建立隧道,解决国内环境 MCP 外部
# 数据库(PubMed/ChEMBL 等)直连不可达的问题(修 #11)。
def do_CONNECT(self):
# operon 用 https_proxy 走到这里;self.path 形如 "host:port"。
# 【为何不走 _auth_ok】CONNECT 把目标放在请求行、没有可嵌 path-secret 的位置,
# operon 的 https_proxy 也带不上 secret。此处不鉴权的实际风险面很小:
# - 只监听回环(127.0.0.1),本机进程本就能自行外连,隧道不给它任何新能力;
# - 隧道是裸 TCP 转发,不注入上游 key、不经推理端点(那两条仍受 secret 保护)。
# 即 path-secret 真正守护的边界(第三方 key + 推理端点)未被削弱。
# 进一步收紧可让 launch 把 secret 放进 https_proxy 的 userinfo 再校验
# Proxy-Authorization,但需先实测 operon 是否会带该头(否则误伤透传),留待整链联调。
target = self.path
host = target.rsplit(":", 1)[0].strip("[]").lower()
if _is_blocked_host(host):
# 401(未登录)而非 403(禁止):让 operon 判 logged-out 秒过,而非当组织问题反复重试。
log(f"CONNECT {target} -> 401 未登录(Anthropic 域名 fast-fail)")
self._connect_reply(401)
return
Expand All @@ -439,19 +434,45 @@ def do_CONNECT(self):
except (ValueError, IndexError):
self._connect_reply(400)
return
try:
upstream = socket.create_connection((host, port), timeout=10)
except Exception as e:
log(f"CONNECT {target} -> 502 上游连不上: {e}")
self._connect_reply(502)
return
if UPSTREAM_PROXY:
# 经上游代理建立隧道(修 #11:MCP 外部数据库连通)
try:
uph, upp = UPSTREAM_PROXY
upstream = socket.create_connection((uph, upp), timeout=10)
connect_req = f"CONNECT {target} HTTP/1.1\r\nHost: {target}\r\n\r\n"
upstream.sendall(connect_req.encode())
# 读上游代理的 CONNECT 响应
resp = b""
while b"\r\n\r\n" not in resp:
chunk = upstream.recv(4096)
if not chunk:
upstream.close()
raise ConnectionError("上游代理 CONNECT 响应不完整")
resp += chunk
status_line = resp.split(b"\r\n")[0].decode("utf-8", "replace")
if " 200 " not in status_line:
upstream.close()
log(f"CONNECT {target} -> 上游代理拒绝: {status_line.strip()}")
self._connect_reply(502)
return
log(f"CONNECT {target} -> 经上游代理 {uph}:{upp} 建立隧道")
except Exception as e:
log(f"CONNECT {target} -> 上游代理连不上 ({uph}:{upp}): {e}")
self._connect_reply(502)
return
else:
try:
upstream = socket.create_connection((host, port), timeout=10)
except Exception as e:
log(f"CONNECT {target} -> 502 直连不上: {e}")
self._connect_reply(502)
return
self.send_response(200, "Connection Established")
self.end_headers()
try:
self.wfile.flush()
except Exception:
pass
log(f"CONNECT {target} -> 隧道建立,透传")
try:
self._tunnel(self.connection, upstream)
finally:
Expand Down Expand Up @@ -644,6 +665,9 @@ def _replay_as_sse(self, aresp):
ap.add_argument("--env-file", default=None)
ap.add_argument("--log", default=None)
ap.add_argument("--auth-token", default=None)
ap.add_argument("--upstream-proxy", default=None,
help="CONNECT 隧道上游代理 host:port(如 127.0.0.1:7890),"
"用于 MCP 外部数据库出墙(PubMed/ChEMBL 等)")
args = ap.parse_args()
PROV_NAME = args.provider
PROV = PROVIDERS[PROV_NAME]
Expand All @@ -654,6 +678,18 @@ def _replay_as_sse(self, aresp):
if _up:
PROV = dict(PROV)
PROV["url"] = _up
if args.upstream_proxy:
hp = args.upstream_proxy.rsplit(":", 1)
if len(hp) == 2:
try:
UPSTREAM_PROXY = (hp[0], int(hp[1]))
except ValueError:
print(f"[csswitch] --upstream-proxy 端口非法:{hp[1]}", file=sys.stderr)
sys.exit(1)
else:
print(f"[csswitch] --upstream-proxy 格式应为 host:port,收到:{args.upstream_proxy}",
file=sys.stderr)
sys.exit(1)
if not KEY:
print(f"找不到 {PROV['key_env']}。用环境变量或 --env-file <路径> 提供。", file=sys.stderr)
sys.exit(1)
Expand Down
21 changes: 16 additions & 5 deletions scripts/launch-virtual-sandbox.sh
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,10 @@
# - encryption.key 的 keychain 镜像账号按【路径哈希】派生,沙箱与真实天然隔离
#
# 用法:
# 先起代理: DEEPSEEK_API_KEY=... python3 proxy/csswitch_proxy.py --provider deepseek --port 18991
# 再起沙箱: scripts/launch-virtual-sandbox.sh [--port 8990] [--proxy-url http://127.0.0.1:18991]
# 先起代理: DEEPSEEK_API_KEY=... python3 proxy/csswitch_proxy.py --provider deepseek --port 18991 \
# [--upstream-proxy 127.0.0.1:7890]
# 再起沙箱: scripts/launch-virtual-sandbox.sh [--port 8990] [--proxy-url http://127.0.0.1:18991] \
# [--upstream-proxy http://127.0.0.1:7890]
set -euo pipefail

PROJ="${0:A:h:h}"
Expand All @@ -24,6 +26,7 @@ REAL_DIR="$HOME/.claude-science"
BIN="/Applications/Claude Science.app/Contents/Resources/bin/claude-science"
PORT=8990
PROXY_URL="http://127.0.0.1:18991"
UPSTREAM_PROXY="" # 上游代理(如 Clash 7890),修 #11:MCP 外部数据库连通
EMAIL="[email protected]"
DRY_RUN=0
SKIP_FORGE=0 # app 调用时置 1:OAuth 由 app 进程内 Rust 伪造,本脚本不再调 node
Expand All @@ -32,6 +35,7 @@ while [[ $# -gt 0 ]]; do
case "$1" in
--port) PORT="$2"; shift 2;;
--proxy-url) PROXY_URL="$2"; shift 2;;
--upstream-proxy) UPSTREAM_PROXY="$2"; shift 2;;
--email) EMAIL="$2"; shift 2;;
--dry-run) DRY_RUN=1; shift;;
--skip-oauth-forge) SKIP_FORGE=1; shift;;
Expand Down Expand Up @@ -102,18 +106,25 @@ echo " 账号 = $EMAIL (本地假账号,不用真实凭证)"
# 在到不了 claude.ai 的网络上会挂住重试 → UI 卡在 "Switching organization"。
# 做法:**只设 https_proxy**(那条卡死的 profile 请求是 HTTPS → 走 CONNECT,代理的
# do_CONNECT 对上述域名立即 403,operon 秒判 logged-out 秒过;其余 HTTPS 隧道透传)。
# 【刻意不设 http_proxy】:代理未实现普通 HTTP 转发(GET http://host/…),若设了 http_proxy
# 普通 HTTP 的 MCP/下载/包源会撞代理拿 404(修 P2);不设则普通 HTTP 直连或走用户自己的
# http_proxy,且无 Anthropic 域名走普通 HTTP,故不影响 fast-fail。
# no_proxy 让本地推理仍直连 127.0.0.1(operon 认【小写】 https_proxy/no_proxy)。
# 【修 #11】:若指定了 --upstream-proxy,则同时设 http_proxy 指向上游代理,
# 解决 MCP 外部数据库(PubMed/ChEMBL 等)的 HTTP 请求国内直连不可达问题。
# HTTPS 的 MCP 请求仍走 CSswitch 代理的 CONNECT 隧道(配合代理的 --upstream-proxy 参数)。
_PROXY_HOSTPORT="$(printf '%s' "$PROXY_URL" | sed -E 's#^[a-zA-Z][a-zA-Z0-9+.-]*://([^/]+).*#\1#')"
_FASTFAIL_PROXY="http://$_PROXY_HOSTPORT"
_NO_PROXY="127.0.0.1,localhost,::1"
echo " 外联防卡 = Anthropic HTTPS fast-fail(经 $_FASTFAIL_PROXY,no_proxy=$_NO_PROXY)"
if [[ -n "$UPSTREAM_PROXY" ]]; then
echo " 上游代理 = $UPSTREAM_PROXY(MCP 外部数据库 / 包源走此通道)"
_HTTP_PROXY="$UPSTREAM_PROXY"
else
_HTTP_PROXY=""
fi
echo

HOME="$SANDBOX_HOME" \
ANTHROPIC_BASE_URL="$PROXY_URL" \
http_proxy="${_HTTP_PROXY:-}" HTTP_PROXY="${_HTTP_PROXY:-}" \
https_proxy="$_FASTFAIL_PROXY" HTTPS_PROXY="$_FASTFAIL_PROXY" \
no_proxy="$_NO_PROXY" NO_PROXY="$_NO_PROXY" \
"$BIN" serve \
Expand Down