Files
MailUI4Agents/plugins/pi-mail-bridge/test/relay-key.test.mjs
JianFeeeee 630b5bfdd7 fix(bridges): 续谈失败静默 + duplicate_relay 静默挂死(两个都是「人那边什么都收不到」)
同一类问题在两个地方:出事的当下看不出来,表现是「信发出去了,然后再无音讯」。

## 1)pi 的续谈失败不回失败信(实测缺口)

模型侧 402(余额不足)时,**新会话**那条路会回一封「处理失败」,而**续谈**那条路
只写日志就 `throw` —— 发件人什么都收不到。邮件驱动的会话没有本地界面可以看,
没有这封信就等于静默挂死。复现条件很普通:往一条**已存在**的会话再发一封信。

修法与邻居一致:续谈失败也回失败信。但**不能复用**共用库的 `renderFailureReport`
——那段文案说「划定范围内的模型全部调用失败」并建议「调整可用模型范围」,
而续谈是**故意不降级**的(换模型=换会话=丢掉上下文,而上下文正是发件人指定
这条会话的原因)。照抄等于让人去调一个在这里无效的旋钮,他会去改配置,
然后发现依然失败。新增 `renderResumeFailure`:点明是续谈、附上游错误原文、
建议「确实要换模型就新建一条会话」。

**活体验证**(模型侧仍是 402,失败本身就是测试条件):发一封进 pi 的已有会话,
5 秒内收到失败信,内容含 402 原文且不再出现「调整模型范围」。

顺带把 pi 里 2 处没 clamp 的 relay_key 收敛(上一轮审计只看了权限键)。

## 2)duplicate_relay:只有 zcode 认,另三桥会等一个永远不会来的决策

网关对重复的 relay_key 回 **HTTP 200 `{status:"duplicate_relay"}` 并提前返回**:
不建请求、不发邮件、**永远不会有人来决策**。zcode 桥认它并当场失败,而
pi/opencode/dsh 把它当成功,接着等 `permission_decision` 事件 —— pi 那句
`await new Promise(...)` 连超时都没有。这是 zcode 上一轮那个缺陷的同类,
只是发生在另三个桥上。

- `lib/relay-key.js`(**共用**,四处逐字节同源)新增 `isDuplicateRelay` /
  `DUPLICATE_RELAY_STATUS`:它长得像成功(200),所以必须单独认;对「发信」
  那一侧重复就该当成功(幂等),但对「等一个决定」那一侧它与故障后果相同。
- pi / opencode / dsh 三桥在权限转发处接上判据并**当场拒绝**
  (各自用自己的拒绝形状:`block: true` / `output.status = "deny"` / `'rejected'`)。
- zcode 里那份本地实现收敛到共用库(同一判据不该有两个定义)。

## 3)新增接线断言(带判据自检)

`test/permission-forward-wiring.test.mjs`(pi/opencode/dsh 三份同一内容):
纯函数测试对这类缺口天生无能为力(函数是对的,只是没人调用它),所以它读源码
验形态,钉住「判据在、落在权限转发这条路上、给出本桥形状的拒绝」。

三条自检都在写的过程中抓到了我自己的错:
- 第一次 `ROOT` 算错 → 过滤后 0 个桥、循环全不跑而「全绿」→ 加了
  「找不到装着各桥的目录就判红」;
- 顺序判据写成「在文件里最早的 await 之前」,量到了别处的等待 → 三桥全红,
  改成「必须在上报之后」;
- dsh 是**两段式**(`.then` 里抛、`catch` 的 `duplicateRelay` 分支里拒),
  第一版抽取套错了分支 → 永远找不到 `return 'rejected'`。
扰动验证:把 pi 的判据禁用后该条变红,还原即绿(改动前后都核对了字节数)。

而 dsh 那条也暴露了:我把返回形状写成了 opencode 的 `{status:'deny'}`,
**`tsc` 没报错**(返回类型是宽联合),只有对着邻居读才发现 DSH 要的是
`'rejected'` 字符串 + `noteDenial`。

