Files
MailUI4Agents/plugins/pi-mail-bridge/test/cross-bridge-permission-routing.test.mjs
JianFeeeee 1ea92098d1 fix(四桥)★★: parent_from 方向判据在 dsh/opencode 上是死代码 —— 接线补齐
## 起因:修部署漂移时撞出来的

b0c8719(2026-09-30)给 `inboundHeadline` 加了 `parentFrom`/`selfName`,
用来分辨「回的是你那封」与「多方线索里的他人续谈」——单向续信链(8 封全是
对方发来的)同样满足 `in_reply_to`,不判方向就会被逐封宣称「回的是你那封」,
模型把它当新任务处理。**但只有 pi 桥接了**:

    pi        parentFrom: data?.parent_from    ✓(turn.mjs:148)
    dsh       从不传                        ✗ 3 处
    opencode  从不传                        ✗ 1 处

判据函数是三个桥**同一份** relay-policy.js 的拷贝,看起来测过了 ——
但「函数支持」≠「调用方传了」。三个桥各自都有 relay-policy 单元测试且全绿,
因为它们只测那个**纯函数**,没有一个测「桥真的把参数传进去了」。

dsh 还多一层:它有一份**手写**的 `lib/relay-policy.d.ts`,缺这两个字段
⇒ tsc 报 TS2353 ⇒ 调用方即使想传也过不了类型检查。那行判据是被类型系统
**主动拦住**的死代码。只补 .js 不补 .d.ts 等于没补。

## 改动

dsh 3 处 + opencode 1 处调用点补 `parentFrom`/`selfName`,对齐 pi 的样板;
dsh 的 `.d.ts` 补声明(注释写明「光有声明不够,调用点也得传」)。

## 新增判据 deploy/check-parent-from.mjs(7 格)

不只是查「有没有传」,还查**三处曾经不一致的接缝**:

  ① 每个 inboundHeadline 调用点都传了 parentFrom(次数写死,新增调用点
     忘了传要能被发现,而不是被 `>0` 静默接受)
  ② 传了 parentFrom 就必须传 selfName —— 只传前者时
     `parentFrom === selfName` 永不成立,判据形同虚设
  ③ 三份 relay-policy.js 都真的有 `parentFrom === selfName`
  ④ dsh 的 .d.ts 声明与实现一致(否则 tsc 拦住,判据等于没有)

**变异验证**:撤一处传参 → 红 2;只撤 selfName → 红 1;撤 .d.ts 声明 → 红 1。

## ★ 判据自身也踩了两次坑(都已修)

1. 初版 `.d.ts` 那格用 `\bparentFrom\b` 全文件搜 ⇒ 撤掉字段声明后,
   **注释文字里还留着这个词** ⇒ 照样匹配 ⇒ 变异不红(假绿)。
   改为只认字段声明 `^\s*parentFrom\??\s*:\s*string\s*;`。
2. 初版 `.mjs` 写了 `require` ⇒ ReferenceError(ESM)。已改 import。

## 顺带修掉 6 个陈旧判据(都在 HEAD 上、此前静默红着)

`turn.test.mjs` 的「回信到达时明说不是新任务」:只造 `in_reply_to` 却断言
`/m-0/`,而 `b0c8719` 新增的「我的回复」分支文案里压根不含父邮件 ID ——
判据没跟上那次改动。已改成覆盖三种情形(parent_from 是我 / 是别人 / 缺席)。

★ 我第一版改完是**假绿**:只断言 `/续谈/` 时,把 `mine` 分支改坏也会走
「续谈」分支而照样通过。加反向断言(mine 不含「续谈」、theirs 不含
「不是新任务」)后才抓住。变异 A/B 各 35 pass / 1 fail。

`cross-bridge-permission-routing.test.mjs`:zcode 的路径与目录还指向
`d09ef39` 已删的 `hooks/`、`mcp/` ⇒ ENOENT。zcode 确实**没有** 409 分支了
(移除执行类工具所致,非缺陷)⇒ `permanent: null` 表示不适用,并加反向
守卫:若 `src/` 里出现 `isPermanentFailure` 就红(防止前提变了却继续空转)。
`isPermanentFailure` 是 `lib/relay-key.js` 的通用网关辅助,与权限放行无关 ——
踩过一次:把 `lib` 加进扫描范围后对着共享辅助函数误报。

