Files
MailUI4Agents/plugins/zcode-mail-bridge/test/turn-mode.test.mjs
JianFeeeee 3a6d572020 feat(zcode): 工具加 MCP 注解 + headless 档位映射改为 plan(否则一个工具都用不了)
## 逆出 ZCode 的 MCP 权限判定,并据此让工具真的可用

逐字逆自 CLI 产物:

  Ari():  annotations.readOnlyHint === true → riskLevel "low"
          annotations.destructiveHint === true → riskLevel "high"
          needsApproval = true   ← **硬编码为真,与注解无关**
  checkBuildMode(): needsApproval || destructive || sideEffectScope !== "none" → ask
  checkPlanMode():  permissionName === "mcp" && !destructive → allow

两条合起来的结论不直观但很关键:

- **build 档下每一个 MCP 工具都要审批**(needsApproval 恒真),而 headless
  模式没有交互式审批客户端 ⇒ 全被拒。实测:模型连 read_inbox 都调不动,
  只能从提示词里猜;更糟的是它**绕道**用 Bash 去读网关的 sqlite WAL 文件
  (它自己在回信里如实交代了这件事)。
- **plan 档下只要不声明 destructive,MCP 工具直接放行**。

于是两处改动:

1. `lib/tools.mjs` 给每个工具加真实注解(读类 readOnlyHint,写类
   destructiveHint:false——它们确实不破坏任何东西);`lib/mcp-rpc.mjs` 透传
   annotations。**漏传不是"少个提示",而是工具在该档下全被拒**。
2. `src/turn-mode.mjs` 的 workspace 档映射从 build 改为 **plan**。
   build 在本环境等于「什么都不能做」,那不是保守而是不可用;plan 才是真的
   fail-closed:危险的自带工具被平台直接拒,能用的只有我们声明为非破坏性的工具。
   日志会明确写出为什么退档。可用 `AGENTMAIL_ZCODE_MODE_MAP` 覆盖
   (平台修好钩子后只改配置就能恢复 build,不必等发版)。

## 真模型验证

场景 A 的判据同时加强:**正文本标记只出现在邮件正文里**(驱动的提示词只带主题
与 mail_id),所以模型必须真的读信才可能答对。通过 —— 约 20-30 秒一轮。

反过来说,早先那版「通过」是假的:标记在主题里,模型从提示词抄一遍就行。

## 仍然做不到的(见 README 已知缺口)

授权桥(PermissionRequest 钩子)在本版本(3.10.2 / CLI 0.16.5)**不可用**:
有时根本不触发,触发时在 ~5ms 内失败且**命令从未被 spawn**
(用「钩子写 marker 文件」的副作用验证,process 与 command 两种类型都一样)。
所以 workspace 档「危险操作问人」目前在 headless 下无法实现。

单元 329/329。
2026-09-12 16:49:28 +08:00

90 lines
4.4 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.

