[Bug] 主进程 fetch 未接 outbound-proxy-resolver,系统代理模式下 Claude 授权 / 模型发现 / token 刷新全部 403
- Dominant language
- TypeScript
- Stars
- 2.7k
- Forks
- 401
- Avg merge
- 21h 48m
- Merged PRs (30d)
- 776
Description
## 问题描述
Windows 端在代理软件跑「系统代理」模式(非 TUN)时,连接 Claude 的授权流程会在最后一步失败:浏览器授权页正常打开、正常回跳,然后弹出「授权未完成 / 连接 Claude 时发生错误」,副标题 `Token exchange failed (403)`。
同一根因还会导致另外两个现象:
1. 即使本机已有有效的 Claude 凭证,模型列表也是空的:
`[WARN] [model-discovery:anthropic] anthropic /v1/models fetch failed; keeping current list { error: 'Error: HTTP 403' }`
2. token 刷新走同一条路径,同样会被拒(`claude oauth refresh rejected by server`)。
迷惑性在于:浏览器那一步是成功的,用户会以为是账号或授权码问题,反复重试;实际失败的是主进程发出的那一跳。
## 复现环境
- Cindy 0.1.14 (win32-x64),Windows 11
- 代理软件为「系统代理」模式,`HKCU\...\Internet Settings` 中 `ProxyEnable=1`、`ProxyServer=127.0.0.1:10810`
- 未开 TUN / 虚拟网卡
- 从 GUI(快捷方式)启动,进程环境里没有 `HTTP_PROXY` / `HTTPS_PROXY`
## 根因
上述请求都用主进程的全局 `fetch`。Electron 主进程的全局 `fetch` 是 Node 的 undici,**既不读 Windows 系统代理,也不读 `HTTP(S)_PROXY` 环境变量**(`NODE_USE_ENV_PROXY` 要 Node 24+ 且需显式开启)。因此这些请求恒为直连。
Anthropic 对未支持地区的直连返回 403,于是:
- 浏览器打开授权页 → 走系统代理 → 成功
- 主进程 `exchangeCodeForTokens` → 直连 → 403 → 用户看到「授权未完成」
## 对照实验
同一台机器,同一时刻:
| 请求 | 直连 | 经 `http://127.0.0.1:10810` |
|---|---|---|
| `POST https://platform.claude.com/v1/oauth/token` | **403** `{"error":{"type":"forbidden","message":"Request not allowed"}}` | 400 `invalid_request`(端点可达,仅参数无效) |
| `GET https://api.anthropic.com/v1/models`(带有效 OAuth token) | **403**(Cloudflare HTML) | **200**,返回 10 个模型 |
直连的 403 由 Cloudflare 边缘返回,body 不是 JSON,因此 `res.json()` 会抛异常——这一点在刷新路径上还有副作用,见文末。
## 源码定位
三处主进程 `fetch` 都没有传 `dispatcher`:
```ts
// apps/desktop/src/main/maker-host/claude-oauth-login.ts:104
const res = await fetch(TOKEN_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ grant_type: 'authorization_code', ... }),
signal,
});
```
```ts
// apps/desktop/src/main/maker-host/model-discovery/anthropic.ts:687
const res: Response = await fetch(url, { headers: { authorization: `Bearer ${...}` }, ... });
```
```ts
// apps/desktop/src/main/maker-host/claude-oauth-refresh.ts:362 (grant_type=refresh_token)
// apps/desktop/src/main/maker-host/claude-oauth-refresh.ts:126 (PROFILE_URL)
```
从 `Token exchange failed` 这个字符串看,`generic-oauth.ts` 和 `grok-oauth-login.ts` 应该也是同样的写法,建议一并检查。
**关键点:仓库里其实已经有这个问题的解决方案,只是没接到这几处。**
`apps/desktop/src/main/maker-host/outbound-proxy-resolver.ts` 的模块注释描述的正是同一个场景:
> 本地 loopback proxy(anthropic-compat-proxy / codex proxy)转发上游用的是 Node http 栈,不读系统代理也不读代理环境变量;用户代理软件跑「系统代理」模式(非 TUN)时,浏览器 / Codex CLI 正常而 Cindy 上游连接裸直连失败
它已经实现了「代理环境变量 → Electron `session.resolveProxy`」两层解析,但目前只注入给了两个 loopback proxy host。Codex 侧因此工作正常(日志可见 `[outbound-proxy] outbound proxy resolved upstream=https://chatgpt.com:443 source=env proxy=http://127.0.0.1:10810`),Anthropic 的 OAuth / 模型发现路径则完全没有走这套,形成了同一个 App 内两套不一致的出网策略。
## 建议修复
**方案 A(推荐)**:复用现有的 `outbound-proxy-resolver`,为主进程 fetch 构造 undici dispatcher。`undici` 已经是依赖(8.7.0),`ProxyAgent` / `EnvHttpProxyAgent` 都可用:
```ts
import { ProxyAgent } from 'undici';
const proxyUrl = await resolveOutboundProxy(TOKEN_URL); // env → session.resolveProxy
const res = await fetch(TOKEN_URL, {
...init,
...(proxyUrl ? { dispatcher: new ProxyAgent(proxyUrl) } : {}),
});
```
**方案 B**:把这几处换成 Electron 的 `net.fetch`,直接走 Chromium 网络栈,自动遵守系统代理与 PAC,不需要额外解析逻辑。
另外建议在解析出代理后把结果写回 `process.env.HTTP_PROXY` / `HTTPS_PROXY` / `NO_PROXY`,这样派生的 `claude.exe`(Claude Code CLI 同样只认代理环境变量)也能继承到——否则即使授权成功,GUI 启动下的实际对话请求仍然会直连。
## 两个附带建议
**1. 403 的错误提示应当可区分。** 目前只显示 `Token exchange failed (403)`,用户无法判断是账号问题还是网络问题。403 + `Request not allowed` 是明确的地区/网络信号,可以直接提示「请检查网络代理设置」,避免用户反复重试授权。
**2. 刷新路径对非 JSON 错误响应的处理。** `claude-oauth-refresh.ts` 里:
```ts
if (res.status !== 200) {
let errorCode = '';
try { const body = await res.json(); errorCode = body.error; } catch {}
return { oauth: null, invalidGrant: errorCode === 'invalid_grant' };
}
```
Cloudflare 的 403 返回 HTML,`res.json()` 抛异常 → `errorCode` 为空 → `invalidGrant: false`。这个 fail-safe 方向是对的(不会误清凭证),但结果是网络故障和真正的 token 失效在日志里表现相近,建议把 `res.status` 和 body 前缀一起记进日志便于排查。
**3. 复用本机已有的 Claude 凭证。** Codex 侧有 `claimDetectedNativeProviderAuth('openai', ...)` 会自动认领本机已登录的凭证,Anthropic 侧没有对应逻辑,只能走 OAuth。已经用 Claude Code CLI 登录过、`~/.claude/.credentials.json` 有效的用户本可以直接连上——这既是体验优化,也能让处于受限网络的用户绕开上述问题。
Contributor guide
Research direction
Start with apps/desktop/src/main/maker-host/outbound-proxy-resolver.ts, then inspect the fetch call sites in claude-oauth-login.ts, claude-oauth-refresh.ts, and model-discovery/anthropic.ts. Determine the intended dispatcher or Electron network path and check generic-oauth.ts and grok-oauth-login.ts as suggested. Done means system-proxy mode works consistently for Claude authorization, model discovery, and token refresh without breaking direct connections.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- electron, typescript
- Domain
- authentication, backend, networking
- Issue type
- Bug
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Activity status
- Active
- Clarity
- Mostly clear
- Newbie friendliness
- 58/100