全量:pi 533 / opencode 364 / dsh 433,零红(此前 4 红)。

## 部署与端到端

pi / opencode / dsh 三个宿主均已部署;dsh 已真实收发验证:

    [dsh-mail-bridge] 本轮不自动转发:来信方 pi 是 Agent…
    [dsh-mail-bridge] new_mail -> 新会话 mail-8e8bf319…
2026-10-03 00:25:28 +08:00

223 lines
12 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.

/**
* 四桥**都**转发权限询问 —— 所以四桥都必须在 409 里认出「本档不该问」并放行。
*
* # 这条判据的来历(先记一笔错误,免得下一个人以为它天生存在)
*
* 我 2026-09-14 在回信里写过:「zcode / opencode 不转发权限询问(src 里没有
* `permission/request`,也没有 409 分支),所以不需要改」。pi 要求把这句**否定性事实**
* 变成常驻判据("你查过了"只值一次),于是写了这条 —— **它当场就红给我看**:
* zcode 的 `hooks/permission.mjs` 与 opencode 的 `index.js` 都在
* `post('/permission/request')`,也都有永久失败(含 409)分支。
*
* 我上次查错的原因是**搜索面太窄**:只扫了 `<plugin>/src/*.mjs`,
* 而 zcode 的转发在 `hooks/`、opencode 的实现就在根目录 `index.js`。
* "我查过了"这句话本身没算错,错在**查的范围是随手定的**。
*
* 于是这条判据有两个用处:
* 1. 四桥都转发 → 四桥都必须处理"409 带档位"这个情形(否则那条路径上工具调用
* 会被自己人拦死);
* 2. **否定性事实必须变成判据** —— 否则它只靠人记得,而人(包括我)会记错。
*
* # 判据形态
*
* 表驱动:每个桥登记「409 + full 的放行写法」「是否要写回状态」「永久失败锚点」。
* 找不到 → 红(不是跳过):"找不到"与"没有"在结果上必须区分开。
*/
import assert from 'node:assert/strict';
import { readFileSync, readdirSync, statSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { test } from 'node:test';
import { fileURLToPath } from 'node:url';
const HERE = dirname(fileURLToPath(import.meta.url));
const PLUGINS = join(HERE, '..', '..');
/**
* 每个插件的**实现**入口(只看实现,不看测试)。
*
* 布局四个桥各不相同(实测:dsh/pi/zcode 有 `src/`,zcode 的转发放在 `hooks/`,
* opencode 是根目录一个 `index.js`),所以显式登记而不是"扫 src、没有就算了"
* —— 后者正是本仓最忌讳的**静默放行**:扫不到就等于判据永远为真。
*/
const IMPL = {
'dsh-mail-bridge': ['src'],
'pi-mail-bridge': ['src'],
// ★ 2026-10-02:d09ef39 删掉了 zcode 的 mcp/ 与 hooks/(MCP 已内置网关,
// 执行门禁随之退场)。留着这两个目录名 ⇒ stat 抛 ENOENT ⇒ 整档红,
// 而且是「文件不存在」这种红,会盖住真正要看的判据失败。
'zcode-mail-bridge': ['src', 'lib'],
'opencode-mail-bridge': ['index.js'],
};
function readImpl(plugin) {
const out = [];
const walk = d => {
for (const e of readdirSync(d, { withFileTypes: true })) {
const p = join(d, e.name);
if (e.isDirectory()) walk(p);
else if (/\.(mjs|ts|js)$/.test(e.name)) out.push(readFileSync(p, 'utf8'));
}
};
for (const rel of IMPL[plugin]) {
const p = join(PLUGINS, plugin, rel);
const st = statSync(p); // 登记了就必须在:找不到时抛 ENOENT 让判据红,别静默跳过
if (st.isDirectory()) walk(p);
else out.push(readFileSync(p, 'utf8'));
}
assert.ok(out.length > 0, `${plugin} 的实现文本读出来是空的 —— 判据的读法要跟着布局改`);
return out.join('\n');
}
/** 剥注释:判"代码里有什么"必须读剥离版(本仓规范) */
const codeOnly = src =>
src.replace(/\/\*[\s\S]*?\*\//g, '').replace(/(^|[^:])\/\/[^\n]*/g, '$1');
/** 一个桥的登记:放行写法、是否要写回状态、永久失败锚点 */
const BRIDGES = [
{
plugin: 'dsh-mail-bridge',
allow: /corrected === MODE_FULL/,
mustWriteBack: /applyPermissionMode\(/,
permanent: 'if (isPermanentFailure(e))',
note: '409 里带 full 时放行,并把权威档位写回会话(否则沙箱仍是窄的)',
},
{
plugin: 'pi-mail-bridge',
allow: /permission_mode === MODE_FULL/,
mustWriteBack: null,
permanent: 'if (isPermanentFailure(e))',
note: 'pi 的档位是 per-job 的,没有会话状态要写回',
},
{
plugin: 'zcode-mail-bridge',
allow: /normalizeMode\(data\?\.permission_mode\)/,
allow: /normalizeMode\(data\?\.permission_mode\)/,
// ★ zcode 整条 409 分支都不存在(d09ef39 移除执行类工具后不再向人问权限),
// 所以「放行必须排在永久失败之前」对它**不适用**:没有 permanent 分支,
// 也就没有顺序可排。置 null = 本桥不参与该子判据,不是「跳过了检查」。
permanent: null,
// ★ zcode 已无 409 分支:d09ef39 移除执行类工具后,src/index.mjs:232 的
// modeReachesPermissionHook 只是提醒(本平台没有执行面),不存在「按
// workspace 档问人」这条路。此处只钉住它仍读 permission_mode 并归一档位。
note: 'src/index.mjs:219 的 normalizeMode(data?.permission_mode)',
},
{
plugin: 'opencode-mail-bridge',
allow: /permission_mode \|\| ""\) === "full"/,
mustWriteBack: null,
permanent: 'if (isPermanentFailure(e))',
note: 'index.js 的 permission.ask 钩子(status: ask | deny | allow)',
},
];
test('探测器自检:它认得出"有转发"的样子', () => {
const has = src => /permission\/request/.test(src);
assert.equal(has("await client.post('/permission/request', body)"), true);
assert.equal(has("await client.get('/mail/inbox')"), false);
});
test('★ 四桥都转发权限询问(我上次说"只有两个"是查错了)', () => {
for (const b of BRIDGES) {
assert.match(readImpl(b.plugin), /permission\/request/,
`${b.plugin} 应当转发权限询问。若确实不再转发,请回来改这条判据并把理由写清楚 —— ` +
`"我之前查过"不算理由(我就查错过一次)。`);
}
});
test('★ 四桥都必须认出「409 带 full → 放行」,且排在永久失败分支之前', () => {
for (const b of BRIDGES) {
const src = codeOnly(readImpl(b.plugin));
assert.match(src, b.allow,
`${b.plugin} 的 409 处理里找不到"服务端说 full 就放行"这一支:${b.note}。\n` +
'缺了它的后果:补投漏传档位时按 workspace 档问人 → 服务端按真实档位回 409 → ' +
'被当成"链上没有人类"拒绝,一条 full 档会话的工具调用全被自己人拦死。');
if (b.mustWriteBack) {
assert.match(src, b.mustWriteBack,
`${b.plugin} 有会话档位旋钮,所以 409 里的权威档位还必须**写回会话**:` +
'只放行修的是"问不问",沙箱(能不能)还是窄的。');
}
if (b.permanent === null) {
// 本桥没有 409 分支 ⇒ 顺序问题不适用。但仍要确认它**真的**没有,
// 而不是路径/正则过时了:出现任一个就说明前提变了,该改判据。
// 只扫它自己的 src/:isPermanentFailure 是 lib/relay-key.js 里的**通用**
// 网关辅助(所有桥都有),与「409 权限放行分支」无关。全量扫会误判 ——
// 踩过一次:把 lib 加进 IMPL 后,这格对着共享辅助函数报红。
assert.doesNotMatch(readImpl(b.plugin).split('lib/')[0], /isPermanentFailure/,
`${b.plugin} 声明「无 409 分支」但 src/ 里出现了 isPermanentFailure —— ` +
'前提变了,别让这格继续空转');
continue;
}
const allowAt = src.search(b.allow);
const permAt = src.indexOf(b.permanent);
assert.ok(permAt >= 0,
`${b.plugin} 找不到永久失败分支锚点 \`${b.permanent}\`(结构变了,判据要跟着改,别让它空转)`);
assert.ok(allowAt >= 0 && allowAt < permAt,
`${b.plugin} 里"full 放行"必须排在永久失败分支**之前**,否则永远不可达 —— ` +
'整套跑得绿、实际走不到,是"文件里没有能红的路径"那一族。');
}
});
test('★ 判据自检:把放行那一支挪到永久失败分支之后,顺序判据必须能红', () => {
const orderHolds = (src, b) => {
const a = src.search(b.allow);
const p = src.indexOf(b.permanent);
return a >= 0 && p >= 0 && a < p;
};
for (const b of BRIDGES) {
// ★ 2026-10-02:permanent === null 的桥(zcode)没有 409 分支,顺序无从谈起。
// 原来这里直接 indexOf(null) ⇒ 把 null 当成字符串找不到 ⇒ 前置恒红 ——
// 一条判据自己制造的红。跳过前仍要过一遍上面那格,确认「无分支」是真的。
if (b.permanent === null) continue;
const src = codeOnly(readImpl(b.plugin));
assert.ok(orderHolds(src, b), `前置:${b.plugin} 当前顺序正确,才谈得上"挪动"`);
const a = src.search(b.allow);
const p = src.indexOf(b.permanent);
const swapped = src.slice(0, a) + src.slice(p) + src.slice(a, p);
assert.equal(orderHolds(swapped, b), false,
`自检失败:${b.plugin} 把放行那一支挪到后面之后,顺序判据居然还是绿的 —— 那它抓不到这个误编辑`);
}
});
/**
* ★ 生产者 / 消费者配对:档位这个键名,四平台的消费者都在读,补投也真的产出它。
*
* 事故的形状是「生产者漏字段、消费者照读」:补投路径少带 `permission_mode`,
* 而 worker 那边 `msg.data?.permission_mode` 读到 `undefined` → 当 workspace 档去问人
* → 服务端按真实档位回 409 → 被当"无人可问"拒绝,一整轮工具能力没了。
*
* 为什么这条放在**平台专属**文件里、而不是 `test/catchup.test.mjs`:
* 后者是四平台**共用**的(`deploy/check-shared-libs.sh` 要求逐字节相同),
* 一旦写进"pi 的 worker 在 src/worker.mjs"这种路径,四份就不再相同 ——
* 我上一版正是这么干的,结果那个检查从 `153985e` 起一直红着,而我没跑过它。
* 共用文件放**契约**(值/行为),平台专属文件放**跨平台配对**(来源/结构)。
*/
test('★ 配对:消费者读的档位键名,生产者必须产出(四平台逐一登记读取处)', async () => {
// 消费者登记:每个平台从投递事件里读档位的那一行(找不到 → 红,不静默跳过)
const CONSUMERS = [
['pi', 'pi-mail-bridge/src/worker.mjs'],
['dsh', 'dsh-mail-bridge/src/index.ts'],
['opencode', 'opencode-mail-bridge/index.js'],
// ★ 2026-10-02 修路径(不是删判据):d09ef39 删掉 zcode 的本地 MCP 与整套
// 执行门禁(含 hooks/permission.mjs),zcode 改为在 src/index.mjs:219
// 读档位。能力没丢,只是搬了家;原路径不存在 ⇒ 4 个 subtest 全是
// ENOENT,白烧 4 个红且会掩盖真实失败。判据仍要求它登记。
['zcode', 'zcode-mail-bridge/src/index.mjs'],
];
for (const [who, rel] of CONSUMERS) {
const text = codeOnly(readFileSync(join(PLUGINS, rel), 'utf8'));
assert.match(text, /permission_mode/,
`${who}(${rel})没有引用 \`permission_mode\` —— 要么它改读了别的键名,` +
'要么它不再关心档位(后者要在本判据里写清为什么)');
}
// 生产侧是**行为**判据:真的调一次 mailToEvent,看它产不产出这个键
const { mailToEvent } = await import('../lib/catchup.js');
const ev = mailToEvent({
mail_id: 'm1', from_name: 'x', session_alias: 's', subject: 's', body: 'b',
permission_mode: 'full', permission_enforcement: 'strict',
});
assert.ok('permission_mode' in ev,
'补投事件必须产出 `permission_mode`:消费者读它,缺了就是"读到 undefined 当默认档"那场事故');
assert.equal(ev.permission_mode, 'full', '有档位时不许丢;键名要与 SSE new_mail 逐字一致(蛇形)');
});