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);
+});