From a8d5eb2bcbaedc904f457689df7a6cee43c9ccd0 Mon Sep 17 00:00:00 2001 From: zouyanjian Date: Fri, 23 Oct 2026 19:59:22 +0800 Subject: [PATCH 1/3] =?UTF-8?q?=E8=A1=A5=E4=B8=80=E6=9D=A1=E7=94=A8?= =?UTF-8?q?=E4=BE=8B=EF=BC=9A=E7=BC=BA=20TOOL=5FSECRET=5FKEY=20=E6=97=B6?= =?UTF-8?q?=E6=8E=A7=E5=88=B6=E9=9D=A2=E5=BA=94=E6=8B=92=E7=BB=9D=E5=90=AF?= =?UTF-8?q?=E5=8A=A8=EF=BC=8C=E7=8E=B0=E5=9C=A8=E4=BC=9A=E9=9D=99=E9=BB=98?= =?UTF-8?q?=E7=94=A8=E5=BC=80=E5=8F=91=E5=AF=86=E9=92=A5=20(#23)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Be3SZps2JjqvyrJ1vbLmni --- .../control-plane/test/start-keys.test.ts | 40 +++++++++++++++++++ 1 file changed, 40 insertions(+) create mode 100644 packages/control-plane/test/start-keys.test.ts diff --git a/packages/control-plane/test/start-keys.test.ts b/packages/control-plane/test/start-keys.test.ts new file mode 100644 index 0000000..486030c --- /dev/null +++ b/packages/control-plane/test/start-keys.test.ts @@ -0,0 +1,40 @@ +import { execFile } from "node:child_process"; +import { randomBytes } from "node:crypto"; +import { fileURLToPath } from "node:url"; +import { expect, it } from "vitest"; + +const MAIN = fileURLToPath(new URL("../src/main.ts", import.meta.url)); +const TSX = fileURLToPath(new URL("../node_modules/.bin/tsx", import.meta.url)); + +/** 库指向连不上的端口、端口交给系统分配:钥匙齐了也起不来,只看钥匙缺了时报的是什么。 */ +function start(drop: "TOOL_SECRET_KEY" | "CREDENTIALS_KEY" | null, toolSecretKey: string) { + const env: NodeJS.ProcessEnv = { + ...process.env, + DATABASE_URL: "postgres://baize:baize@127.0.0.1:1/none", + PORT: "0", + TOOL_SECRET_KEY: toolSecretKey, + CREDENTIALS_KEY: randomBytes(32).toString("base64"), + }; + if (drop !== null) delete env[drop]; + return new Promise<{ readonly code: number | null; readonly stderr: string }>((resolve) => { + execFile(TSX, [MAIN], { env, timeout: 30_000 }, (error, _stdout, stderr) => { + resolve({ code: error === null ? 0 : typeof error.code === "number" ? error.code : null, stderr }); + }); + }); +} + +it("TOOL_SECRET_KEY 没设或留空时控制面拒绝启动,报错点名这个变量", async () => { + const missing = await start("TOOL_SECRET_KEY", ""); + expect(missing.code).toBe(1); + expect(missing.stderr).toContain("TOOL_SECRET_KEY 没设,用 openssl rand -hex 32 生成一个"); + + const blank = await start(null, " "); + expect(blank.code).toBe(1); + expect(blank.stderr).toContain("TOOL_SECRET_KEY 没设,用 openssl rand -hex 32 生成一个"); +}); + +it("CREDENTIALS_KEY 没设时控制面同样拒绝启动", async () => { + const result = await start("CREDENTIALS_KEY", randomBytes(32).toString("hex")); + expect(result.code).toBe(1); + expect(result.stderr).toContain("CREDENTIALS_KEY 没设,用 openssl rand -base64 32 生成一个"); +}); From 1e0b8d97464868c8ba541912ee46e13da32b8438 Mon Sep 17 00:00:00 2001 From: zouyanjian Date: Fri, 23 Oct 2026 20:51:19 +0800 Subject: [PATCH 2/3] =?UTF-8?q?=E5=8E=BB=E6=8E=89=20TOOL=5FSECRET=5FKEY=20?= =?UTF-8?q?=E7=9A=84=E5=BC=80=E5=8F=91=E9=BB=98=E8=AE=A4=E5=80=BC=EF=BC=8C?= =?UTF-8?q?=E7=BC=BA=E4=BA=86=E5=92=8C=20CREDENTIALS=5FKEY=20=E4=B8=80?= =?UTF-8?q?=E6=A0=B7=E6=8B=92=E7=BB=9D=E5=90=AF=E5=8A=A8=20(#23)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Be3SZps2JjqvyrJ1vbLmni --- .claude/skills/verify/SKILL.md | 2 +- .claude/skills/verify/scripts/stack.sh | 12 ++++++++++++ .env.example | 5 ++--- deploy/README.md | 4 ++-- "docs/\345\274\200\345\217\221.md" | 4 +++- packages/control-plane/README.md | 2 +- packages/control-plane/src/env.ts | 10 ++-------- packages/control-plane/test/disconnect.test.ts | 1 + .../control-plane/test/dispatchable-push.test.ts | 1 + packages/control-plane/test/env.test.ts | 6 +----- packages/control-plane/test/liveness.test.ts | 1 + packages/control-plane/test/reconnect.test.ts | 1 + packages/control-plane/test/run-done-replay.test.ts | 8 +++++++- packages/control-plane/test/start-keys.test.ts | 4 ++-- packages/desktop/scripts/prove.ts | 1 + packages/e2e/src/stack.ts | 1 + 16 files changed, 39 insertions(+), 24 deletions(-) diff --git a/.claude/skills/verify/SKILL.md b/.claude/skills/verify/SKILL.md index 8481641..114a447 100644 --- a/.claude/skills/verify/SKILL.md +++ b/.claude/skills/verify/SKILL.md @@ -54,7 +54,7 @@ $S entry2 agent # 控制面已起,只补电脑代理,shell 里的电 它做的事: 1. `docker start baize-postgres`,等 `pg_isready`。 -2. 控制面:把 `.env` 按 dev.mjs 的规则导出(只认 `KEY=value`,shell 里已有的优先),跑 `pnpm --filter @baize/control-plane start`。端口取 `.env` 的 `PORT`,没写就是 3001。控制面启动要 `CREDENTIALS_KEY`,shell 和 `.env` 都没有时脚本生成一把存进 `.audit/verify-run/credentials.key`,之后每次都用它。挂 GitHub、X 账号用 `stack.sh entry2 account-add --owner … --platform github --handle … --token …`,走的是同一把钥匙;实机发布把 `GITHUB_API_BASE_URL`、`X_API_BASE_URL` 指到假平台,不用真令牌。 +2. 控制面:把 `.env` 按 dev.mjs 的规则导出(只认 `KEY=value`,shell 里已有的优先),跑 `pnpm --filter @baize/control-plane start`。端口取 `.env` 的 `PORT`,没写就是 3001。控制面启动要 `CREDENTIALS_KEY` 和 `TOOL_SECRET_KEY`,shell 和 `.env` 都没有时脚本各生成一把存进 `.audit/verify-run/credentials.key`、`.audit/verify-run/tool-secret.key`,之后每次都用它们。挂 GitHub、X 账号用 `stack.sh entry2 account-add --owner … --platform github --handle … --token …`,走的是同一把钥匙;实机发布把 `GITHUB_API_BASE_URL`、`X_API_BASE_URL` 指到假平台,不用真令牌。 3. Web:`API_PORT=<.env 的 API_PORT,没写取 PORT> WEB_PORT=5173 pnpm --filter @baize/web dev`。 4. 加 `--agent` 或跑 `entry2 agent` 时起电脑代理:`pnpm --filter @baize/computer-agent exec tsx src/main.ts`。`COMPUTER_ID`、`AGENT_TOKEN`、`CONTROL_PLANE_AGENT_URL`、`BAIZE_DATA_DIR` 每个先取 shell 里的值,没有再取 `.env`,拼成一行 JSON `{ url, computerId, token, dataDir }`,经 `.audit/verify-run/entry2/computer-agent.stdin` 这个 fifo 写进代理的 stdin。代理环境里去掉 `.env` 的所有键和这四个键,和 dev.mjs 一样,token 和上游密钥都不进 Bot 的 bash。环境里只要有 `AGENT_TOKEN`(空串也算),代理就拒绝启动。stdin 关闭时代理自己停机。脚本等到代理日志里出现 `{"type":"online"}` 这一整行就算连上。这行是代理在 stdout 写的状态行(shared 的 `AgentStatusLine`),控制面接纳后就写,桌面端也认它;随后几毫秒内日志里会出现「收到第 N 代 config」。日志措辞会变,别拿它判就绪。 diff --git a/.claude/skills/verify/scripts/stack.sh b/.claude/skills/verify/scripts/stack.sh index 961703e..14ed851 100755 --- a/.claude/skills/verify/scripts/stack.sh +++ b/.claude/skills/verify/scripts/stack.sh @@ -68,6 +68,17 @@ ensure_credentials_key() { export CREDENTIALS_KEY } +ensure_tool_secret_key() { + [[ -n "${TOOL_SECRET_KEY-}" ]] && return + local file="$RUN/tool-secret.key" + if [[ ! -s "$file" ]]; then + mkdir -p "$RUN" + (umask 077 && openssl rand -hex 32 >"$file") + fi + TOOL_SECRET_KEY="$(cat "$file")" + export TOOL_SECRET_KEY +} + listener_pid() { lsof -nP -t -iTCP:"$1" -sTCP:LISTEN 2>/dev/null | head -1 || true } @@ -330,6 +341,7 @@ entry2() { exec_control_plane() { export_env_file ensure_credentials_key + ensure_tool_secret_key exec pnpm --filter @baize/control-plane start } diff --git a/.env.example b/.env.example index 91e55af..37967ed 100644 --- a/.env.example +++ b/.env.example @@ -13,9 +13,8 @@ COOKIE_SECURE= # 控制面前面有几层自己的反向代理。登录失败和注册按来源 IP 限频,留空或 0 只认 socket 地址,不读 X-Forwarded-For, # 因为客户端能随便填它。前面挂了 Caddy 或 nginx 就填 1,取 X-Forwarded-For 最右一项;没有代理却填了,任何人都能伪造来源。 TRUSTED_PROXY_HOPS= -# 工具凭据的加密钥匙,64 位十六进制,用 openssl rand -hex 32 生成,换了之前存的工具凭据就解不开。 -# 不设时用代码里的开发密钥并告警;设了空值会启动失败,所以不用时整行保持注释。 -# TOOL_SECRET_KEY= +# 工具凭据的加密钥匙,64 位十六进制,没有默认值,缺了控制面起不来。用 openssl rand -hex 32 生成,换了之前存的工具凭据就解不开。 +TOOL_SECRET_KEY= # 账号令牌的加密钥匙,32 字节 base64,没有默认值,缺了控制面起不来。用 openssl rand -base64 32 生成,换了之前存的令牌就解不开。 CREDENTIALS_KEY= # 发布走的平台接口根地址,默认官方地址。验证时指到假平台。 diff --git a/deploy/README.md b/deploy/README.md index d43aaef..2c52bf5 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -75,8 +75,8 @@ compose 读 `deploy/remote/.env`(`remote/README.md:41`)。控制面读哪些 | `UPSTREAM_BASE_URL` | 是 | 报错不起 | OpenAI 兼容的上游地址(`env.ts:94`) | | `UPSTREAM_API_KEY` | 是 | 报错不起 | 只能是可打印 ASCII,否则控制面启动时抛错(`env.ts:170-176`) | | `GATEWAY_MODELS` | 否 | `plan=baize-default,fast=baize-default` | 格式 `plan=模型,fast=模型`,两个用途都要有,写错启动时抛错(`env.ts:146-167`) | -| `TOOL_SECRET_KEY` | 是 | 报错不起 | 64 位十六进制(`packages/control-plane/src/crypto.ts:31`),`openssl rand -hex 32`。控制面本身没设时退回一把写在代码里的开发密钥(`env.ts:16-24`),compose 用 `:?` 挡住了这种情况 | -| `CREDENTIALS_KEY` | 是 | 报错不起 | 32 字节 base64,`openssl rand -base64 32`。没设或格式不对启动失败(`env.ts:27-33`、`packages/control-plane/src/credentials.ts:24`) | +| `TOOL_SECRET_KEY` | 是 | 报错不起 | 64 位十六进制,`openssl rand -hex 32`。没设或格式不对启动失败(`env.ts:15-19`、`packages/control-plane/src/crypto.ts:31`) | +| `CREDENTIALS_KEY` | 是 | 报错不起 | 32 字节 base64,`openssl rand -base64 32`。没设或格式不对启动失败(`env.ts:21-27`、`packages/control-plane/src/credentials.ts:24`) | | `WEB_BASE_URL` | 是 | 报错不起 | 浏览器打开 Web 的地址,如 `https://baize.example.com`,OAuth 回调拼在它上面(`env.ts:100`) | | `PRICE_INPUT_PER_M`、`PRICE_OUTPUT_PER_M` | 否 | `0` | 每百万 token 价格,折算 run 的花费(`env.ts:96-97`)。不设用量金额恒为 0 | | `GITHUB_API_BASE_URL`、`X_API_BASE_URL`、`GITHUB_OAUTH_BASE_URL`、`X_OAUTH_BASE_URL` | 否 | 各家官方地址,和 `env.ts:84-87` 一致 | 只在验证时指到假平台(`env.ts:98-99`、`:122-123`),生产不用设 | diff --git "a/docs/\345\274\200\345\217\221.md" "b/docs/\345\274\200\345\217\221.md" index 52fe23f..6bcbb5c 100644 --- "a/docs/\345\274\200\345\217\221.md" +++ "b/docs/\345\274\200\345\217\221.md" @@ -61,7 +61,7 @@ Bot 走到要点头的工具时群里出一张审批卡,点按钮或在群里 | `UPSTREAM_API_KEY` | 空 | 转发给上游的 key,只能是可打印 ASCII,否则启动时失败 | | `PRICE_INPUT_PER_M` | `0` | 每百万输入 token 的价,用来折算 run 的花费 | | `PRICE_OUTPUT_PER_M` | `0` | 每百万输出 token 的价 | -| `TOOL_SECRET_KEY` | 开发密钥,启动时告警 | 加密工具连接凭据的钥匙,64 位十六进制,用 `openssl rand -hex 32` 生成。设了但格式不对(包括空值)启动失败;换了钥匙之前存的工具凭据就解不开。部署必须设 | +| `TOOL_SECRET_KEY` | 无,必填 | 加密工具连接凭据的钥匙,64 位十六进制,用 `openssl rand -hex 32` 生成。缺了或格式不对启动失败;换了钥匙之前存的工具凭据就解不开 | | `CREDENTIALS_KEY` | 无,必填 | 加密账号令牌的钥匙,32 字节 base64,用 `openssl rand -base64 32` 生成。缺了或长度不对启动失败;换了钥匙之前存的令牌就解不开 | | `GITHUB_API_BASE_URL` | `https://api.github.com` | 发 GitHub 草稿打的接口根地址,验证时指到假 GitHub | | `X_API_BASE_URL` | `https://api.x.com` | 发 X 草稿打的接口根地址,X 的令牌端点也在它下面 | @@ -77,6 +77,8 @@ GitHub、X 账号在账号页走 OAuth 连。开发时手里有令牌想跳过 ## 跑起来 +先 `cp .env.example .env`,填上两把钥匙:`CREDENTIALS_KEY` 用 `openssl rand -base64 32`,`TOOL_SECRET_KEY` 用 `openssl rand -hex 32`。缺哪一把控制面都起不来,开发也没有默认值。 + ``` pnpm install docker compose up -d diff --git a/packages/control-plane/README.md b/packages/control-plane/README.md index 5210f45..09124c3 100644 --- a/packages/control-plane/README.md +++ b/packages/control-plane/README.md @@ -149,7 +149,7 @@ hub 认连接用 `#onHello` 入表时登记的 WeakMap,再校验它还是 `#co 工具连接是用户级对象。`tool_connections` 存 MCP 地址、发现的操作及其 `needsApproval` 标记,并把认证信息以 AES-256-GCM 密文存入 `authorization_enc`;`tool_connection_bots` 记录它分配给哪些 Bot,删 Bot 时对应分配由外键级联清除。API 列表和写入响应都不返回认证信息。默认 `localOnly: false`,凭据只留在服务器,`config.tools` 仅下发操作表和 Bot 分配,不含 `authorization`。运行器经 `/mcp-proxy/:connectionId/call` 代执行,本机和远程电脑都能使用。设成「只在本机用」后,本机电脑收到 `localOnly: true` 和解密后的凭据并直连 MCP;远程电脑不收到这条连接。 -生产环境设置 `TOOL_SECRET_KEY` 为 64 位十六进制密钥。未设置时启动会警告一次,并使用固定开发密钥;已有密文依赖原密钥解密,换密钥前需迁移密文。MCP 发现时把认证信息作为 `Authorization` 请求头发送。操作默认按 MCP 的 `readOnlyHint` 定审批:明确为 `true` 的不需要,其余都需要;用户可通过 `PATCH /api/tools/:id` 覆盖标记。连接创建、修改、删除后,控制面都会向该用户在线电脑重发整份 `config`。 +`TOOL_SECRET_KEY` 是 64 位十六进制密钥,开发和部署都必须设,没设控制面拒绝启动。不给开发默认值,因为写在仓库里的钥匙人人知道,漏配的部署会带着它照常起来,工具凭据等于明文。已有密文依赖原密钥解密,换密钥前需迁移密文。MCP 发现时把认证信息作为 `Authorization` 请求头发送。操作默认按 MCP 的 `readOnlyHint` 定审批:明确为 `true` 的不需要,其余都需要;用户可通过 `PATCH /api/tools/:id` 覆盖标记。连接创建、修改、删除后,控制面都会向该用户在线电脑重发整份 `config`。 从分享链接导入的插件固定 `localOnly: false`,没有授权,操作表只有包里带的名字和 `needsApproval`,参数表是占位的空表:导入时控制面不去连包里的地址(D11)。所以导入的连接记成 `awaiting_authorization`,随 `config.tools[].status` 下发,运行器照样注册它的操作,但调用当场拒绝、不去连包里的地址。不能拿 `authorization` 为 null 来判:自己建的匿名 MCP 也是 null,那是 `ready`。导入后要先在 `PATCH /api/tools/:id` 带上 `authorization` 补授权,插件才能用;对端本来就不要认证的,传 `authorization: null`,控制面不带头重新探测,连上了清掉凭据、记成 `ready`。这一步对一种对端会误删凭据:`tools/list` 允许匿名、只有 `tools/call` 才要凭据。不带头的探测照样连得上,凭据一次就被清掉,之后每次调用都是 401,要所有者重新带凭据 PATCH。这不算越权,只有所有者能改自己的连接。Web 现在不发 null,以后接入这个入口时只对 `awaiting_authorization` 的连接开放,或者先二次确认。补授权时控制面当场重新 initialize 加 `tools/list`,连上了就按真实列表重建操作表并记成 `ready`,同名操作沿用原来的 `needsApproval`;连不上回 400,旧凭据和操作表都不动。探测要等对端,期间别的 PATCH 可能已经改了审批,所以写回在事务里先 `SELECT ... FOR UPDATE` 锁住这一行,按落库的最新操作表合并,普通的 operations 修改也走同一把锁。导入撞名时工具名和 Bot 名一样截掉尾巴给序号让位,不超过 40,再导出不会被分享包的上限挡住。对端能把收到的 Authorization 按任意编码写进报错,逐种编码去认认不完,所以探测失败只回固定的中文分类(`describeMcpFailure`,HTTP 状态码、MCP 错误码这一级),对端写的文字一个字都不回;运行器连不上、调用抛错也是同一套分类。探测成功时对端也可能把凭据写进操作的名字、说明或参数表,这些会落库、进回包、进分享包、进模型的工具定义,`discoverTools` 发现其中有凭据(原文、百分号编码、JSON 转义三种写法)就整份不收、回 400,不去改写操作名,改了名字就调不到对端的工具。工具调用的正文是对端的业务输出。默认由控制面在返回电脑之前脱敏,本机直连时由运行器脱敏;两条路径都换掉凭据的那三种写法,别的编码不保证。base64、小写的百分号编码、凭据的片段、Unicode 转义都认不出,成功和 `isError` 的正文一样原样进模型,模型可能转述进群消息,Pi 的 session 文件也会落盘。操作元数据的检查用的是同样三种写法,操作名里放凭据的 base64 能过检查,会落库,也会进再分享包的操作名。这个范围是有意的:对端本来就持有这把凭据,想外传不必借我们的通道,脱敏防的是它无意中原样回显。 diff --git a/packages/control-plane/src/env.ts b/packages/control-plane/src/env.ts index c6c0a38..10a00e6 100644 --- a/packages/control-plane/src/env.ts +++ b/packages/control-plane/src/env.ts @@ -2,8 +2,6 @@ import type { GatewayModels } from "@baize/shared"; const DEFAULT_DATABASE_URL = "postgres://baize:baize@localhost:5434/baize"; const DEFAULT_PORT = 3001; -const DEV_TOOL_SECRET_KEY = "8f8d7b86df250e27ea4f1bb66c7f4dc57b75ad623154f343890a258d68d03c90"; -let warnedToolSecret = false; export function databaseUrl(): string { return nonEmpty("DATABASE_URL") ?? DEFAULT_DATABASE_URL; @@ -24,12 +22,8 @@ export function trustedProxyHops(): number { export function toolSecretKey(): string { const key = nonEmpty("TOOL_SECRET_KEY"); - if (key !== null) return key; - if (!warnedToolSecret) { - console.warn("TOOL_SECRET_KEY 没设,工具认证信息用开发密钥加密"); - warnedToolSecret = true; - } - return DEV_TOOL_SECRET_KEY; + if (key === null) throw new Error("TOOL_SECRET_KEY 没设,用 openssl rand -hex 32 生成一个"); + return key; } /** 账号凭据的钥匙没有开发默认值:令牌是真平台的,用一把人人知道的钥匙加密等于没加密。 */ diff --git a/packages/control-plane/test/disconnect.test.ts b/packages/control-plane/test/disconnect.test.ts index 2062b4e..8406f41 100644 --- a/packages/control-plane/test/disconnect.test.ts +++ b/packages/control-plane/test/disconnect.test.ts @@ -30,6 +30,7 @@ beforeAll(async () => { port: 0, config: DEFAULT_RUNTIME_CONFIG, credentialsKey: TEST_CREDENTIALS_KEY, + toolSecretKey: "a".repeat(64), disconnectGraceMs: graceMs, }); }); diff --git a/packages/control-plane/test/dispatchable-push.test.ts b/packages/control-plane/test/dispatchable-push.test.ts index c5c1274..a035467 100644 --- a/packages/control-plane/test/dispatchable-push.test.ts +++ b/packages/control-plane/test/dispatchable-push.test.ts @@ -20,6 +20,7 @@ beforeAll(async () => { port: 0, config: DEFAULT_RUNTIME_CONFIG, credentialsKey: TEST_CREDENTIALS_KEY, + toolSecretKey: "a".repeat(64), scheduler: { now: () => new Date(), intervalMs: DAY_MS }, publisher: { intervalMs: DAY_MS }, disconnectGraceMs: 200, diff --git a/packages/control-plane/test/env.test.ts b/packages/control-plane/test/env.test.ts index 936601b..2cd5bb1 100644 --- a/packages/control-plane/test/env.test.ts +++ b/packages/control-plane/test/env.test.ts @@ -9,7 +9,6 @@ import { DEFAULT_X_OAUTH_BASE_URL, credentialsKey, runtimeConfig, - toolSecretKey, trustedProxyHops, } from "../src/env.js"; @@ -166,7 +165,7 @@ it("没配 OAuth 变量时两个平台都是 null,WEB_BASE_URL 留空就是 nu expect([config.oauth.github, config.oauth.x]).toEqual([null, null]); }); -it(".env 里留空的地址和工具密钥按没设处理,不把空串下发成网关地址", () => { +it(".env 里留空的地址按没设处理,不把空串下发成网关地址", () => { for (const name of [ "PUBLIC_BASE_URL", "UPSTREAM_BASE_URL", @@ -174,11 +173,9 @@ it(".env 里留空的地址和工具密钥按没设处理,不把空串下发 "X_API_BASE_URL", "GITHUB_OAUTH_BASE_URL", "X_OAUTH_BASE_URL", - "TOOL_SECRET_KEY", ]) { vi.stubEnv(name, ""); } - vi.spyOn(console, "warn").mockImplementation(() => {}); const config = runtimeConfig(); expect(config.publicBaseUrl).toBeNull(); @@ -187,7 +184,6 @@ it(".env 里留空的地址和工具密钥按没设处理,不把空串下发 expect(config.xApiBaseUrl).toBe(DEFAULT_X_API_BASE_URL); expect(config.oauth.githubOauthBaseUrl).toBe(DEFAULT_GITHUB_OAUTH_BASE_URL); expect(config.oauth.xOauthBaseUrl).toBe(DEFAULT_X_OAUTH_BASE_URL); - expect(toolSecretKey()).not.toBe(""); }); it("TRUSTED_PROXY_HOPS 留空是 0,只收非负整数", () => { diff --git a/packages/control-plane/test/liveness.test.ts b/packages/control-plane/test/liveness.test.ts index 699d374..13d987d 100644 --- a/packages/control-plane/test/liveness.test.ts +++ b/packages/control-plane/test/liveness.test.ts @@ -34,6 +34,7 @@ beforeAll(async () => { port: 0, config: DEFAULT_RUNTIME_CONFIG, credentialsKey: TEST_CREDENTIALS_KEY, + toolSecretKey: "a".repeat(64), scheduler: { now: () => clock, intervalMs: HOUR_MS }, publisher: { intervalMs: 24 * HOUR_MS }, silenceTimeoutMs: SILENCE_MS, diff --git a/packages/control-plane/test/reconnect.test.ts b/packages/control-plane/test/reconnect.test.ts index e1af4aa..c5a6ce7 100644 --- a/packages/control-plane/test/reconnect.test.ts +++ b/packages/control-plane/test/reconnect.test.ts @@ -28,6 +28,7 @@ beforeAll(async () => { port: 0, config: DEFAULT_RUNTIME_CONFIG, credentialsKey: TEST_CREDENTIALS_KEY, + toolSecretKey: "a".repeat(64), }); }); diff --git a/packages/control-plane/test/run-done-replay.test.ts b/packages/control-plane/test/run-done-replay.test.ts index 1321bf9..64fb885 100644 --- a/packages/control-plane/test/run-done-replay.test.ts +++ b/packages/control-plane/test/run-done-replay.test.ts @@ -26,7 +26,13 @@ let hasty: RunningServer; beforeAll(async () => { database = await createTestDb(); - const base = { db: database.db, port: 0, config, credentialsKey: TEST_CREDENTIALS_KEY } as const; + const base = { + db: database.db, + port: 0, + config, + credentialsKey: TEST_CREDENTIALS_KEY, + toolSecretKey: "a".repeat(64), + } as const; // 宽限设长,断开到重连之间只有 hello 对账会收尾。 patient = await startServer({ ...base, disconnectGraceMs: 60_000 }); hasty = await startServer({ ...base, disconnectGraceMs: 50 }); diff --git a/packages/control-plane/test/start-keys.test.ts b/packages/control-plane/test/start-keys.test.ts index 486030c..b98b281 100644 --- a/packages/control-plane/test/start-keys.test.ts +++ b/packages/control-plane/test/start-keys.test.ts @@ -5,12 +5,12 @@ import { expect, it } from "vitest"; const MAIN = fileURLToPath(new URL("../src/main.ts", import.meta.url)); const TSX = fileURLToPath(new URL("../node_modules/.bin/tsx", import.meta.url)); +const UNREACHABLE_DATABASE_URL = "postgres://baize:baize@127.0.0.1:1/none"; -/** 库指向连不上的端口、端口交给系统分配:钥匙齐了也起不来,只看钥匙缺了时报的是什么。 */ function start(drop: "TOOL_SECRET_KEY" | "CREDENTIALS_KEY" | null, toolSecretKey: string) { const env: NodeJS.ProcessEnv = { ...process.env, - DATABASE_URL: "postgres://baize:baize@127.0.0.1:1/none", + DATABASE_URL: UNREACHABLE_DATABASE_URL, PORT: "0", TOOL_SECRET_KEY: toolSecretKey, CREDENTIALS_KEY: randomBytes(32).toString("base64"), diff --git a/packages/desktop/scripts/prove.ts b/packages/desktop/scripts/prove.ts index 700acc7..dbaa65f 100644 --- a/packages/desktop/scripts/prove.ts +++ b/packages/desktop/scripts/prove.ts @@ -486,6 +486,7 @@ async function main(): Promise { PORT: String(controlPort), COOKIE_SECURE: "false", CREDENTIALS_KEY: randomBytes(32).toString("base64"), + TOOL_SECRET_KEY: randomBytes(32).toString("hex"), }; run("pnpm", ["--filter", "@baize/control-plane", "migrate"], baseEnv); control = spawn("pnpm", ["--filter", "@baize/control-plane", "start"], { diff --git a/packages/e2e/src/stack.ts b/packages/e2e/src/stack.ts index d55207f..788be59 100644 --- a/packages/e2e/src/stack.ts +++ b/packages/e2e/src/stack.ts @@ -157,6 +157,7 @@ export async function startStack(options: StackOptions): Promise { process.env["X_API_BASE_URL"] = options.xApiBaseUrl ?? `${UNREACHABLE}/x`; const credentialsKey = randomBytes(32).toString("base64"); process.env["CREDENTIALS_KEY"] = credentialsKey; + process.env["TOOL_SECRET_KEY"] = randomBytes(32).toString("hex"); // 端口交给系统分配,同一台机器上别的进程占着 3001 也不影响。 const server = await startServer({ From e5cbd1f62620178293277dc18965857f51c237f1 Mon Sep 17 00:00:00 2001 From: zouyanjian Date: Fri, 23 Oct 2026 22:28:49 +0800 Subject: [PATCH 3/3] =?UTF-8?q?=E6=8E=A7=E5=88=B6=E9=9D=A2=E5=90=AF?= =?UTF-8?q?=E5=8A=A8=E5=85=88=E9=AA=8C=E4=B8=A4=E6=8A=8A=E9=92=A5=E5=8C=99?= =?UTF-8?q?=E5=86=8D=E6=8B=BF=E5=8D=95=E5=AE=9E=E4=BE=8B=E9=94=81=EF=BC=8C?= =?UTF-8?q?=E7=BC=BA=E9=92=A5=E5=8C=99=E6=97=B6=E4=B8=8D=E8=BF=9E=E5=BA=93?= =?UTF-8?q?=E7=9B=B4=E6=8E=A5=E6=8B=92=E7=BB=9D=E5=90=AF=E5=8A=A8=20(#23)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #85 把单实例锁放在启动最前面,缺 TOOL_SECRET_KEY 时会先撞上连库失败,报错不再点名缺的变量。 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Be3SZps2JjqvyrJ1vbLmni --- packages/control-plane/src/main.ts | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/packages/control-plane/src/main.ts b/packages/control-plane/src/main.ts index 4eb7548..88a0dce 100644 --- a/packages/control-plane/src/main.ts +++ b/packages/control-plane/src/main.ts @@ -1,5 +1,13 @@ import { connect } from "./db/client.js"; -import { databaseUrl, port, runtimeConfig, secureCookie, trustedProxyHops } from "./env.js"; +import { + credentialsKey, + databaseUrl, + port, + runtimeConfig, + secureCookie, + toolSecretKey, + trustedProxyHops, +} from "./env.js"; import { acquireInstanceLock } from "./instance-lock.js"; import { startServer, type RunningServer } from "./server.js"; @@ -47,6 +55,8 @@ process.on("uncaughtException", (error) => { }); try { + // 钥匙先验:缺了直接拒绝启动,不去连库,也不占单实例锁。 + const keys = { toolSecretKey: toolSecretKey(), credentialsKey: credentialsKey() }; await acquireInstanceLock(databaseUrl(), () => { console.error("持有单实例锁的库连接断了,别的实例可能已经拿走了锁,按停机流程退出"); stop(1); @@ -58,6 +68,7 @@ try { secureCookie: secureCookie(), trustedProxyHops: trustedProxyHops(), config: runtimeConfig(), + ...keys, }); } catch (error) { console.error("控制面没起来", error);