Files
MailUI4Agents/plugins/opencode-mail-bridge/test/permission-forward-wiring.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

200 lines
8.3 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.

/**
* 接线断言:「网关把重复 relay_key 判为幂等命中」时,各桥必须**当场拒绝**
* 而不是把那句 `await 决策` 一直等下去。
*
* # 为什么需要一条这样的测试
*
* 判据本身(`isDuplicateRelay`)在 `lib/relay-key.js` 里有单测 —— 但那次缺口
* 不在判据,而在**接线**:只有 zcode 桥认这个回包pi/opencode/dsh 把它当成功,
* 然后等一个永远不会来的 `permission_decision`,表现为静默挂死。
* 纯函数测试对这种缺口天生无能为力:函数是对的,只是没人调用它。
*
* # 为什么读源码而不是跑起来
*
* 三个桥的入口都**不是可导入的模块**pi 的 worker 是子进程、opencode 把入口的
* 每个导出当插件工厂检查、dsh 的入口拉起整个 Cordis 插件。让它们可测需要把
* 这些路径重构成可注入的形状,那是更大的改动面 —— 而这里要钉住的只有一件事:
* 「那个判断还在、且落在权限转发这条路上」。
*
* 与 `narrow-layout.test.mjs` 同一取舍:**验形态,不验行为**。
* 代价是它对重构敏感(改动这一段的写法时这里会红),这正是它该做的提醒。
*
* # 这条测试自己也有判据自检
*
* 一个「永远为真」的接线断言比没有更糟:它会让下一个人以为这里被覆盖着。
* 所以下面先证明「拿一段没有该判断的源码喂给它,它会红」。
*/
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync, existsSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
const HERE = dirname(fileURLToPath(import.meta.url));
/**
* 向上找到「装着各桥的那个目录」。
*
* 不写死 `../..`:这个文件会被复制到每个桥的 test/ 下,也可能被从别的 cwd 跑。
* 找不到时**必须判红**,不能静默返回空列表 —— 那会让下面每条断言都不执行,
* 而结果看起来是「全绿」(这条自检第一次跑就是这么逮住我自己的:过滤后 0 个桥)。
*/
function findPluginsDir(start) {
let dir = start;
for (let i = 0; i < 6; i++) {
if (existsSync(join(dir, 'pi-mail-bridge')) && existsSync(join(dir, 'opencode-mail-bridge'))) return dir;
const up = dirname(dir);
if (up === dir) break;
dir = up;
}
return null;
}
const PLUGINS_DIR = findPluginsDir(HERE);
/**
* 各桥的入口文件与它自己的「拒绝」写法。
*
* 三家的返回形状**各不相同**pi 是 `{block:true, reason}`、opencode 是
* `output.status = "deny"`、dsh 是返回 DSH 的 ApprovalOutcome 字符串 `'rejected'`
* 所以这里逐个列出来 —— 我第一版给 dsh 写成了 opencode 的形状,
* 而 `tsc` **没报错**(返回类型是宽联合),只有对着邻居读才发现。
*/
const BRIDGES = [
{
name: 'pi-mail-bridge',
source: 'src/worker.mjs',
// pi 在同一个分支里既判又拒
branch: /if\s*\(\s*isDuplicateRelay\(/,
denyShape: /block:\s*true/,
denyShapeDesc: 'block: true'
},
{
name: 'opencode-mail-bridge',
source: 'index.js',
branch: /if\s*\(\s*isDuplicateRelay\(/,
denyShape: /output\.status\s*=\s*"deny"/,
denyShapeDesc: 'output.status = "deny"'
},
{
name: 'dsh-mail-bridge',
source: 'src/index.ts',
// dsh 是**两段式**.then 里把重复转成异常抛出去catch 的 duplicateRelay
// 分支里才写拒绝。所以「分支」要取 catch 那一段(第一版取到了 .then 那段,
// 于是永远找不到 return 'rejected' —— 是判据错了,不是代码错了)。
branch: /if\s*\(\s*e\?\.duplicateRelay\s*\)/,
denyShape: /return\s+'rejected'/,
denyShapeDesc: "return 'rejected'",
// 同时要求它确实认得这个回包(.then 那段)
alsoNeeds: /isDuplicateRelay\(/
}
].filter(b => PLUGINS_DIR && existsSync(join(PLUGINS_DIR, b.name)));
/** 取出「重复回包 → 拒绝」那个分支的源码片段(从 if 到下一个同级收尾)。 */
export function duplicateBranchOf(source, branch) {
const m = source.match(branch || /if\s*\(\s*isDuplicateRelay\(/);
if (!m) return null;
const start = m.index;
let depth = 0;
let i = source.indexOf('{', start);
if (i < 0) return null;
for (let j = i; j < source.length; j++) {
if (source[j] === '{') depth++;
else if (source[j] === '}') {
depth--;
if (depth === 0) return source.slice(start, j + 1);
}
}
return null;
}
/** 权限转发必须**先**判重复:判据要出现在等决策之前。 */
export function handlerChecksDuplicateFirst(source, denyShape, branchPattern) {
const branch = duplicateBranchOf(source, branchPattern);
if (!branch) return { ok: false, why: '源码里找不到处理重复回包的分支' };
if (!denyShape.test(branch)) {
return { ok: false, why: `重复分支里没有出现本桥的拒绝形状(期望 ${denyShape}` };
}
return { ok: true, why: '' };
}
test('★ 判据自检:拿没有该判断的源码喂进来必须判红', () => {
const PI = /if\s*\(\s*isDuplicateRelay\(/;
// ① 找不到分支 → 红
assert.equal(handlerChecksDuplicateFirst('const x = 1;', /block:\s*true/, PI).ok, false);
// ② 有分支但没有拒绝形状 → 红
assert.equal(
handlerChecksDuplicateFirst('if (isDuplicateRelay(accepted)) {\n console.error("重复");\n}', /block:\s*true/, PI).ok,
false
);
// 反向对照:形状写对时判绿(否则上面两条可能只是因为断言恒假)
assert.equal(
handlerChecksDuplicateFirst(
'if (isDuplicateRelay(accepted)) {\n return { block: true, reason: "x" };\n}',
/block:\s*true/,
PI
).ok,
true
);
// dsh 那种两段式形态branch 正则必须能取到 catch 那一段
const DSH = /if\s*\(\s*e\?\.duplicateRelay\s*\)/;
assert.equal(
handlerChecksDuplicateFirst(
"if (e?.duplicateRelay) {\n noteDenial(a, b, 'x');\n return 'rejected';\n}",
/return\s+'rejected'/,
DSH
).ok,
true
);
// 同一段源码,用错 branch 正则(取到 .then 那段)应当判红 —— 这正是第一版
assert.equal(
handlerChecksDuplicateFirst(
"if (isDuplicateRelay(accepted)) {\n throw new Error('dup');\n}",
/return\s+'rejected'/,
DSH
).ok,
false
);
});
test('★ 判据自检:源码位置读取本身是真的(不是空跑)', () => {
// 若过滤逻辑把所有桥都滤掉了,下面的循环会一条都不跑而「全绿」——
// 这条自检第一次跑就是这么逮住我自己的(路径写错 → 0 个桥)。
assert.ok(PLUGINS_DIR, `找不到装着各桥的目录(从 ${HERE} 向上找了 6 层)`);
assert.ok(BRIDGES.length >= 3, `应当至少覆盖三个桥,实际 ${BRIDGES.length}`);
});
for (const b of BRIDGES) {
test(`${b.name}:权限转发遇到 duplicate_relay 当场拒绝(而不是等一个不会来的决策)`, () => {
const file = join(PLUGINS_DIR, b.name, b.source);
assert.ok(existsSync(file), `找不到入口:${file}`);
const src = readFileSync(file, 'utf8');
// ① 必须认得这个回包(导入并在权限路径上用)
assert.match(
src,
/isDuplicateRelay/,
`${b.name} 没有认 duplicate_relay —— 网关对重复键回 HTTP 200 并提前返回,` +
'不认它就会一直等 permission_decision表现为静默挂死'
);
// ② 该判断必须落在权限转发这条路上,并且给出本桥形状的拒绝
const r = handlerChecksDuplicateFirst(src, b.denyShape, b.branch);
assert.ok(r.ok, `${b.name}${r.why}(期望 ${b.denyShapeDesc}`);
// ③ 两段式桥dsh还要认得这个回包
if (b.alsoNeeds) {
assert.match(src, b.alsoNeeds, `${b.name}:没有认 duplicate_relay 回包`);
}
// ④ 顺序:判据必须在**发出请求之后**。
// 第一版写成「在文件里最早的 await 之前」,量到的是别处的等待,
// 于是三个桥全红 —— 判据量错了对象,不是代码错了。
const postAt = src.indexOf('/permission/request');
const dupAt = src.search(b.branch);
assert.ok(postAt > 0, `${b.name}:找不到权限上报调用`);
assert.ok(dupAt > postAt, `${b.name}:重复判据出现在上报之前(${dupAt} < ${postAt}`);
});
}