From e3c10cbd6523a5743c5cd98500d27f7af8467f81 Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Mon, 5 Oct 2026 11:04:55 +0800 Subject: [PATCH] =?UTF-8?q?fix(pi-bridge)=E2=98=85=E2=98=85:=20=E8=BA=AB?= =?UTF-8?q?=E4=BB=BD=E7=BB=91=E5=AE=9A=E5=86=B2=E7=AA=81=E6=97=B6=E4=B8=AD?= =?UTF-8?q?=E6=AD=A2=EF=BC=8C=E4=B8=8D=E5=86=8D=E4=BB=A5=E9=94=99=E8=AF=AF?= =?UTF-8?q?=E8=BA=AB=E4=BB=BD=E7=BB=A7=E7=BB=AD=E8=BF=90=E8=A1=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 2026-10-05。起因是一份 bug 报告里的三条,我只认了其中一条真的。 ## 先说报告里那两条**不成立**的(我实测了) ① 「服务端会用 200 + {"error":…} 返回业务失败,所以回执是假成功」 实测穷举所有失败路径,**没有一条 200 带 error**: 跨会话读 403 缺 workspace 400 坏凭证 401 邮件不存在 404 send 缺参数 400 收件人不存在 404 根因:`Error(w, status, msg)` 的 status 是必填参数, 全仓不存在 `Error(w, 200, …)`。所以 `post()` 只在 `!res.ok` 时抛错是对的。 ② 「sent_total=0 说明没发出去」 **Agent 身份没有「已发送」端点**: GET /api/v1/me/mail/sent → 401(那是人类端点) Agent 能用的邮件端点只有 mail/{id}、mail/{id}/thread、contacts、calendar。 `sent_total=0` 不是「计数为 0」,是**根本查不到** —— 把「查不到」读成「没发出去」,这是整条误判链的起点。 而实际证据是**信送到了**: mail_id 317a0b4b… from laptoppi-win → jianf mail_reads: reader_name=jianf, read_at=2026-10-05 02:55:49 (发信后 13 分钟被人亲自读了) ## 报告里那条**真的**:换 key 后不自知 它的根因描述准确:身份由密钥决定,而配置在模块加载期读入,改 env 不热生效。 本仓库的桥同构(`index.mjs` 顶部 `const AGENT_SECRET = process.env…`), 且启动日志只印**本地配置里**的名字 —— 于是漂移在日志里看不出来。 ## 修法:服务端早就内置了这道闸,桥没接 实测 `POST /api/v1/agent/register`: {"name":"pi"} + dsh 的 key → {"error":"该密钥已绑定到 Agent \"dsh\",不能用于注册 \"pi\""} 「这把 key 到底是谁的」服务端能权威回答。 旧代码把注册失败**当普通错误打印一行就继续跑** —— 于是继续以错误身份 发信/收信/上报心跳,而日志里那句「身份 ${AGENT_NAME}」读起来一切正常。 两处改动: 1. **冲突时中止**:识别 `/已绑定到 Agent/` 后 `throw`, 并打印**怎么查**(sqlite 查 agent_keys)、**怎么改**(改 env + 必须重启)。 ★ 只对绑定冲突停 —— 密钥无效 / 网络不通属可恢复的, 停掉会连带 SSE 与心跳,而那两样本就该重试后自愈。 2. **启动日志印 key 前 8 位**:身份实际由密钥决定,只印配置里的名字 不足以代表真实身份。现在一行能对出来: 已接入 http://127.0.0.1:8180,身份 pi(密钥认证 c31044d6…) ★ 印 8 位不发全串 —— 避开日志泄密。 ## 判据(新建,5 格,3 变异全红) 变异① 只记日志不中止 → 转红 变异② 认不出冲突 → 转红 变异③ 日志不印 key 片段 → 转红 pi 桥全量:533 → **538 格**(+5),0 失败。已部署并重启线上服务。 ## 没能修的:Windows 上那个扩展 `C:\Users\21989\.pi\agent\extensions\agentmail.ts` 本机不可达。 它需要做的两件事,本仓库这个提交就是参照: 1. 注册/首次调用时拿服务端权威答复核对身份,不一致就**停下并说清怎么查** 2. 启动日志同时印出 `agent_name` + `key 前 8 位`,让漂移可见 --- plugins/pi-mail-bridge/src/index.mjs | 41 ++++++++++- .../test/identity-selfcheck.test.mjs | 68 +++++++++++++++++++ 2 files changed, 107 insertions(+), 2 deletions(-) create mode 100644 plugins/pi-mail-bridge/test/identity-selfcheck.test.mjs diff --git a/plugins/pi-mail-bridge/src/index.mjs b/plugins/pi-mail-bridge/src/index.mjs index 60f443d..0e7cdb4 100644 --- a/plugins/pi-mail-bridge/src/index.mjs +++ b/plugins/pi-mail-bridge/src/index.mjs @@ -449,11 +449,48 @@ async function main() { try { await client.register(); // B-1.2 saveConfig({ gateway_url: GATEWAY_URL, agent_name: AGENT_NAME, registered_at: new Date().toISOString() }); - log(`已接入 ${GATEWAY_URL},身份 ${AGENT_NAME}(${agentKey ? '密钥认证' : 'name/secret 认证'})。`); + // ★ 2026-10-05:把**密钥**也印出一段。原先只印 AGENT_NAME(本地配置里的), + // 而身份实际由密钥决定 —— 于是「配置的 name 与密钥归属不一致」在日志里 + // 完全看不出来(读起来一切正常,只有收信方在库里看到另一个署名才会发现)。 + // 印 key 前 8 位后,那类漂移一眼能对出来;不发全串,避开日志泄密。 + log(`已接入 ${GATEWAY_URL},身份 ${AGENT_NAME}(${agentKey ? `密钥认证 ${String(agentKey).slice(0, 8)}…` : 'name/secret 认证'})。`); } catch (e) { // 密钥未登记时这里报「密钥无效」—— 必须说清该做什么, // 否则用户只看到一句 401,不知道要拿密钥去后台登记。 - log(`注册失败: ${describeError(e)}`); + const why = describeError(e); + log(`注册失败: ${why}`); + + // ★ 2026-10-05:身份绑定冲突必须**停下**,不能只记一行日志就继续跑。 + // + // 服务端已经内置这道闸(实测): + // {"error":"该密钥已绑定到 Agent \\"dsh\\",不能用于注册 \\"pi\\""} + // + // 也就是说「这把 key 到底是谁的」服务端能权威回答。旧代码把它当普通 + // 错误打印一句就往下走,于是**继续以错误身份发信/收信/上报心跳** —— + // 而且日志里那句「身份 ${AGENT_NAME}」印的是**本地配置里的名字**, + // 读起来一切正常;只有收信方在库里看到另一个署名才会发现。 + // + // 与「配置在模块加载期被缓存」同族(换 key 后不自知),但那道无法在本文件 + // 修(何时读 env 由 pi 决定),这一道可以:服务端已经给了确定答复的时候 + // 把进程停住。 + // + // ★ 只对「绑定冲突」停。密钥无效 / 网络不通属于可恢复的, + // 停掉会连带 SSE 与心跳(那两样本就该重试后自愈)。 + if (/已绑定到 Agent/.test(why)) { + log(''); + log('★★ 身份冲突,已停止:上面这把密钥在服务端属于另一个 Agent。'); + log(' 本进程配置的 agent_name 与密钥实际归属不一致,继续运行会'); + log(' 以错误身份发信 —— 收信方在库里看到的署名会与预期不符。'); + log(''); + log(' 查这把 key 实际属于谁:'); + log(' sqlite3 /opt/agentmail/data/agentmail.db \\'); + log(" \"SELECT agent_name FROM agent_keys WHERE key_token LIKE '%';\""); + log(' 然后把 pi.env 里的 AGENTMAIL_AGENT_NAME 改成那个名字(或换一把匹配的密钥),'); + log(' 并**重启进程** —— 配置在模块加载期读入,改 env 不热生效。'); + log(''); + throw new Error(`身份冲突:${why}`); + } + if (agentKey) log(`若提示密钥无效,请让管理员在 AgentMail 后台登记这把密钥。`); } diff --git a/plugins/pi-mail-bridge/test/identity-selfcheck.test.mjs b/plugins/pi-mail-bridge/test/identity-selfcheck.test.mjs new file mode 100644 index 0000000..362ab0e --- /dev/null +++ b/plugins/pi-mail-bridge/test/identity-selfcheck.test.mjs @@ -0,0 +1,68 @@ +/* +pi 桥的身份自检判据(2026-10-05)。 + +★ 起因(一份 bug 报告里的三条,我只认了其中一条真): + 报告称「扩展 key 缓存在模块加载期,换 key 后不自知,仍以旧身份发信」。 + 我先验证了服务端行为,发现**服务端早就内置了这道闸**: + + POST /api/v1/agent/register {"name":"pi"} + dsh 的 key + → {"error":"该密钥已绑定到 Agent \"dsh\",不能用于注册 \"pi\""} + + 也就是说「这把 key 到底是谁的」服务端能权威回答。 + 而旧代码把注册失败**当普通错误打印一行就继续跑** —— + 于是继续以错误身份发信/收信/上报心跳,且日志里读起来一切正常。 + +★ 顺带纠正报告里的另一条:它说「服务端会用 200 + {error} 返回业务失败」。 + 实测穷举了所有失败路径(403/400/401/404),**没有一条 200 带 error**; + `Error(w, status, msg)` 的 status 是必填参数,全仓无 `Error(w, 200, …)`。 + 所以那不是「假成功」的成因。 +*/ + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { dirname, join } from 'node:path'; + +const here = dirname(fileURLToPath(import.meta.url)); +const src = readFileSync(join(here, '../src/index.mjs'), 'utf8'); + +test('★ 身份绑定冲突必须停下,不能只记日志继续跑', () => { + // 服务端已经给了确定答复,桥必须据此中止 —— + // 否则它会继续以错误身份发信,而日志里看不出来。 + assert.match(src, /\/已绑定到 Agent\/\.test\(why\)/, + '★ 没有识别「身份绑定冲突」—— 注册失败被当普通错误处理,继续以错误身份运行'); + assert.match(src, /throw new Error\(`身份冲突:\$\{why\}`\)/, + '★ 识别到了却没中止 —— 冲突时仍继续发信/收信/心跳'); +}); + +test('只对绑定冲突停,不把可恢复错误也停掉', () => { + // 密钥无效 / 网络不通属于可恢复的:停掉会连带 SSE 与心跳, + // 而那两样本就该在重试后自愈。 + const m = src.match(/if \(\/已绑定到 Agent\/\.test\(why\)\) \{([\s\S]*?)\n {4}\}/); + assert.ok(m, '找不到绑定冲突分支'); + assert.doesNotMatch(m[1], /密钥无效/, + '★ 冲突分支里不要混入「密钥无效」的处置 —— 那是另一类可恢复错误'); + assert.match(src, /注册失败: \$\{why\}/, + '注册失败仍要记日志(停之前得让人看见原因)'); +}); + +test('冲突时要说清怎么查、怎么改', () => { + assert.match(src, /agent_keys/, '没有告诉用户去哪儿查这把 key 属于谁'); + assert.match(src, /重启进程/, '没有提示必须重启(配置在模块加载期读入,改 env 不热生效)'); +}); + +test('启动日志要印出密钥片段,让身份漂移可见', () => { + // 原先只印 AGENT_NAME(本地配置里的),而身份实际由密钥决定 ⇒ + // 「配置的 name 与密钥归属不一致」在日志里完全看不出来。 + assert.match(src, /String\(agentKey\)\.slice\(0, 8\)/, + '★ 启动日志没印 key 片段 —— 身份漂移无法一眼对出'); + assert.doesNotMatch(src, /\$\{agentKey\}(?!\.slice)\}/, + '★ 密钥被原样打进日志 —— 会泄进日志文件'); +}); + +test('身份由密钥决定,不靠 name/secret 头', () => { + // 报告里提到「X-Agent-Name 对服务端无效」。这条要钉住: + // 身份只来自 Bearer 密钥,所以打印 config 里的名字不足以代表真实身份。 + assert.match(src, /密钥认证/, '启动日志应区分密钥认证与 name/secret 两条路径'); +});