Files
MailUI4Agents/plugins/pi-mail-bridge/test/identity-selfcheck.test.mjs
JianFeeeee e3c10cbd65 fix(pi-bridge)★★: 身份绑定冲突时中止,不再以错误身份继续运行
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 位`,让漂移可见
2026-10-05 11:04:55 +08:00

69 lines
3.6 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/*
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 两条路径');
});