makecindy / makecindy/cindy

[Bug] 主进程 fetch 未接 outbound-proxy-resolver,系统代理模式下 Claude 授权 / 模型发现 / token 刷新全部 403

Open
#545 1 comment 0 reactions 0 assignees View on GitHub
bug
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

Open the contributing 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

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.