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 位`,让漂移可见
69 lines
3.6 KiB
JavaScript
69 lines
3.6 KiB
JavaScript
/*
|
||
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 两条路径');
|
||
});
|