From 57bcac92dec960c649235ffaace73aed1cae68df Mon Sep 17 00:00:00 2001 From: wjf1 Date: Sun, 20 Sep 2026 23:19:21 +0800 Subject: [PATCH] =?UTF-8?q?fix(audit):=20=E6=B6=88=E9=99=A4=207=20?= =?UTF-8?q?=E5=A4=84=E3=80=8C=E7=9C=8B=E8=B5=B7=E6=9D=A5=E5=9C=A8=E5=B7=A5?= =?UTF-8?q?=E4=BD=9C=E3=80=81=E5=AE=9E=E9=99=85=E6=B2=A1=E5=B7=A5=E4=BD=9C?= =?UTF-8?q?=E3=80=8D=E7=9A=84=E9=9D=99=E9=BB=98=E5=A4=B1=E6=95=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 架构审查后整改清单的批次 A。这一批的共同特征:都不是写错的代码,而是没人 验证过的代码路径。A5/A6 是有实际后果的行为变更,正文里单独标注。 1. onRetry 死路径 —— 撞额度时重试途中的换号从来没生效过(4.17.0「已知问题」 已记录)。这条路径上实际有三层缺陷,只补第一层仍然不会切号: - sendToCC 从不 await opts.onRetry; - headers 在重试循环之外构建一次,换号后仍带着旧 key; - 路由里的回调给局部变量 apiKey 赋值,而 opts.apiKey 早在构造参数对象时 就把旧值快照进去了。 契约随之调整:onRetry 现在**返回**下一次要用的 apiKey(返回 undefined = 沿用当前 key)。 行为变更:撞额度时会在重试途中切到另一个账号,该请求的上游归属随之改变。 注:rotationMode !== 'auto-quota' 或 accounts.length <= 1 时回调必然返回 undefined(config.ts:479),单账号 manual 部署观测不到差别。 2. upstream.timeoutMs 从此真正生效 —— 此前它被加载、写入默认值、并在 /api/status 与仪表盘展示,但 src/ 里 0 个消费点;唯一起作用的 idleTimeoutMs 每收到一个字节就重置,因此一个持续 trickle 的上游可以无限期挂住连接。新增 跨「等响应头 + 读流」两阶段的挂钟上限,超时归类为既有的 REQUEST_TIMEOUT。 行为变更 / 需要关注:任何长度超过 timeoutMs(默认 600s)的请求现在会被切断。 此前它能活下来纯粹是因为这个配置不执行。 3. npm test 不再对推理路由零覆盖地报全绿 —— 本仓库所有集成用例都 spawn 编译 产物,缺 dist/ 时被 describe.skipIf 静默跳过(实测:未构建时 244 passed / 42 skipped 且退出码 0)。现在 pretest 自动构建,并在 beforeAll 首行加了明确 报错。注意 vitest 在一个文件没有任何可运行用例时不会执行文件级 beforeAll, 所以 tests/integration.test.ts 里那条不带 skipIf 的前置用例是这套防护的触发器。 4. 「发现新版本」提示恢复工作 —— 改为读 /tags 并按 semver 取最大。此前读的是 releases/latest,而本仓库只打 tag 不建 Release 对象:实测 releases/latest 停在 v4.12.0 而 tag 已到 v4.17.0,于是自 v4.13.0 起该提示永远不会触发。(另一条路线 是恢复创建 GitHub Release 并补齐缺的 5 个,未在本次改动内。) 5. /api/auth/manual-login 不再明文回传上游 apiKey,改为与 /api/accounts 一致的 apiKeyMasked。loginNewAccount 的返回类型仍带完整凭据(内部调用方需要),收口 在 HTTP 边界。SPA 只读 status/error 与 apiKeyMasked(index.html:471,493,523), 无消费方受影响。 6. 仪表盘 badge() 转义 text —— title 参数一直走 esc(),text 却是裸拼进 innerHTML, 而调用点把上游定价页抓来的 m.deal.discountPercent 直接传入。全站仍无 CSP, 故属纵深防御缺口。 7. pkg.assets 移除 models.json —— 它是运行时生成的缓存且已在 .gitignore 里, 全新克隆上打包会静默缺该资产。 附带修掉本次新增测试引入的一处自伤:admin-key-mask 走进程内 app.inject,而 .env 路径按 getProjectRootDir()(=cwd) 解析,于是账号保存的副作用把 .env(含 COMMANDCODE_API_BASE)写进了仓库根;env 的优先级高于 config.json(config.ts:264), 且 .env 命中 .gitignore 在 git status 里看不见。新增 COMMANDCODE_ENV_PATH 覆盖, 与 CONFIG_PATH / MODELS_CACHE_PATH 同一套约定,不设置时行为一字不变。 测试:新增 5 个文件共 16 项,全部先观察到失败再实现。全量 304 项通过(原 288+16), tsc --noEmit 与 eslint . 无错误;语句覆盖率 48.36% → 57.57%。 覆盖率读数说明:先前记录的 57.8% 偏高,那多出的 12 条语句是仓库根存在 .env 时被 顺带跑到的 loadEnvFile() 解析路径 —— 也就是说该路径至今没有直接测试覆盖。 本次未改动但已记入 CHANGELOG「已知问题」:管理面默认零鉴权与 /v1、/api 共用一把 密钥(批次 B);syncEnvFile 把 apiBase/ccVersion 硬编码成默认值(config.ts:342-343), 用自建上游的人动一次账号就会在下次重启被静默改回公网默认。 --- CHANGELOG.md | 35 ++++++++ package.json | 2 +- public/index.html | 2 +- src/adapters/commandcode/upstream.ts | 64 +++++++++++++- src/routes/chat.ts | 4 + src/routes/dashboard.ts | 8 +- src/routes/messages.ts | 4 + src/utils/config.ts | 4 +- src/utils/update-check.ts | 63 ++++++++++++-- tests/admin-key-mask.test.ts | 95 +++++++++++++++++++++ tests/integration.test.ts | 24 +++++- tests/onretry-account-switch.test.ts | 121 +++++++++++++++++++++++++++ tests/spa-badge-escape.test.ts | 83 ++++++++++++++++++ tests/update-check-tags.test.ts | 41 +++++++++ tests/upstream-total-timeout.test.ts | 118 ++++++++++++++++++++++++++ 15 files changed, 652 insertions(+), 16 deletions(-) create mode 100644 tests/admin-key-mask.test.ts create mode 100644 tests/onretry-account-switch.test.ts create mode 100644 tests/spa-badge-escape.test.ts create mode 100644 tests/update-check-tags.test.ts create mode 100644 tests/upstream-total-timeout.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index baf0cc8..d653328 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,41 @@ 所有主要版本更新都记录在此文件。 +## [Unreleased] + +架构审查(批次 A)修复。7 项中有 2 项是**行为变更**,已单独标注。 + +### 修复 +- **额度/瞬时错误重试时的账号切换现在真正生效**(原 `onRetry` 死代码,见 4.17.0「已知问题」)。这条路径上实际有**三层**缺陷,只补第 1 层仍然不会切号: + 1. `sendToCC` 从不 `await opts.onRetry`; + 2. `headers` 在重试循环**之外**构建一次,换号后仍带旧 key; + 3. 路由里的回调给局部变量 `apiKey` 赋值,而 `opts.apiKey` 早在构造参数对象时把旧值快照进去了。 + 契约随之调整:`onRetry` 现在**返回**下一次要用的 apiKey(返回 undefined = 沿用当前 key)。 + **行为变更**:撞额度时会在重试途中切到另一个账号,该请求的上游归属随之改变。 +- **`upstream.timeoutMs` 从此真正生效**。此前它被加载、写入默认值、并在 `/api/status` 与仪表盘展示,但 `src/` 里 0 个消费点——唯一起作用的是 `idleTimeoutMs`,而它每收到一个字节就重置,因此一个持续 trickle 的上游可以无限期挂住连接。新增跨"等响应头 + 读流"两阶段的挂钟上限,超时归类为既有的 `REQUEST_TIMEOUT`。 + **行为变更 / 需要关注**:任何长度超过 `timeoutMs`(默认 600s)的请求现在会被切断。此前它能活下来纯粹是因为这个配置不执行。长推理会话请确认 600s 是否合适,必要时在 `config.json` 调大。 +- **`npm test` 不再对推理路由零覆盖地报全绿**。本仓库所有集成用例都 `spawn` 编译产物,缺 `dist/` 时被 `describe.skipIf` 静默跳过(实测:未构建时 244 passed / 42 skipped 且退出码 0)。现在 `pretest` 自动构建,并在 `beforeAll` 首行加了明确报错。注意 vitest 在一个文件没有任何可运行用例时**不会执行文件级 beforeAll**,所以 `tests/integration.test.ts` 里那条不带 skipIf 的前置用例是这套防护的触发器,删除它会退回老行为。 +- **「发现新版本」提示恢复工作**:改为读 `/tags` 并按 semver 取最大。此前读的是 `releases/latest`,而本仓库只打 tag 不建 Release 对象——实测 `releases/latest` 停在 v4.12.0 而 tag 已到 v4.17.0,于是自 v4.13.0 起该提示永远不会触发。(另一条路线是恢复创建 GitHub Release,未在本次改动内。) +- **`/api/auth/manual-login` 不再明文回传上游 apiKey**,改为与 `/api/accounts` 一致的 `apiKeyMasked`。`loginNewAccount` 的返回类型仍带完整凭据(内部调用方需要),收口在 HTTP 边界。 +- **仪表盘 `badge()` 转义 text**:`title` 参数一直走 `esc()`,`text` 却是裸拼进 innerHTML,而调用点把上游定价页抓来的 `m.deal.discountPercent` 直接传入。全站仍无 CSP,故属纵深防御缺口。 + +### 构建 +- `pkg.assets` 移除 `models.json`:它是运行时生成的缓存且已在 `.gitignore` 里,全新克隆上打包会静默缺该资产。 + +### 测试 +- 新增 5 个文件共 16 项,全部先观察到失败再实现:`onretry-account-switch`(断言上游实际收到的 `Authorization` 头变化,而非"回调被调用过")、`upstream-total-timeout`(把 `idleTimeoutMs` 刻意设得大于总时限,使超时只能归因于挂钟上限)、`admin-key-mask`、`spa-badge-escape`(取出 index.html 里真实的 `badge`/`esc`/`BADGE_TONES` 源码执行)、`update-check-tags`。 +- 新增 `COMMANDCODE_ENV_PATH` 覆盖(与 `COMMANDCODE_CONFIG_PATH` / `..._MODELS_CACHE_PATH` 等同一套约定,不设置时行为不变)。动机是修一处测试自伤:`admin-key-mask` 走进程内 `app.inject`,`.env` 路径按 `getProjectRootDir()`(=cwd) 解析,于是它把一次账号保存的副作用写进了**仓库根 `.env`**;而 `.env` 在启动时被回注且 `COMMANDCODE_API_BASE`/`COMMANDCODE_VERSION` 的优先级高于 `config.json`(`config.ts:264`)——在既当部署目录又当源码目录的地方跑一次测试,下次启动就会被指到别处。`.env` 命中 `.gitignore:4`,`git status` 看不见它。基线(4.17.0)跑测试只留 `logs/`、`models.json`(同样被忽略),不写 `.env`,所以这条是本次新增测试带来的。 +- 全量 **304 项通过**(原 288 + 16),`tsc --noEmit`、`eslint .` 无错误;语句覆盖率 48.36% → **57.57%(1460/2536,无污染下的真实值)**。 + - 顺带修正一处读数:早先记的 57.8% 偏高,因为仓库根存在 `.env` 时 `loadEnvFile()` 的解析分支会被顺带跑到的语句计入覆盖。实测同一棵树:有仓库根 `.env` → 1472 条,无 → 1460 条,差 12 条。反过来说,`loadEnvFile()` 的正经解析路径目前**没有任何直接覆盖**,靠副作用才被动跑到——补测试归入后续批次。 + +### 已知问题(本次排查中发现,未修) +- **管理面默认零鉴权**:`PROXY_API_KEY` 未设置时 `verifyProxyAuth` 直接 return,`/api/*` 16 个管理端点完全无鉴权;且 `/v1` 数据面与 `/api` 管理面共用同一把密钥,未做权限分离。跨站驱动已被 `isSameOriginIfPresent` 挡住,但该检查比对的是**攻击者可控的 `Host` 头**,DNS rebinding 可绕过(无 Host 白名单)。属需要设计决策的独立批次,未随本次一起改。 +- 出站 fetch 未设 `redirect:'manual'`,`assertSafeUpstreamUrl` 只校验初始 URL,二跳可逃逸 SSRF 白名单。 +- OAuth 回调在 `state` 缺失时放行(`config.ts` 注释说明是为兼容旧版 CLI 的有意取舍)。 +- 密钥以明文写入 `config.json` / `.env`,无文件权限加固;`.env` 会被回注 `process.env`。 +- **`syncEnvFile` 把 `apiBase`/`ccVersion` 写死成默认值**(`config.ts:342-343`),而回注时这两个 env 的优先级高于 `config.json`(`config.ts:264`)。于是一个用自建/反代上游的人:在仪表盘加或切换一次账号 → `.env` 被写入 `COMMANDCODE_API_BASE=https://api.commandcode.ai` → 下次重启后他的自定义 `upstream.apiBase` 被**静默改回公网默认**。默认部署里两者取值相同(都是 `https://api.commandcode.ai` / `1.27.1`),所以这台机器上不会触发,属潜伏缺陷。修法是把写入值改成生效配置,或干脆不写这两行。 +- `rewriteSafely`(仅 dev 工具,不在服务路径)的 `renameSync` 无 try/catch,Windows 上偶发 `EPERM` 会抛出并泄漏临时文件。 + ## [4.17.0] - 2026-09-18 ### 修复 diff --git a/package.json b/package.json index c345e50..b9022ff 100644 --- a/package.json +++ b/package.json @@ -26,13 +26,13 @@ "build:exe": "npm run build && esbuild dist/index.js --bundle --platform=node --format=cjs --outfile=dist/bundle.cjs --external:fsevents", "build:win": "npm run build:exe && pkg dist/bundle.cjs --target node18-win-x64 --output dist/commandcode-proxy-v4.exe --compress GZip", "test": "vitest run", + "pretest": "npm run build", "typecheck": "tsc --noEmit", "lint": "eslint .", "test:coverage": "vitest run --coverage" }, "pkg": { "assets": [ - "models.json", "public/**/*" ], "outputPath": "dist" diff --git a/public/index.html b/public/index.html index 6800119..9f73ef9 100644 --- a/public/index.html +++ b/public/index.html @@ -669,7 +669,7 @@

