Files
MailUI4Agents/plugins/pi-mail-bridge/test/cross-bridge-permission-routing.test.mjs
JianFeeeee d5cfcbdc9c fix(权限): 409 的第二种含义是「本档不该问」——四桥都补上;状态写入点不再兜默认档
线上事故(jianf 经 pi 转达):补投路径漏传 permission_mode,插件拿 undefined 兜了
workspace 档,把 full 档会话写成 workspace-write + ask —— 不是"拦一次",是一整轮
工具能力降级,且状态留在会话里;随后该会话每次受守卫调用都撞 409。

四件事:

1. **状态写入点不接受默认值**(新增共享 `modeForStateWrite`):缺字段/脏值 → `null`
   = 不写状态。"默认值可以出现在**决策**里,不可以出现在**状态写入**里。"
   同时保留共享契约的 fail-closed:真读到 workspace 才写 workspace。

2. **409 的两种含义分开处理**。`allowed-once` 只绕过**审批**,改不了**沙箱** ——
   所以 dsh 桥在放行前先把服务端给的权威档位**写回会话**(这也就成了自愈路径:
   已经降级的会话,下一次带档位的 409 会把它修回来);只认服务端明说的 full,
   plan 与"链上没有人类"照旧 fail closed。

3. **同一处缺陷在 zcode / opencode 也在**(`hooks/permission.mjs` 与 `index.js`
   都把 409 当永久失败拒绝)。我先前在回信里写过"这两个桥不转发权限询问,不需要改"
   —— 那句话是错的,我当时的搜索面只有 `<plugin>/src/*.mjs`。按 pi 的要求把这条
   **否定性事实变成常驻判据**后,它第一次运行就红给我看。四桥现在都有
   「409 + full → 放行」,且**排在永久失败分支之前**(含顺序变异自检)。

4. **共用测试重新同源**:`test/catchup.test.mjs` 从 `153985e` 起就是分叉的
   (我那版把平台专属路径写进了共用文件),而 `deploy/install.sh` 第 24 行会跑
   `check-shared-libs.sh` —— 也就是说**部署一直是红的**,我没跑过那个脚本。
   共用文件只放契约(值/行为),跨平台配对judge 移到平台专属文件,四份逐字节相同。

另外把"判代码 vs 判理由"从记忆变成代码:`test/lib/read.mjs` 提供 `code()/prose()/bytes()`,
判据目录里不得再裸用 `readFileSync`(新判据 `criteria-hygiene` 管,含读取器自检)。

判据证据(每条都做过"能不能红"的变异):
- 写回去掉 → 红;纠正块挪到普通 409 之后 → 红;状态写入点退回兜默认 → 红;
- zcode/opencode 的放行分支拿掉 → 各自红;共用测试分叉 → check-shared-libs 红。

各套件:dsh 388、pi 443、zcode 387、opencode 333(均经 npm test,含 tsc);
electron `npm test` 15/15 判据绿 + vitest 266 + typecheck;`check-shared-libs.sh` 退出 0;
Go `go test ./...` 全 ok。
2026-09-14 16:21:27 +08:00

195 lines
9.2 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'],
'zcode-mail-bridge': ['src', 'mcp', 'hooks'],
'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: /permission_mode \|\| ''\) === 'full'/,
mustWriteBack: null,
permanent: 'if (isPermanentFailure(e))',
note: 'hooks/permission.mjs 的 409 分支(该桥没有 sandbox/approval 旋钮)',
},
{
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 里的权威档位还必须**写回会话**:` +
'只放行修的是"问不问",沙箱(能不能)还是窄的。');
}
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) {
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'],
['zcode', 'zcode-mail-bridge/hooks/permission.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 逐字一致(蛇形)');
});