Files
MailUI4Agents/plugins/dsh-mail-bridge/test/permission-409-full.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

148 lines
8.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.

/**
* 接线判据dsh 桥怎么处理「409 带档位」。
*
* # 判据分三块,缺一块就是假判据
*
* 1. **动作**409 回包里有权威档位时,先**写回会话状态**`applyPermissionMode`
* 再决定放行与否 —— 因为 `allowed-once` 只绕过**审批**,改不了**沙箱**
* 档位没恢复的话,"工作区外写入"这类操作会在沙箱层被拒,且每次受守卫的调用
* 都要重新走一遍 409放行是 per-call 的)。写回才是"修状态",也顺带是**自愈**路径。
* 2. **顺序(可达性)**:这块必须在「普通 409无人可问 → 拒绝)」**之前**
* 否则永远不可达。见下面 `orderHolds` 与它的**顺序自检**。
* 3. **状态写入点不接受默认值**`applyPermissionMode` 必须走 `modeForStateWrite`
* (缺字段 → 不写),且缺字段的分支要排在第一个 `session.append` 之前。
*
* # 为什么读源码而不是跑起来
*
* 与 `permission-forward-wiring.test.mjs` 同一取舍:入口是插件工厂 + Cordis 依赖,
* 不可导入。这里钉的是"接线还在不在、顺序对不对"**值**那一半由
* `permission-mode.test.mjs` 对 `modeForStateWrite` 的行为判据覆盖 ——
* 两条一起才闭合(值 → 行为,结构/来源 → 静态)。
*
* # 判据自检(两条,都跟"能不能红"有关)
*
* - 拿掉写回/放行 → 必须红(证明它**有用**
* - **把它挪到普通 409 之后 → 必须红**(证明它**可达**pi 2026-09-14 §2 指出:
* 只做前一条自检,证明的是"被用到",不是"顺序对")。
*/
import assert from 'node:assert/strict';
import { readFileSync } 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 SRC = readFileSync(join(HERE, '..', 'src', 'index.ts'), 'utf8');
/** 剥掉注释后再断言"代码里有什么"(本仓规范:判代码读剥离版,判理由读原文) */
const codeOnly = src =>
src.replace(/\/\*[\s\S]*?\*\//g, '').replace(/(^|[^:])\/\/[^\n]*/g, '$1');
/** 取某个分支/函数的正文(按花括号配对,不看窗口) */
function blockAt(src, needle) {
const at = src.indexOf(needle);
if (at < 0) return '';
const open = src.indexOf('{', at);
let depth = 0;
for (let i = open; i < src.length; i++) {
if (src[i] === '{') depth++;
else if (src[i] === '}') { depth--; if (depth === 0) return src.slice(open + 1, i); }
}
return '';
}
const CORRECTED = 'if (e?.status === 409 && corrected) {';
const PLAIN = 'if (e?.status === 409) {';
/** 「纠正 + 决定」这一整段的起点:声明在 if 之前,所以判据要连它一起读 */
const CORRECTION_AT = 'const corrected = modeForStateWrite(';
/** 顺序判据本身(抽出来是为了能对"变异后的源码"复用它 —— 见顺序自检) */
function orderHolds(src) {
const a = src.indexOf(CORRECTION_AT);
const b = src.indexOf(PLAIN);
return a >= 0 && b >= 0 && a < b;
}
test('★ 409 带档位:先写回会话状态,再按档位决定放行', () => {
const at = SRC.indexOf(CORRECTION_AT);
const end = SRC.indexOf(PLAIN);
assert.ok(at >= 0 && end > at, '要能找到「读权威档位 → 决定」这一段(找不到说明结构变了,判据要跟着改)');
const block = codeOnly(SRC.slice(at, end));
assert.ok(block.length > 100, '这一段太短,八成是结构变了');
assert.match(block, /modeForStateWrite\(e\?\.body\?\.permission_mode\)/,
'权威档位要用状态写入点的解析器读(缺字段 → 不写,不兜默认)');
assert.match(block, /applyPermissionMode\(/,
'必须把服务端的纠正**写回会话** —— 只放行不写回,沙箱仍然是窄的(放行只修"问不问"');
assert.match(block, /corrected === MODE_FULL/,
'只有服务端明说 full 才放行plan 与"无人可问"照旧拒绝,猜宽了就是提权)');
assert.match(block, /return 'allowed-once'/);
assert.ok(block.indexOf('applyPermissionMode(') < block.indexOf("return 'allowed-once'"),
'写回会话要发生在返回之前(否则这一次放行了,状态还是错的)');
});
test('★ 顺序(可达性):带档位那块必须在普通 409 之前', () => {
assert.ok(orderHolds(SRC),
'「409 + 档位」必须排在普通 409 之前 —— 否则 full 档会先落进后面那块、被当成"无人可问"拒绝,\n' +
'而这块代码永远不可达("文件里没有能红的路径"那一族)。');
const plain = codeOnly(blockAt(SRC, PLAIN));
assert.ok(plain.length > 100, '要能找到普通 409 分支');
assert.match(plain, /return 'rejected'/, '非 full 的 409 仍要当场拒绝');
assert.ok(!/allowed-once/.test(plain),
'普通 409 分支里不许出现放行(那就等于"没人可问也放行"');
});
test('★ 顺序自检:把它挪到普通 409 之后,上面那条顺序判据必须能红', () => {
/*
* pi 2026-09-14 §2我旧的自检只做了"拿掉分支 → 红",那只证明它**被用到**
* 不证明**顺序对** —— 若有人把它挪到后面,整套判据照样绿(分支还在,只是走不到)。
* 所以这里对"挪动后的源码"复用同一条 orderHolds要求它为假。
*
* 挪法模拟真实的误编辑:把这块整段剪下来,插到普通 409 那块之后。
*/
const start = SRC.indexOf(CORRECTION_AT);
const plainAt = SRC.indexOf(PLAIN);
assert.ok(start >= 0 && plainAt > start, '前置:当前顺序正确,才谈得上"挪动"');
const moved = SRC.slice(start, plainAt);
const swapped = SRC.slice(0, start) + SRC.slice(plainAt) + moved;
assert.equal(orderHolds(swapped), false,
'自检失败把「409 + 档位」挪到普通 409 之后,顺序判据居然还是绿的 —— 那它抓不到这个误编辑');
/*
* 另一条自检:**整段拿掉**。注意不能用"改成 `if (false)`"来验 —— 那只是"够不着"
* 顺序关系还在,该由上面那条**动作**判据去抓(分工不同,别混)。
*/
const removed = SRC.slice(0, start) + SRC.slice(plainAt);
assert.equal(orderHolds(removed), false, '自检失败:整段删掉后顺序判据应当红(找不到这一段)');
});
test('★ 状态写入点applyPermissionMode 不兜默认档,且缺字段分支在任何 append 之前', () => {
const fn = codeOnly(blockAt(SRC, 'function applyPermissionMode('));
assert.ok(fn.length > 100, '要能找到 applyPermissionMode');
assert.match(fn, /modeForStateWrite\(mode\)/,
'状态写入点必须走 modeForStateWrite缺字段 → null → 不写)');
assert.ok(!/\(\s*mode\s*\|\|\s*'workspace'\s*\)/.test(fn),
'不许再把缺字段兜成 workspace —— 那正是 2026-09-14 事故的形状');
const guard = fn.indexOf('m === null');
const firstAppend = fn.indexOf('session.append(');
assert.ok(guard >= 0 && firstAppend > guard,
'缺字段的提前返回要排在第一个 session.append 之前(否则状态已经被改了)');
});
test('★ lib 里用到的名字必须真的 import 进来(我曾经漏过一次,且被管道骗过)', () => {
/*
* 2026-09-14 我在这里栽过409 分支里直接用 `MODE_FULL` 却没 import
* 而 `npx tsc --noEmit | tail -3` 的退出码是 `tail` 的 —— 于是"类型检查通过"是假的,
* `ReferenceError` 正好会抛在它要修的那条路径上。
* 这条判据把"名字被绑定"这件事钉住(值/行为判据覆盖不到静态的绑定)。
*/
const importLine = /import\s*\{([^}]+)\}\s*from\s*'\.\.\/lib\/permission-mode\.js'/.exec(SRC);
assert.ok(importLine, "要能从 '../lib/permission-mode.js' import找不到说明 import 被改了)");
const bound = new Set(importLine[1].split(',').map(s => s.trim()).filter(Boolean));
for (const name of ['MODE_FULL', 'modeForStateWrite']) {
assert.ok(new RegExp(`\\b${name}\\b`).test(codeOnly(SRC)),
`${name} 要用在源码里(否则这条判据该删掉,别留空转)`);
assert.ok(bound.has(name),
`${name} 在源码里被用到,却没从 '../lib/permission-mode.js' import 进来 —— ` +
`运行时是 ReferenceError而且它抛在**它要修的那条路径**上。`);
}
});