Files
MailUI4Agents/plugins/dsh-mail-bridge/test/permission-409-full.test.mjs
JianFeeeee 7028c244fd dsh 桥同一处缺陷:409 带 full 档时也当场拒绝(并且把会话降级了)
pi 报的是它自己的桥,但**同一处缺陷 dsh 桥也有**(`src/index.ts` 的 409 分支无条件
`return 'rejected'`),而且后果多一层 —— dsh 的档位是通过 `applyPermissionMode()`
写进会话的(沙箱 + 审批策略)。补投漏传档位时 `applyPermissionMode(session, '')`
把会话**降级**成 workspace:`danger-full-access → workspace-write`、
`never → ask`,于是每个受守卫的工具调用都去问一次,再被 409 拒绝 ——
一条 full 档会话只要有一封补投邮件,这一轮的工具调用全被自己人拦死,
**顺带把自己的权限也降了**。

修法同 pi 桥:409 分支先认回包里的 `permission_mode`,是 full 就
`return 'allowed-once'`(DSH 的 ApprovalOutcome 只认 allowed-once/rejected/cancelled/unavailable);
plan 档与"链上没有人类"照旧 `return 'rejected'`。放行分支排在普通 409 之前,否则不可达。

判据 `test/permission-409-full.test.mjs`(含自检:拿掉放行分支必须红)。
自检那步发现我第一版判据又踩了同一个坑:「普通 409 分支里不许出现 allowed-once」
读的是**含注释**的正文,而那段的注释正好写着 "ApprovalOutcome 只认
allowed-once / rejected / …" → 误报。改成读剥注释的源码(规范里那条:
判"代码里有什么"读剥离版,判"理由写清了没"读原文)。

zcode / opencode 不转发权限询问(没有 409 分支),无需改。

验证:dsh 套件 383 通过(+2);变异(拿掉放行分支)→ 自检红。
2026-09-14 15:51:38 +08:00

74 lines
4.1 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 里说「本会话是 full 档」时dsh 桥必须**放行**而不是拒绝。
*
* # 为什么需要这条(与 pi 桥同一处缺陷,同日一起修)
*
* 409 原本只有一个含义:「这条任务链上没有人类」→ 当场拒绝(`return 'rejected'`)。
* 但 `handler/permission.go` 在**档位不该问人**时也回 409并在回包里带
* `permission_mode`。两种 409 的正确反应相反:无人可问 → 拒绝full 档 → **放行**。
*
* dsh 桥还有一层放大:档位是从邮件事件 `applyPermissionMode()` 应用的,
* 补投路径漏传档位时(`lib/catchup.js`,同日修)会话被**降级**成 workspace ——
* 沙箱 danger-full-access → workspace-write、审批 never → ask
* 于是每个受守卫的工具调用都去问一次,再被 409 拒绝。一条 full 档会话只要有一封
* 补投邮件,这一轮的工具调用就全被自己人拦死。
*
* # 为什么读源码而不是跑起来
*
* 与 `permission-forward-wiring.test.mjs` 同一取舍:入口不是可导入的模块
* (插件工厂 + Cordis 依赖)。这里要钉的只有一件事:那个放行分支还在、且认服务端给的档位。
*
* # 判据自检
*
* 拿掉放行分支的源码喂给它,必须红 —— 否则这条断言永远为真,比没有更糟。
*/
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');
/** 取 409 相关分支的正文(按花括号配对,不看窗口) */
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 '';
}
test('★ 409 + full 档 → 放行plan / 无人可问 → 仍拒绝', () => {
const full = blockAt(SRC, "if (e?.status === 409 && String(e?.body?.permission_mode || '') === 'full')");
assert.ok(full.length > 40, '要能找到「409 + full」分支找不到说明结构变了判据要跟着改');
assert.match(full, /return 'allowed-once'/, 'full 档要放行DSH 的 ApprovalOutcome 只认 allowed-once/rejected/…)');
const plain = blockAt(SRC, 'if (e?.status === 409) {');
assert.ok(plain.length > 100, '要能找到普通 409 分支');
assert.match(plain, /return 'rejected'/, '非 full 的 409 仍要当场拒绝');
/*
* ⚠️ 「代码里有没有放行」必须读**剥掉注释**的正文:普通 409 分支的注释里正好写着
* "ApprovalOutcome 只认 allowed-once / rejected / …"(解释为什么写 'rejected'
* 而不是 'denied')—— 读原文会把那句话当成"这里会放行"而误报。
* 这是本仓规范里那条:判"代码里有什么"读剥离版,判"理由写清了没"读原文。
*/
const plainCode = plain.replace(/\/\*[\s\S]*?\*\//g, '').replace(/\/\/[^\n]*/g, '');
assert.ok(!/allowed-once/.test(plainCode),
`普通 409 分支不许放行(那就等于"没人可问也放行"${(plainCode.match(/allowed-once/) || [''])[0]}`);
// 顺序full 分支必须在普通 409 之前,否则永远不可达
assert.ok(SRC.indexOf("permission_mode || '') === 'full'") < SRC.indexOf('if (e?.status === 409) {'),
'放行分支要排在普通 409 之前(否则被前面的分支吃掉,永远不可达)');
});
test('★ 判据自检:拿掉放行分支,上面那条必须红', () => {
const crippled = SRC.replace(/if \(e\?\.status === 409 && String\(e\?\.body\?\.permission_mode \|\| ''\) === 'full'\)[\s\S]*?\n \}\n/, '');
assert.equal(blockAt(crippled, "if (e?.status === 409 && String(e?.body?.permission_mode || '') === 'full')").length, 0,
'自检:判据要能检出"放行分支被拿掉"这件事');
});