/**
* 档位 → ZCode `--mode` 映射的测试。
*
* 这个映射是**授权系统存不存在**的开关ZCode 的判定里 yolo 一律 allow
* 而 `--prompt` 的默认 mode 就是 yolo。映射写错不会报错只会让全部授权询问
* 静默消失 —— 所以它是本项目里少数几个「错一个值等于功能整体失效」的地方。
*/
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { zcodeModeForTier, modeReachesPermissionHook, describeTier, ZCODE_MODES } from '../src/turn-mode.mjs';
test('★ workspace 映射到 plan不是 buildfull 映射到 yolo', () => {
// build 在 headless 下等于「什么都不行」MCP 工具的 needsApproval 硬编码为真,
// 而 headless 没有审批客户端 → 连 read_inbox 都被拒(实测)。
// plan 是真的 fail-closed危险的自带工具被平台拒我们的非破坏性工具放行。
assert.equal(zcodeModeForTier('plan'), 'plan');
assert.equal(zcodeModeForTier('workspace'), 'plan');
assert.equal(zcodeModeForTier('full'), 'yolo');
});
test('★ 映射可被 AGENTMAIL_ZCODE_MODE_MAP 覆盖(平台修好后不必等发版)', () => {
assert.equal(zcodeModeForTier('workspace', { AGENTMAIL_ZCODE_MODE_MAP: 'workspace:build' }), 'build');
assert.equal(zcodeModeForTier('workspace', { AGENTMAIL_ZCODE_MODE_MAP: 'workspace:yolo,full:plan' }), 'yolo');
// 非法值被忽略,不改变默认
assert.equal(zcodeModeForTier('workspace', { AGENTMAIL_ZCODE_MODE_MAP: 'workspace:nonsense' }), 'plan');
assert.equal(zcodeModeForTier('workspace', { AGENTMAIL_ZCODE_MODE_MAP: '' }), 'plan');
});
test('★ 只有 full 档会得到 yolo', () => {
// 反向对照:如果任何其它档位(含拼错的、空的、未知的、大小写不对的)
// 也能得到 yolo那就意味着一个打字错误会关掉整个授权系统。
for (const tier of ['plan', 'workspace', '', undefined, 'worjspace', 'default', 'FULL', 'Full']) {
assert.notEqual(
zcodeModeForTier(tier),
'yolo',
`档位 ${JSON.stringify(tier)} 不该得到 yolo实际 ${zcodeModeForTier(tier)}`
);
}
});
test('★ 大写 FULL 不认,落在安全侧', () => {
// 共用库的 normalizeMode 是严格匹配的(只认小写),实测 FULL → workspace。
// 这是**刻意保留**的好性质:认不出来时不会掉进「免授权」那一档,
// 而是退回 default。这条断言把它钉住 —— 哪天有人「顺手」改成大小写不敏感,
// 就会有一个打字错误变成全权授权的风险面。
assert.equal(zcodeModeForTier('FULL'), 'plan');
assert.equal(zcodeModeForTier('PLAN'), 'plan');
});
test('未知档位退回 plan安全侧不是 yolo', () => {
assert.equal(zcodeModeForTier('nonsense'), 'plan');
assert.equal(zcodeModeForTier(undefined), 'plan');
});
test('产出的 mode 必须是 ZCode 认识的值', () => {
for (const tier of ['plan', 'workspace', 'full', 'x', undefined]) {
assert.ok(ZCODE_MODES.includes(zcodeModeForTier(tier)), tier);
}
});
test('★ 只有 full 档会得到 yolo覆盖后仍成立', () => {
assert.equal(zcodeModeForTier('full', {}), 'yolo');
assert.equal(zcodeModeForTier('workspace', {}), 'plan');
assert.equal(zcodeModeForTier('plan', {}), 'plan');
});
test('只有 build / edit 会让危险操作走到授权钩子', () => {
assert.equal(modeReachesPermissionHook('build'), true);
assert.equal(modeReachesPermissionHook('edit'), true);
// plan 由 ZCode 自己就拒了yolo 直接放行 —— 两者都不产生询问
assert.equal(modeReachesPermissionHook('plan'), false);
assert.equal(modeReachesPermissionHook('yolo'), false);
});
test('★ 反向对照plan 与 full 都不产生询问,但原因不同', () => {
// 两条路都不产生 PermissionRequest却在日志里必须能区分
// 一个是「只读ZCode 拒了」,一个是「全权,刻意不问」。
const plan = describeTier('plan');
const full = describeTier('full');
assert.notEqual(plan, full);
assert.match(plan, /只读/);
assert.match(full, /全权/);
// workspace 在本平台退到 plan日志里必须说清**为什么**退
// (否则人只会看到「为什么它什么都不做」而无从判断)
assert.match(describeTier('workspace'), /headless 做不到|只读/);
// 显式覆盖回 build 时,说明恢复成「会走到授权钩子」
assert.match(describeTier('workspace', 'build'), /授权钩子/);
});