' + text + ''; + (title ? ' title="' + esc(title) + '"' : '') + '>' + esc(text) + ''; } // 档位药丸:可用=实心强调色,不可用=灰底。两枚固定占位,卡片高度一致, // 整列纵向对齐后即可一眼比较 GO / GOAT 的差别。 diff --git a/src/adapters/commandcode/upstream.ts b/src/adapters/commandcode/upstream.ts index 767a2ca..ce8fb59 100644 --- a/src/adapters/commandcode/upstream.ts +++ b/src/adapters/commandcode/upstream.ts @@ -112,8 +112,14 @@ function sleep(ms: number): Promise { export interface SendOptions { apiKey: string; abortSignal?: AbortSignal; - /** 每次重试前回调,调用方可在额度错误时切换账号。 */ - onRetry?: (attempt: number, err: UpstreamError) => void | Promise; + /** + * 每次重试前回调,调用方可在额度错误时切换账号。 + * + * 返回**下一次尝试要用的 apiKey**;返回 undefined / 不返回表示沿用当前 key。 + * 之所以要返回而不是就地改外部变量:apiKey 在本对象构造时已被快照,回调再去改 + * 调用方的局部变量对这里没有任何影响。 + */ + onRetry?: (attempt: number, err: Error) => string | undefined | Promise; /** * 流内事件的预判钩子:决定「丢弃本次调用重试」还是「放行给调用方」。 * @@ -325,9 +331,24 @@ export async function sendToCC(body: CCRequestBody, opts: SendOptions): Promise< body.permissionMode = 'auto-accept'; body.params.stream = true; - const headers = buildHeaders(opts.apiKey, config.ccVersion, body); + // headers 必须在重试循环**内部**构建:onRetry 换账号后,旧 key 不能再用于下一次尝试。 + let currentApiKey = opts.apiKey; const reqData = JSON.stringify(body); + // 切号失败(轮换回调自己打上游打挂)不该让本次重试作废,因此只记日志不抛。 + const maybeSwitchAccount = async (attempt: number, err: Error): Promise => { + if (!opts.onRetry) return; + try { + const next = await opts.onRetry(attempt, err); + if (next && next !== currentApiKey) { + currentApiKey = next; + logger.info(`[UPSTREAM] Thread ${body.threadId} | Account switched on retry ${attempt} (key tail ${String(next).slice(-4)})`); + } + } catch (cbErr: any) { + logger.warn(`[UPSTREAM] onRetry callback failed: ${cbErr?.message || cbErr}`); + } + }; + if (MAX_UPSTREAM_CONCURRENCY > 0) { if (activeUpstreamRequests >= MAX_UPSTREAM_CONCURRENCY) { throw new UpstreamError( @@ -353,9 +374,32 @@ export async function sendToCC(body: CCRequestBody, opts: SendOptions): Promise< // 循环内任何 throw 都先释放并发槽位;成功路径的释放挂在返回流的 close/error 上。 try { for (let attempt = 1; attempt <= maxAttempts; attempt++) { + const headers = buildHeaders(currentApiKey, config.ccVersion, body); const timeoutController = new AbortController(); let idleTimer: NodeJS.Timeout | null = null; let idleFired = false; + let deadlineTimer: NodeJS.Timeout | null = null; + let deadlineFired = false; + + // 挂钟总时限(upstream.timeoutMs)。与空闲看门狗的本质区别:看门狗每收到一个字节 + // 就会重置,所以一个持续 trickle 的上游可以无限期挂住连接;这个上限跨"等响应头" + // 与"读流"两个阶段一次性生效,直到流结束才撤销。 + // + // 注意:这是一次**行为变更**——修复前该配置完全不起作用,任何长度超过 timeoutMs + // 的长推理请求都是靠它不被执行才活下来的。 + const armDeadline = () => { + if (deadlineTimer) clearTimeout(deadlineTimer); + deadlineTimer = setTimeout(() => { + deadlineFired = true; + timeoutController.abort(new Error(`Upstream exceeded ${config.upstreamTimeoutMs / 1000}s total deadline`)); + }, config.upstreamTimeoutMs); + deadlineTimer.unref?.(); + }; + armDeadline(); + + const disarmDeadline = () => { + if (deadlineTimer) { clearTimeout(deadlineTimer); deadlineTimer = null; } + }; // 空闲看门狗:每次被调用都会重置计时器。一旦上游超过 idleTimeoutMs 无数据, // 主动 abort 本次请求并标记 idleFired,抛"上游卡死"错误。 @@ -406,6 +450,7 @@ export async function sendToCC(body: CCRequestBody, opts: SendOptions): Promise< ); if (retryable && attempt < maxAttempts) { lastError = err; + await maybeSwitchAccount(attempt, err); // 指数退避:500ms * 2^(attempt-1),封顶 8s。 const backoffMs = Math.min(8000, 500 * Math.pow(2, attempt - 1)); logger.warn(`[UPSTREAM] Retryable ${response.status}, retry ${attempt}/${maxAttempts - 1} in ${backoffMs}ms`); @@ -428,6 +473,7 @@ export async function sendToCC(body: CCRequestBody, opts: SendOptions): Promise< let discarded = false; const onStreamGone = () => { if (idleTimer) clearTimeout(idleTimer); + disarmDeadline(); if (!discarded) releaseSlot(); }; rawStream.on('close', onStreamGone); @@ -457,6 +503,17 @@ export async function sendToCC(body: CCRequestBody, opts: SendOptions): Promise< return rawStream; } catch (err: any) { if (idleTimer) clearTimeout(idleTimer); + disarmDeadline(); + + // 挂钟上限先于空闲判定:两者的 abort 都走 isAbortError,但成因与错误码不同。 + if (deadlineFired) { + throw new UpstreamError( + `Upstream exceeded ${config.upstreamTimeoutMs / 1000}s total deadline`, + 504, + false, + ErrorCode.REQUEST_TIMEOUT, + ); + } if (isAbortError(err)) { if (idleFired) { @@ -478,6 +535,7 @@ export async function sendToCC(body: CCRequestBody, opts: SendOptions): Promise< if (attempt < maxAttempts) { const backoffMs = Math.min(8000, 500 * Math.pow(2, attempt - 1)); logger.warn(`[UPSTREAM] Thread ${body.threadId} | Upstream failure (${err.message}), retry ${attempt}/${maxAttempts - 1} in ${backoffMs}ms`); + await maybeSwitchAccount(attempt, err); await sleep(backoffMs); continue; } diff --git a/src/routes/chat.ts b/src/routes/chat.ts index 206d5c0..086aae4 100644 --- a/src/routes/chat.ts +++ b/src/routes/chat.ts @@ -139,9 +139,13 @@ export async function chatRoutes(fastify: FastifyInstance) { abortSignal: abortController.signal, onRetry: async () => { // auto-quota 模式下重试可能落到一个新账号上。 + // 必须把新 key **返回**给 sendToCC:opts.apiKey 在构造时已快照,就地改局部 + // 变量对下一次尝试没有任何影响(P0-4 的第三层缺陷)。 if (await checkAndRotateAccountsOnQuota()) { apiKey = getActiveApiKey(); + return apiKey; } + return undefined; }, }); } catch (err: any) { diff --git a/src/routes/dashboard.ts b/src/routes/dashboard.ts index 7793756..30c0ca9 100644 --- a/src/routes/dashboard.ts +++ b/src/routes/dashboard.ts @@ -227,7 +227,13 @@ export async function dashboardRoutes(fastify: FastifyInstance) { if (!apiKey) return reply.status(400).send({ error: 'API key is required' }); try { const acc = await loginNewAccount(String(apiKey), name ? String(name).slice(0, 60) : undefined); - return { status: 'success', account: acc }; + // 明文 apiKey 绝不出接口:loginNewAccount 的返回类型带完整凭据(内部调用方需要), + // 这里是它到 HTTP 响应体的唯一出口。掩码范式与 /api/accounts 一致。 + const { apiKey: _plaintext, ...safeAccount } = acc; + return { + status: 'success', + account: { ...safeAccount, apiKeyMasked: acc.apiKey ? `${acc.apiKey.slice(0, 8)}...${acc.apiKey.slice(-4)}` : 'None' }, + }; } catch (err: any) { return reply.status(500).send({ error: err.message }); } diff --git a/src/routes/messages.ts b/src/routes/messages.ts index cb9db1b..5f5a0d4 100644 --- a/src/routes/messages.ts +++ b/src/routes/messages.ts @@ -114,9 +114,13 @@ export async function messagesRoutes(fastify: FastifyInstance) { apiKey, abortSignal: abortController.signal, onRetry: async () => { + // 必须把新 key **返回**给 sendToCC:opts.apiKey 在构造时已快照,就地改局部 + // 变量对下一次尝试没有任何影响(P0-4 的第三层缺陷)。 if (await checkAndRotateAccountsOnQuota()) { apiKey = getActiveApiKey(); + return apiKey; } + return undefined; }, }); } catch (err: any) { diff --git a/src/utils/config.ts b/src/utils/config.ts index d1ae4f8..c6b4979 100644 --- a/src/utils/config.ts +++ b/src/utils/config.ts @@ -25,7 +25,9 @@ export { getProjectRootDir }; export const CONFIG_FILE_PATH = process.env.COMMANDCODE_CONFIG_PATH ? path.resolve(process.env.COMMANDCODE_CONFIG_PATH) : path.join(getProjectRootDir(), 'config.json'); -const ENV_FILE_PATH = path.join(getProjectRootDir(), '.env'); +const ENV_FILE_PATH = process.env.COMMANDCODE_ENV_PATH + ? path.resolve(process.env.COMMANDCODE_ENV_PATH) + : path.join(getProjectRootDir(), '.env'); const DEFAULTS = { port: 9090, diff --git a/src/utils/update-check.ts b/src/utils/update-check.ts index 434affe..5631b8e 100644 --- a/src/utils/update-check.ts +++ b/src/utils/update-check.ts @@ -8,7 +8,8 @@ import { logger } from './logger.js'; import { PROXY_VERSION } from './version.js'; -const RELEASES_API = 'https://api.github.com/repos/wjf1/commandcode-proxy/releases/latest'; +const TAGS_API = 'https://api.github.com/repos/wjf1/commandcode-proxy/tags?per_page=100'; +const RELEASES_PAGE = 'https://github.com/wjf1/commandcode-proxy/releases'; const CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000; const state: { latest: string | null; checkedAt: number } = { latest: null, checkedAt: 0 }; @@ -18,12 +19,55 @@ function parseSemver(v: string): [number, number, number] { return m ? [Number(m[1]), Number(m[2]), Number(m[3])] : [0, 0, 0]; } +/** + * 严格版:只有 x.y.z 形态才算版本号。 + * 与 parseSemver 分开是有意的 —— 后者对不认识的输入回退 [0,0,0], + * 用于"比较两个已知版本"没问题,但会把 'latest' 这类 tag 当成 0.0.0 参与择优。 + */ +function parseTagVersion(v: string): [number, number, number] | null { + const m = /^v?(\d+)\.(\d+)\.(\d+)$/.exec(v.trim()); + return m ? [Number(m[1]), Number(m[2]), Number(m[3])] : null; +} + +function compareSemver(a: [number, number, number], b: [number, number, number]): number { + for (let i = 0; i < 3; i++) { + if (a[i] !== b[i]) return a[i] - b[i]; + } + return 0; +} + export function isNewerVersion(latest: string, current: string): boolean { const a = parseSemver(latest); const b = parseSemver(current); return a[0] > b[0] || (a[0] === b[0] && a[1] > b[1]) || (a[0] === b[0] && a[1] === b[1] && a[2] > b[2]); } +/** + * 从 GitHub `/tags` 的响应里取最大 semver tag。 + * + * 不能用 releases/latest:本项目只打 tag、不创建 Release 对象,releases/latest + * 会永久停在最后一次手工建 Release 的版本上(实测 v4.12.0),使"发现新版本"失效。 + * GitHub 也不保证 /tags 按版本序返回,因此必须比较后取最大。 + * 对脏数据(非数组、null 项、非版本号 tag、字段类型错误)一律忽略而非抛错。 + */ +export function pickLatestTag(items: unknown): string | null { + if (!Array.isArray(items)) return null; + let best: string | null = null; + let bestKey: [number, number, number] | null = null; + + for (const item of items) { + const name = (item as { name?: unknown } | null)?.name; + if (typeof name !== 'string' || !name.trim()) continue; + const key = parseTagVersion(name); + if (key === null) continue; + if (!bestKey || compareSemver(key, bestKey) > 0) { + bestKey = key; + best = name.trim(); + } + } + return best; +} + /** 仪表盘 /api/status 用:当前版本 vs 已知的最新发布版。 */ export function getUpdateState(): { available: boolean; latest: string | null; current: string } { return { @@ -35,19 +79,22 @@ export function getUpdateState(): { available: boolean; latest: string | null; c export async function checkForUpdate(): Promise { try { - const res = await fetch(RELEASES_API, { + // per_page=100 覆盖当前全部 tag(26 个)。若将来 tag 数超过 100,GitHub 不保证 + // /tags 按版本序返回,需要改为分页取最大或换用 git ls-remote 侧的排序端点。 + const res = await fetch(TAGS_API, { headers: { 'User-Agent': 'commandcode-proxy', Accept: 'application/vnd.github+json' }, signal: AbortSignal.timeout(8000), }); if (!res.ok) return; - const data = (await res.json()) as { tag_name?: string }; - if (typeof data.tag_name === 'string' && data.tag_name) { - state.latest = data.tag_name; + const data = await res.json(); + const latest = pickLatestTag(data); + if (latest) { + state.latest = latest; state.checkedAt = Date.now(); - if (isNewerVersion(data.tag_name, PROXY_VERSION)) { + if (isNewerVersion(latest, PROXY_VERSION)) { logger.info( - `[UPDATE] New version available: ${data.tag_name} (current ${PROXY_VERSION}) — ` + - 'https://github.com/wjf1/commandcode-proxy/releases', + `[UPDATE] New version available: ${latest} (current ${PROXY_VERSION}) — ` + + RELEASES_PAGE, ); } } diff --git a/tests/admin-key-mask.test.ts b/tests/admin-key-mask.test.ts new file mode 100644 index 0000000..25138ce --- /dev/null +++ b/tests/admin-key-mask.test.ts @@ -0,0 +1,95 @@ +// ============================================================================= +// P1-4 回归:管理面不得把明文上游 apiKey 回传前端。 +// ----------------------------------------------------------------------------- +// 同文件内 /api/accounts 与 /api/usage/aggregate 都已经用 apiKeyMasked 做掩码, +// manual-login 是唯一漏网的出口。断言打在真实 HTTP 响应上,而不是函数内部。 +// ============================================================================= +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import Fastify from 'fastify'; +import type { FastifyInstance } from 'fastify'; +import http from 'node:http'; +import { mkdtempSync, existsSync, readFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { randomUUID } from 'node:crypto'; +import type { AddressInfo } from 'node:net'; + +const PLAINTEXT_KEY = `ck-plaintext-${randomUUID()}--do-not-leak`; + +let app: FastifyInstance; +let upstream: http.Server; +let envFilePath: string; + +beforeAll(async () => { + const stateDir = mkdtempSync(path.join(tmpdir(), 'ccproxy-mask-')); + + // 真实 loginNewAccount 会打 whoami / billing;用本地 mock 上游喂给它, + // 不 mock 我们自己的逻辑。 + upstream = http.createServer((req, res) => { + const p = new URL(req.url || '/', 'http://127.0.0.1').pathname; + res.setHeader('Content-Type', 'application/json'); + if (p === '/alpha/whoami') { + res.end(JSON.stringify({ success: true, user: { id: 'u9', name: 'Mask Tester', userName: 'masktest' } })); + return; + } + res.end(JSON.stringify({ success: true })); + }); + await new Promise(r => upstream.listen(0, '127.0.0.1', r)); + const base = `http://127.0.0.1:${(upstream.address() as AddressInfo).port}`; + + process.env.COMMANDCODE_API_BASE = base; + process.env.COMMANDCODE_UPSTREAM_ALLOWED_HOSTS = '127.0.0.1'; + process.env.COMMANDCODE_CONFIG_PATH = path.join(stateDir, 'config.json'); + process.env.COMMANDCODE_MODELS_CACHE_PATH = path.join(stateDir, 'models.json'); + process.env.COMMANDCODE_PRICING_CACHE_PATH = path.join(stateDir, 'pricing.json'); + process.env.USAGE_HISTORY_PATH = path.join(stateDir, 'usage.jsonl'); + // 写入新账号会同步 .env(供免重启回注)。它默认落在 cwd,而 vitest 的 cwd 就是 + // 仓库根——在真实部署目录里跑测试会把 mock 的 COMMANDCODE_API_BASE 写进生产 .env。 + envFilePath = path.join(stateDir, '.env'); + process.env.COMMANDCODE_ENV_PATH = envFilePath; + + const { dashboardRoutes } = await import('../src/routes/dashboard.js'); + app = Fastify(); + await app.register(dashboardRoutes); + await app.ready(); +}); + +afterAll(async () => { + await app?.close(); + await new Promise(r => upstream.close(() => r())); +}); + +describe('POST /api/auth/manual-login 凭据外泄(P1-4)', () => { + it('响应体任何位置都不出现明文 apiKey', async () => { + const res = await app.inject({ + method: 'POST', + url: '/api/auth/manual-login', + payload: { apiKey: PLAINTEXT_KEY, name: 'manual' }, + }); + + expect(res.statusCode).toBe(200); + expect(res.body).not.toContain(PLAINTEXT_KEY); + }); + + it('账号以掩码形式回传,且可被前端识别', async () => { + const res = await app.inject({ + method: 'POST', + url: '/api/auth/manual-login', + payload: { apiKey: PLAINTEXT_KEY }, + }); + const json = res.json(); + const account = json.account ?? json; + + expect(account.id).toBeTruthy(); + expect(account.apiKey).toBeUndefined(); + // 与 /api/accounts (dashboard.ts:192) 同一套掩码范式 + expect(account.apiKeyMasked).toBe(`${PLAINTEXT_KEY.slice(0, 8)}...${PLAINTEXT_KEY.slice(-4)}`); + }); + + // 落盘断言而非「仓库根不该有 .env」:后者会被开发者自己放的 .env 误判。 + // .env 本身就是明文凭据存储(审查另记),这里只锁住它**落在哪里**。 + it('保存账号时 .env 跟随 COMMANDCODE_ENV_PATH,而不是写进测试进程的 cwd', () => { + expect(existsSync(envFilePath)).toBe(true); + expect(readFileSync(envFilePath, 'utf-8')).toContain('ACCOUNTS_COUNT=1'); + }); +}); diff --git a/tests/integration.test.ts b/tests/integration.test.ts index 00e45dc..fd7bc68 100644 --- a/tests/integration.test.ts +++ b/tests/integration.test.ts @@ -25,10 +25,24 @@ const PROXY_PORT = await getFreePort(); const PROXY_BASE = `http://127.0.0.1:${PROXY_PORT}`; // 集成测试启动的是编译产物 dist/index.js;干净克隆上没有 dist 会必然超时。 -// 此时跳过整个套件并提示先 `npm run build`,避免 `npm test` 一上来就红。 +// 各 describe 上的 describe.skipIf 用于避免整片红。 +// +// 但"静默跳过"本身是个陷阱:它曾让 42 项端到端用例(两条推理路由的 SSE、重试循环、 +// 错误契约、管理面)在没 build 的克隆上完全不执行,而 `npm test` 照样报全绿。 +// 防护放在下面 beforeAll 的第一行:缺产物就明确报错,而不是让跳过混在汇总行里。 +// (`npm test` 已配 pretest 自动 build;该守卫主要保护直接跑 `npx vitest run` 的路径。) const DIST_ENTRY = path.resolve(__dirname, '..', 'dist', 'index.js'); const distReady = existsSync(DIST_ENTRY); +// 这条**不带 skipIf** 的用例是刻意存在的:vitest 在一个文件里没有任何可运行用例时 +// 根本不会执行文件级 beforeAll,那就会退回到"42 skipped + 全绿"的老问题。 +// 有这条在,beforeAll 必然运行,缺产物时由它抛出上面那句人话。 +describe('集成套件前置条件', () => { + it('构建产物 dist/index.js 存在(否则本文件 42 项端到端用例会被静默跳过)', () => { + expect(distReady).toBe(true); + }); +}); + let mockServer: http.Server; let proxyProcess: ChildProcess; const capturedBodies: any[] = []; @@ -92,6 +106,14 @@ async function waitForUsage(predicate: (rs: any[]) => boolean, timeoutMs = 3000) } beforeAll(async () => { + // 缺产物时必须在这里说清楚,而不是往下走 spawn 换一句误导性的"没有就绪"。 + if (!distReady) { + throw new Error( + `缺少构建产物 ${DIST_ENTRY}。本套件 spawn 的是编译产物,未构建时下面所有用例都会被` + + ` skipIf 静默跳过(历史上曾因此让 npm test 对推理路由零覆盖地报全绿)。` + + `请先 npm run build —— 或直接 npm test(已配 pretest 自动构建)。`, + ); + } // ── Mock CommandCode upstream ── mockServer = http.createServer((req, res) => { let body = ''; diff --git a/tests/onretry-account-switch.test.ts b/tests/onretry-account-switch.test.ts new file mode 100644 index 0000000..7eedf46 --- /dev/null +++ b/tests/onretry-account-switch.test.ts @@ -0,0 +1,121 @@ +// ============================================================================= +// P0-4 回归:额度/瞬时错误重试时,onRetry 换出来的新账号必须真的作用到下一次请求。 +// ----------------------------------------------------------------------------- +// 断言的是**可观测行为**(上游收到的 Authorization 头),不是"回调被调用过"。 +// 这一点是刻意的:修复前存在三层缺陷,只让 onRetry 被调用仍然不足以让切号生效—— +// 1) upstream.ts 从不 await opts.onRetry +// 2) headers 在重试循环**之外**构建一次,之后不再重算 +// 3) 路由里的回调给局部变量 apiKey 赋值,而 opts.apiKey 早已把旧值快照进参数对象 +// 因此"Authorization 在第二次尝试时变成新 key"这条断言,能同时锁死这三层。 +// ============================================================================= +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import http from 'node:http'; +import { mkdtempSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import type { AddressInfo } from 'node:net'; + +const KEY_A = 'ck-account-A-0000000000'; +const KEY_B = 'ck-account-B-0000000000'; + +let server: http.Server; +let base = ''; +let seenAuth: Array = []; +let stateDir = ''; + +beforeAll(async () => { + stateDir = mkdtempSync(path.join(tmpdir(), 'ccproxy-onretry-')); + + server = http.createServer((req, res) => { + let body = ''; + req.on('data', c => (body += c)); + req.on('end', () => { + seenAuth.push(req.headers.authorization); + const attempt = seenAuth.length; + + // 第一次:可重试的瞬时失败(429 在 RETRYABLE_STATUS 内,且不含终止性计费标记)。 + if (attempt === 1) { + res.writeHead(429, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ message: 'Gateway request failed: no available provider' })); + return; + } + // 第二次:正常 SSE 流产出内容。 + res.writeHead(200, { 'Content-Type': 'text/event-stream' }); + res.end('data: {"type":"start"}\n\ndata: {"type":"delta","text":"ok-from-B"}\n\ndata: [DONE]\n\n'); + }); + }); + await new Promise(r => server.listen(0, '127.0.0.1', r)); + base = `http://127.0.0.1:${(server.address() as AddressInfo).port}`; + + // sendToCC 内部调用 loadConfig(),因此这些变量必须在**首次调用之前**就位。 + // vitest 默认按文件隔离模块注册表,本文件不会与其他套件串扰。 + process.env.COMMANDCODE_API_BASE = base; + process.env.COMMANDCODE_UPSTREAM_ALLOWED_HOSTS = '127.0.0.1'; + process.env.COMMANDCODE_CONFIG_PATH = path.join(stateDir, 'config.json'); + process.env.COMMANDCODE_MODELS_CACHE_PATH = path.join(stateDir, 'models.json'); + process.env.USAGE_HISTORY_PATH = path.join(stateDir, 'usage.jsonl'); +}); + +afterAll(async () => { + await new Promise(r => server.close(() => r())); +}); + +function makeBody() { + return { + threadId: 't-onretry', + params: { model: 'claude-sonnet-5', stream: false, messages: [{ role: 'user', content: 'hi' }] }, + config: { workingDir: stateDir }, + } as any; +} + +describe('sendToCC onRetry 账号切换(P0-4)', () => { + it('onRetry 返回新 apiKey 时,下一次尝试用它请求上游', async () => { + const { sendToCC } = await import('../src/adapters/commandcode/upstream.js'); + seenAuth = []; + let switchedTo: string | undefined; + + const stream = await sendToCC(makeBody(), { + apiKey: KEY_A, + // 契约:回调返回**要改用的 key**(返回 undefined = 沿用当前 key)。 + onRetry: async () => { + switchedTo = KEY_B; + return KEY_B; + }, + }); + + const text = await new Promise(resolve => { + let acc = ''; + stream.on('data', (c: Buffer) => (acc += c.toString())); + stream.on('end', () => resolve(acc)); + }); + + expect(switchedTo).toBe(KEY_B); + // 这一条锁死"headers 在循环内重建":旧实现两次都会带 KEY_A。 + expect(seenAuth).toEqual([`Bearer ${KEY_A}`, `Bearer ${KEY_B}`]); + expect(text).toContain('ok-from-B'); + }); + + it('onRetry 返回 undefined 时,重试沿用当前 key(不是清空凭据)', async () => { + const { sendToCC } = await import('../src/adapters/commandcode/upstream.js'); + seenAuth = []; + + const stream = await sendToCC(makeBody(), { + apiKey: KEY_A, + onRetry: async () => undefined, + }); + await new Promise(resolve => { stream.resume(); stream.on('end', () => resolve()); }); + + expect(seenAuth).toEqual([`Bearer ${KEY_A}`, `Bearer ${KEY_A}`]); + }); + + it('不提供 onRetry 时仍按原 key 重试(向后兼容)', async () => { + const { sendToCC } = await import('../src/adapters/commandcode/upstream.js'); + seenAuth = []; + + const stream = await sendToCC(makeBody(), { apiKey: KEY_A }); + await new Promise(resolve => { stream.resume(); stream.on('end', () => resolve()); }); + + expect(seenAuth.length).toBeGreaterThanOrEqual(2); + expect(seenAuth.every(a => a === `Bearer ${KEY_A}`)).toBe(true); + }); +}); diff --git a/tests/spa-badge-escape.test.ts b/tests/spa-badge-escape.test.ts new file mode 100644 index 0000000..addb693 --- /dev/null +++ b/tests/spa-badge-escape.test.ts @@ -0,0 +1,83 @@ +// ============================================================================= +// P1-3 回归:仪表盘 badge() 必须转义 text。 +// ----------------------------------------------------------------------------- +// badge() 的 title 参数走了 esc(),text 却被裸拼进 innerHTML;调用点把上游定价页 +// 抓来的字段(m.deal.discountPercent)直接当 text 传进来。全站没有 CSP 兜底。 +// +// 这里不用正则读源码,而是把 index.html 里**真实的** badge/esc/BADGE_TONES 源码片段 +// 取出来执行 —— 沿用 dashboard-spa.test.ts 已有的 new Function 手法,这样改动实现 +// (比如换变量名)不会让测试假失败,但删掉 esc 一定会。 +// ============================================================================= +import { describe, it, expect } from 'vitest'; +import { readFileSync } from 'node:fs'; +import path from 'node:path'; + +const root = path.resolve(__dirname, '..'); +const html = readFileSync(path.join(root, 'public', 'index.html'), 'utf-8'); + +/** 按大括号配平取出一个顶层 `function name(...) { ... }` 源码。 */ +function extractFn(src: string, name: string): string { + const start = src.indexOf(`function ${name}(`); + if (start < 0) throw new Error(`function ${name} not found in public/index.html`); + const open = src.indexOf('{', start); + let depth = 0; + for (let i = open; i < src.length; i++) { + if (src[i] === '{') depth++; + else if (src[i] === '}') { + depth--; + if (depth === 0) return src.slice(start, i + 1); + } + } + throw new Error(`unbalanced braces in ${name}`); +} + +/** 取出 `const NAME = { ... };` 对象字面量声明。 */ +function extractConstObject(src: string, name: string): string { + const start = src.indexOf(`const ${name} = {`); + if (start < 0) throw new Error(`const ${name} not found in public/index.html`); + const open = src.indexOf('{', start); + let depth = 0; + for (let i = open; i < src.length; i++) { + if (src[i] === '{') depth++; + else if (src[i] === '}') { + depth--; + if (depth === 0) return src.slice(start, src.indexOf(';', i) + 1); + } + } + throw new Error(`unbalanced braces in ${name}`); +} + +const tones = extractConstObject(html, 'BADGE_TONES'); +const badgeSrc = extractFn(html, 'badge'); + +// esc 是箭头函数(index.html:393),必须按声明式取。取不到就直接抛—— +// 绝不退化成恒等函数:本测试初版就是这么写的,结果"未转义"是测试自己造的假阳性。 +const escDecl = html.match(/^const esc = .+$/m)?.[0]; +if (!escDecl) throw new Error('const esc = ... not found in public/index.html'); + +const badge: (text: unknown, tone: string, title?: string) => string = + new Function(`${tones}\n${escDecl}\n${badgeSrc}\nreturn badge;`)(); + +describe('badge() HTML 转义(P1-3)', () => { + it('text 里的标签定界符被转义,payload 只以转义形式出现', () => { + const payload = ''; + const out = badge(payload, 'amber'); + + // 危险的是尖括号,不是 "onerror=" 这个字面子串 —— 定界符转义后它就是纯文本。 + expect(out).not.toContain(''); + expect(out).toContain('<img src=x onerror=alert(1)>'); + }); + + it('保留正常纯文本渲染,且外层结构仍是 span', () => { + const out = badge('DEAL 50%', 'amber'); + expect(out.startsWith(' { + const out = badge('ok', 'slate', '" onload="alert(1)'); + expect(out).not.toContain('onload="alert'); + expect(out).toContain('"'); + }); +}); diff --git a/tests/update-check-tags.test.ts b/tests/update-check-tags.test.ts new file mode 100644 index 0000000..30028bd --- /dev/null +++ b/tests/update-check-tags.test.ts @@ -0,0 +1,41 @@ +// ============================================================================= +// P0-3 回归:版本检查必须基于真实存在的版本源。 +// ----------------------------------------------------------------------------- +// 现状:update-check.ts 打的是 /releases/latest,但本项目只打 git tag、不创建 +// GitHub Release 对象。实测 releases/latest 停在 v4.12.0,而 tag 已到 v4.17.0 +// —— 于是"发现新版本"从 v4.13.0 起永远不会触发,用户看不到包括重要修复在内的 +// 5 个版本。改为读 /tags 并按 semver 取最大。 +// +// GitHub /tags 不保证按版本序返回,所以"取最大 semver"是必需的,不能拿第一条。 +// ============================================================================= +import { describe, it, expect } from 'vitest'; + +describe('pickLatestTag(P0-3)', () => { + it('从乱序 tag 列表里取最大 semver,而不是第一条', async () => { + const { pickLatestTag } = await import('../src/utils/update-check.js'); + const tags = [ + { name: 'v4.9.2' }, { name: 'v4.17.0' }, { name: 'v4.10.0' }, { name: 'v4.9.1' }, + ]; + expect(pickLatestTag(tags)).toBe('v4.17.0'); + }); + + it('跨主/次版本正确比较(4.9.9 < 4.10.0 < 4.17.0)', async () => { + const { pickLatestTag } = await import('../src/utils/update-check.js'); + const tags = [{ name: 'v4.9.9' }, { name: 'v4.10.0' }, { name: 'v5.0.0' }, { name: 'v4.17.0' }]; + expect(pickLatestTag(tags)).toBe('v5.0.0'); + }); + + it('忽略非版本号 tag 与脏数据', async () => { + const { pickLatestTag } = await import('../src/utils/update-check.js'); + const tags = [{ name: 'latest' }, { name: 'v3-beta' }, null, { name: 42 }, {}, { name: 'v4.17.0' }]; + expect(pickLatestTag(tags)).toBe('v4.17.0'); + }); + + it('无可用 tag 或非数组响应时返回 null(不发假警报)', async () => { + const { pickLatestTag } = await import('../src/utils/update-check.js'); + expect(pickLatestTag([])).toBeNull(); + expect(pickLatestTag(undefined)).toBeNull(); + expect(pickLatestTag({ tag_name: 'v4.17.0' })).toBeNull(); + expect(pickLatestTag('v4.17.0')).toBeNull(); + }); +}); diff --git a/tests/upstream-total-timeout.test.ts b/tests/upstream-total-timeout.test.ts new file mode 100644 index 0000000..58a7295 --- /dev/null +++ b/tests/upstream-total-timeout.test.ts @@ -0,0 +1,118 @@ +// ============================================================================= +// P1-1 回归:upstreamTimeoutMs 必须是真实的挂钟上限,而不只是一个展示在仪表盘上的数字。 +// ----------------------------------------------------------------------------- +// 修复前该配置被 loadConfig 读取、写入默认值、并在 /api/status 回显,但 src/ 里 +// **0 个消费点**:唯一生效的是 idleTimeoutMs(且每次收到字节都会被重置,因此一个 +// 持续 trickle 的慢上游可以永远挂住连接)。 +// +// 两个用例刻意把 idleTimeoutMs 设得比 upstreamTimeoutMs 大得多,这样"超时发生了" +// 只能归因于挂钟上限,而不是空闲看门狗——否则测试无法区分两者。 +// ============================================================================= +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import http from 'node:http'; +import { mkdtempSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import type { AddressInfo } from 'node:net'; + +const TOTAL_TIMEOUT_MS = 400; +const IDLE_TIMEOUT_MS = 2_500; // 刻意远大于总时限,确保区分 +// 判定"是挂钟上限掐断的、不是空闲看门狗"的硬边界。 +const DEADLINE_BOUND_MS = 1_500; + +let silentServer: http.Server; // 收下连接但永不响应 +let trickleServer: http.Server; // 持续 trickle 保活注释行 +let stateDir = ''; +const timers: NodeJS.Timeout[] = []; + +beforeAll(async () => { + stateDir = mkdtempSync(path.join(tmpdir(), 'ccproxy-deadline-')); + + silentServer = http.createServer(() => { /* 故意不响应 */ }); + trickleServer = http.createServer((_req, res) => { + res.writeHead(200, { 'Content-Type': 'text/event-stream' }); + res.write('data: {"type":"start"}\n\n'); + // 每 100ms 一个保活:空闲看门狗永远被重置,只有挂钟上限能掐断它。 + const t = setInterval(() => { try { res.write(': keep\n\n'); } catch { clearInterval(t); } }, 100); + t.unref?.(); + timers.push(t); + res.on('close', () => clearInterval(t)); + }); + + await new Promise(r => silentServer.listen(0, '127.0.0.1', r)); + await new Promise(r => trickleServer.listen(0, '127.0.0.1', r)); +}); + +afterAll(async () => { + for (const t of timers) clearInterval(t); + await Promise.all([ + new Promise(r => silentServer.close(() => r())), + new Promise(r => trickleServer.close(() => r())), + ]); +}); + +/** 每个用例指向不同 mock 端口,并重写 config.json —— 这同时验证配置真的被消费。 */ +async function loadSendToCC(port: number) { + const base = `http://127.0.0.1:${port}`; + writeFileSync( + path.join(stateDir, 'config.json'), + JSON.stringify({ + upstream: { apiBase: base, timeoutMs: TOTAL_TIMEOUT_MS, idleTimeoutMs: IDLE_TIMEOUT_MS, maxRetries: 0 }, + }), + ); + process.env.COMMANDCODE_API_BASE = base; + process.env.COMMANDCODE_UPSTREAM_ALLOWED_HOSTS = '127.0.0.1'; + process.env.COMMANDCODE_CONFIG_PATH = path.join(stateDir, 'config.json'); + process.env.COMMANDCODE_MODELS_CACHE_PATH = path.join(stateDir, 'models.json'); + process.env.USAGE_HISTORY_PATH = path.join(stateDir, 'usage.jsonl'); + + // loadConfig() 每次都重读 env + 配置文件,无需手动失效缓存。 + const up = await import('../src/adapters/commandcode/upstream.js'); + return up.sendToCC; +} + +function makeBody() { + return { + threadId: 't-deadline', + params: { model: 'claude-sonnet-5', stream: false, messages: [{ role: 'user', content: 'hi' }] }, + config: { workingDir: stateDir }, + } as any; +} + +describe('上游挂钟总时限(P1-1)', () => { + it('上游迟迟不返回响应头时,以 REQUEST_TIMEOUT 失败(空闲看门狗此时不该抢先)', async () => { + const sendToCC = await loadSendToCC((silentServer.address() as AddressInfo).port); + const started = Date.now(); + + await expect(sendToCC(makeBody(), { apiKey: 'ck-deadline' })).rejects.toMatchObject({ + code: 'REQUEST_TIMEOUT', + }); + + const elapsed = Date.now() - started; + expect(elapsed).toBeGreaterThanOrEqual(TOTAL_TIMEOUT_MS - 50); + expect(elapsed).toBeLessThan(DEADLINE_BOUND_MS); // 证明不是 idle 看门狗干的 + }, 8_000); + + it('上游持续 trickle 时,连接仍会在挂钟上限处被掐断', async () => { + const sendToCC = await loadSendToCC((trickleServer.address() as AddressInfo).port); + const started = Date.now(); + + const stream = await sendToCC(makeBody(), { apiKey: 'ck-deadline' }); + + const outcome = await new Promise(resolve => { + const done = (v: string) => resolve(v); + stream.on('data', () => {}); + stream.once('error', () => done('errored')); + stream.once('end', () => done('ended')); + stream.once('close', () => done('closed')); + // 兜底:若上限完全失效,让断言去判失败,而不是把测试挂到超时。 + const guard = setTimeout(() => done('hung'), IDLE_TIMEOUT_MS * 2); + guard.unref?.(); + }); + + const elapsed = Date.now() - started; + expect(outcome).not.toBe('hung'); + expect(['errored', 'ended', 'closed']).toContain(outcome); + expect(elapsed).toBeLessThan(DEADLINE_BOUND_MS); + }, 8_000); +});