## 4)部署脚本:zcode 分支现在会重启驱动

`redeploy-plugin.sh` 的 zcode 分支只切软链(宿主是 ZCode 应用,不能重启它),
但**驱动是我们自己的 unit** —— 不重启它,进程里跑的还是切换前的代码。
这个由刚写的 `check-deploy-drift.mjs` 当场抓到(它比进程启动时刻与软链切换时刻),
而当时所有其它检查都是绿的。已补上重启并验证。

## 复查

四桥全量 413 / 321 / 370 / 380 全绿;共用库四方同源;部署漂移四项全通过;
四桥真发真收冒烟(dsh/opencode/zcode 正常回信;pi 因模型侧 402 回失败信 ——
这正是上面第 1 条要修的路径)。

另:写这段时踩到一个自伤 —— 用 `npx asar extract-file <asar> dist/index.html`
检查包内容时,它把文件**写进了 cwd**,正好覆盖掉 Vite 的源码模板
`client/electron/index.html`(下次构建会拿被污染的模板去构建)。已还原并重建,
产物哈希与之前一致。要看 asar 内容请用 `@electron/asar` 的 API(返回 Buffer),
别用这个 CLI 子命令。
2026-09-12 23:13:48 +08:00

236 lines
9.5 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.

/**
* lib/relay-key.js 的测试 —— 四个平台逐字节共用。
*
* 事故背景生产实测pi 会话里 bash 的 relay_key 突然超过服务端 160 字节
* 列宽,返回 400。真实会话文件里 toolCallId 有两种形态:
* toolu_bdrk_01F6roEBHa8nic1mYiyLgNWK 35 字节
* toolu_bdrk_01FsWUWhEs4arnEWo44gqzLC~sig1:CAISoQIK… 437 ~ 13601 字节
* 启用 extended thinking 时 Bedrock 把思考签名拼进了 toolCallId。
*
* 更严重的是那次 400 被归入「暂时失败 → 让位给本地决策」,而邮件驱动的
* worker 没有 TUI —— 那次 bash 没有任何人批准就执行了。
*/
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { createHash } from 'node:crypto';
import {
RELAY_KEY_MAX_BYTES,
byteLength,
truncateToBytes,
clampRelayKey,
isPermanentFailure,
isDuplicateRelay,
DUPLICATE_RELAY_STATUS,
} from '../lib/relay-key.js';
// ─── byteLength ───
test('byteLength 算的是 UTF-8 字节而不是字符数', () => {
assert.equal(byteLength('abc'), 3);
assert.equal(byteLength('中文'), 6); // 每个 3 字节
assert.equal(byteLength(''), 0);
assert.equal(byteLength(null), 0);
assert.equal(byteLength(undefined), 0);
});
// ─── truncateToBytes ───
test('未超限时原样返回', () => {
assert.equal(truncateToBytes('abcdef', 10), 'abcdef');
assert.equal(truncateToBytes('abcdef', 6), 'abcdef');
});
test('ASCII 按字节精确截断', () => {
assert.equal(truncateToBytes('abcdef', 3), 'abc');
});
test('不切出半个多字节字符', () => {
// '中文' = 6 字节。上限 4 时不能切出 '中' + 半个 '文'
const out = truncateToBytes('中文', 4);
assert.equal(out, '中');
assert.equal(byteLength(out) <= 4, true);
// 结果必须能无损往返(有半个字符时会变成 U+FFFD
assert.equal(out.includes('\uFFFD'), false);
});
test('截断结果的字节数永不超上限(扫一遍长度)', () => {
const s = '会话abc标识def中文gh';
for (let limit = 0; limit <= byteLength(s) + 2; limit++) {
const out = truncateToBytes(s, limit);
assert.equal(byteLength(out) <= limit, true, `limit=${limit} 时超了`);
assert.equal(out.includes('\uFFFD'), false, `limit=${limit} 时切出了半个字符`);
}
});
test('上限 0 或负数返回空串', () => {
assert.equal(truncateToBytes('abc', 0), '');
assert.equal(truncateToBytes('abc', -5), '');
});
// ─── clampRelayKey ───
test('正常长度的键原样返回(不能改写已合规的键)', () => {
// 生产上真实的 pi 键36 字节会话 id + ':' + 35 字节 toolCallId = 72
const key = '01a05a5e-8abb-7bf4-bc87-47eadae619a8:toolu_bdrk_01CJevE1rw69DyVWSJv3n3eA';
assert.equal(byteLength(key) <= RELAY_KEY_MAX_BYTES, true);
assert.equal(clampRelayKey(key), key);
});
test('恰好等于上限时原样返回(边界不能差一)', () => {
const key = 'k'.repeat(RELAY_KEY_MAX_BYTES);
assert.equal(clampRelayKey(key), key);
});
test('超一个字节就收敛', () => {
const key = 'k'.repeat(RELAY_KEY_MAX_BYTES + 1);
const out = clampRelayKey(key);
assert.notEqual(out, key);
assert.equal(byteLength(out) <= RELAY_KEY_MAX_BYTES, true);
});
test('收敛后一定不超上限(用真实的带签名 toolCallId 长度)', () => {
// 生产实测 437 ~ 13601 字节都出现过
for (const n of [437, 1000, 5493, 13601]) {
const key = `01a05a5e-8abb-7bf4-bc87-47eadae619a8:toolu_bdrk_01X~sig1:${'A'.repeat(n)}`;
const out = clampRelayKey(key);
assert.equal(byteLength(out) <= RELAY_KEY_MAX_BYTES, true, `n=${n} 时超了`);
}
});
test('同一输入永远得到同一输出(幂等键的根本要求)', () => {
const key = `sess:${'x'.repeat(500)}`;
assert.equal(clampRelayKey(key), clampRelayKey(key));
});
test('不同输入不撞键 —— 这正是不能直接截断的理由', () => {
// 两个键前 160 字节完全相同,只有尾部不同。
// 直接截断会让它们变成同一个键,第二次询问被服务端当重复请求丢掉。
const common = 'a'.repeat(300);
const k1 = `${common}:call-1`;
const k2 = `${common}:call-2`;
assert.notEqual(clampRelayKey(k1), clampRelayKey(k2));
});
test('收敛结果保留可读前缀(日志里还能 grep 出会话)', () => {
const sid = '01a05a5e-8abb-7bf4-bc87-47eadae619a8';
const out = clampRelayKey(`${sid}:toolu_bdrk_01X~sig1:${'A'.repeat(900)}`);
assert.equal(out.startsWith(sid), true);
assert.match(out, /:sha256:[0-9a-f]{64}$/);
});
test('哈希是原始键的完整 sha256不是截断后的', () => {
const key = `sess:${'y'.repeat(400)}`;
const expect = createHash('sha256').update(key, 'utf8').digest('hex');
assert.equal(clampRelayKey(key).endsWith(`:sha256:${expect}`), true);
});
test('含中文的超长键不切出半个字符', () => {
const key = `会话标识:${'中'.repeat(300)}`;
const out = clampRelayKey(key);
assert.equal(byteLength(out) <= RELAY_KEY_MAX_BYTES, true);
assert.equal(out.includes('\uFFFD'), false);
});
test('上限小到装不下哈希时退化为截断哈希(仍然确定)', () => {
const key = 'z'.repeat(500);
const out = clampRelayKey(key, 20);
assert.equal(byteLength(out) <= 20, true);
assert.equal(out, clampRelayKey(key, 20));
});
test('空键与 null 不炸', () => {
assert.equal(clampRelayKey(''), '');
assert.equal(clampRelayKey(null), '');
assert.equal(clampRelayKey(undefined), '');
});
// ─── isPermanentFailure ───
test('400 是永久失败 —— 事故的核心(原来被当暂时失败让位)', () => {
assert.equal(isPermanentFailure({ status: 400 }), true);
});
test('409 是永久失败(这条链上没有人类,永远不会有人点头)', () => {
assert.equal(isPermanentFailure({ status: 409 }), true);
});
test('401 是永久失败:密钥无效要人去后台登记,不是等一等就好', () => {
// 本会话实测opencode 拿着已撤销的密钥重试了 18 小时2690 次 401
assert.equal(isPermanentFailure({ status: 401 }), true);
});
test('403 / 404 / 422 都是永久失败', () => {
for (const s of [403, 404, 422]) {
assert.equal(isPermanentFailure({ status: s }), true, `${s} 应当是永久`);
}
});
test('408 与 429 是暂时失败(超时与限流等一会儿真的可能成功)', () => {
assert.equal(isPermanentFailure({ status: 408 }), false);
assert.equal(isPermanentFailure({ status: 429 }), false);
});
test('5xx 是暂时失败(服务端的问题)', () => {
for (const s of [500, 502, 503, 504]) {
assert.equal(isPermanentFailure({ status: s }), false, `${s} 应当是暂时`);
}
});
test('没有 status 的错误按暂时处理网络层DNS / 连接被拒)', () => {
assert.equal(isPermanentFailure(new Error('fetch failed')), false);
assert.equal(isPermanentFailure({}), false);
assert.equal(isPermanentFailure(null), false);
assert.equal(isPermanentFailure(undefined), false);
});
test('status 是字符串时也能判HTTP 客户端可能挂上字符串)', () => {
assert.equal(isPermanentFailure({ status: '400' }), true);
assert.equal(isPermanentFailure({ status: '503' }), false);
});
test('2xx / 3xx 不算永久失败(本不该走到这里,但不能误判成永久)', () => {
assert.equal(isPermanentFailure({ status: 200 }), false);
assert.equal(isPermanentFailure({ status: 302 }), false);
});
// ─── isDuplicateRelay ─────────────────────────────────────────────────────
//
// 这一组来自一个**静默挂死**:网关对重复的 relay_key 返回 HTTP 200 +
// `{status:"duplicate_relay"}` 并提前返回(不建请求、不发邮件、永远不会有人决策),
// 而 pi 的 worker 在 post 之后无条件等着 `permission_decision` 事件 ——
// 长得像成功,实际永远等不到。所以「发成功了」与「什么都没发生」必须分得开。
test('★ 认得出幂等命中(权限询问与代发邮件两个出口都是这个回包)', () => {
assert.equal(
isDuplicateRelay({ status: DUPLICATE_RELAY_STATUS, relay_key: 'k', detail: '该权限询问已转发过,本次调用未产生新邮件' }),
true
);
// 邮件出口的措辞不同status 相同 —— 判定只看 status不看 detail
assert.equal(
isDuplicateRelay({ status: DUPLICATE_RELAY_STATUS, relay: 'summary', detail: '该上游消息已转发过,本次调用未产生新邮件' }),
true
);
});
test('★ 反向对照:正常的 200 回包不能被当成幂等命中', () => {
// 误判的代价是反的:把正常请求当重复 ⇒ 明明发出去了却当场拒绝。
for (const res of [{}, { status: 'ok' }, { status: 'pending' }, { status: 'decided' }, { status: '' }]) {
assert.equal(isDuplicateRelay(res), false, JSON.stringify(res));
}
});
test('★ 非对象输入不炸也不误判', () => {
for (const v of [null, undefined, '', 'duplicate_relay', 0, false, [], ['duplicate_relay']]) {
assert.equal(isDuplicateRelay(v), false, JSON.stringify(v));
}
// 数组即使带 status 字段也不算(防止把响应列表当成单个响应)
assert.equal(isDuplicateRelay([{ status: DUPLICATE_RELAY_STATUS }]), false);
});
test('status 常量与服务端字面量一致(改一处会两边不认,这条钉住)', () => {
// gateway 侧server/internal/handler/{permission,mail}.go 都写字面量 "duplicate_relay"
assert.equal(DUPLICATE_RELAY_STATUS, 'duplicate_relay');
});