Skip to content

fix(audit): 批次 A —— 消除 7 处「看起来在工作、实际没工作」的静默失效 - #9

Open
wjf1 wants to merge 1 commit into
mainfrom
fix/audit-batch-a
Open

wjf1 wants to merge 1 commit into
mainfrom
fix/audit-batch-a

Conversation

@wjf1

@wjf1 wjf1 commented Sep 20, 2026

Copy link
Copy Markdown
Owner

Summary

架构审查后整改清单的批次 A(7 项,全部 S 量级)。共同特征:不是写错的代码,而是没人验证过的代码路径——用户和 CI 都看不见。

  • onRetry 死路径:撞额度时重试途中的换号从未生效。这条路径实际有三层缺陷,只补第一层仍然不会切号(sendToCC 从不 await 回调 / headers 在重试循环外构建一次 / 路由回调改的是局部变量而 opts.apiKey 已快照旧值)。契约调整:onRetry 现在返回下一次要用的 apiKey。
  • upstream.timeoutMs 从此真正生效:此前被加载、写默认值、在 /api/status 与仪表盘展示,但 src/ 里 0 个消费点;唯一生效的 idleTimeoutMs 每收到一个字节就重置,持续 trickle 的上游可无限挂住连接。新增跨「等响应头 + 读流」两阶段的挂钟上限。
  • npm test 不再零覆盖报全绿:集成用例全部 spawn 编译产物,缺 dist/ 时被 describe.skipIf 静默跳过(实测未构建时 244 passed / 42 skipped 且退出码 0)。现在 pretest 自动构建 + beforeAll 首行明确报错。
  • 「发现新版本」恢复工作:改读 /tags 按 semver 取最大。本仓库只打 tag 不建 Release,实测 releases/latest 停在 v4.12.0 而 tag 已到 v4.17.0,于是自 v4.13.0 起该提示永不触发。
  • /api/auth/manual-login 不再明文回传上游 apiKey,改用与 /api/accounts 同一套 apiKeyMasked;收口在 HTTP 边界。
  • 仪表盘 badge() 转义 texttitle 一直走 esc()text 却裸拼进 innerHTML,而调用点传入上游定价页抓来的 m.deal.discountPercent。全站无 CSP,属纵深防御。
  • pkg.assets 移除 models.json:运行时生成物且已在 .gitignore,全新克隆上打包会静默缺该资产。

附带修掉本批新增测试引入的一处自伤:admin-key-mask 走进程内 app.inject.env 路径按 getProjectRootDir()(=cwd) 解析,于是账号保存的副作用把 .env(含 COMMANDCODE_API_BASE,而 env 优先级高于 config.json)写进仓库根,且被 .gitignore 遮住。新增 COMMANDCODE_ENV_PATH 覆盖,与 CONFIG_PATH/MODELS_CACHE_PATH 同一套约定,不设置时行为一字不变

行为变更(需要关注的两条)

  1. 长度超过 upstream.timeoutMs(默认 600s)的请求现在会被切断。此前它能活下来纯粹因为这个配置不执行。
  2. 撞额度时会在重试途中切到另一个账号,该请求的上游归属随之改变。rotationMode !== 'auto-quota' 或账号数 ≤ 1 时必然走 return undefined 分支(config.ts:479),即单账号 manual 部署观测不到差别。

对日常使用的影响核查(判据来自运行中的实例与它自己的数据)

现行实例:127.0.0.1:9090、v4.13.0、authRequired:false、1 个账号、rotationMode:manual

影响 依据
600s 挂钟上限 无感 12 天 3089 条真实请求:p50 5.8s / p95 22.8s / p99 52.2s / max 117.1s,>120s 为 0 条;其中最大输入 38.1 万 token(>25 万 token 有 262 条)。上限距最慢请求 5 倍余量,且 timeoutMs:600000 本就是 config.json 里声明的值
重试换号 无感 manual + 单账号必然返回 undefined → 沿用原 key,有用例锁该分支
manual-login 脱敏 无感 SPA 只读 data.status/data.errorindex.html:471),列表用 apiKeyMasked:493:523),无消费方受影响
更新检查 唯一会被看见的变化 提示会真的开始弹;现行 v4.13.0 的 /api/status 实测 latest:"v4.12.0"available:false,即该 bug 的活证据

Test plan

  • 新增 5 个文件共 16 项,全部先观察到失败再实现:onretry-account-switch(断言上游实际收到的 Authorization 头变化,而非「回调被调用过」)、upstream-total-timeout(刻意把 idleTimeoutMs 设得大于总时限,使超时只能归因于挂钟上限)、admin-key-maskspa-badge-escape(取出 index.html 里真实的 badge/esc/BADGE_TONES 源码执行)、update-check-tags
  • npm test304 项通过(原 288 + 16),Windows 本机跑过 3 轮
  • tsc --noEmiteslint . 均 0 错误
  • A1 判据单独验:删掉 dist/ 直接 vitest run tests/integration.test.ts → 退出码 1 并打印「缺少构建产物」
  • 本 PR 的 ubuntu + windows 双矩阵 CI(分支 push 不触发 CI,故由本 PR 提供)
  • 覆盖率:statements 48.36% → 57.57%(1460/2536)。读数说明:先前记的 57.8% 偏高,多出的 12 条是仓库根存在 .env 时顺带跑到的 loadEnvFile() 解析路径——反过来说该路径至今没有直接测试覆盖,归入后续批次。

明确未做(已记 CHANGELOG「已知问题」)

管理面默认零鉴权与 /v1/api 共用一把密钥(批次 B);出站 fetch 未设 redirect:'manual'(二跳可逃逸 SSRF 白名单);OAuth 回调缺 state 时放行;syncEnvFileapiBase/ccVersion 硬编码成默认值(config.ts:342-343),用自建上游的人动一次账号就会在下次重启被静默改回公网默认(默认部署两者取值相同故不触发);rewriteSafelyrenameSync 无 try/catch。

架构审查后整改清单的批次 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),
用自建上游的人动一次账号就会在下次重启被静默改回公网默认。
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant