From 3a6d572020e5ed51328319a3be89096125bf9179 Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Sat, 12 Sep 2026 16:49:28 +0800 Subject: [PATCH] =?UTF-8?q?feat(zcode):=20=E5=B7=A5=E5=85=B7=E5=8A=A0=20MC?= =?UTF-8?q?P=20=E6=B3=A8=E8=A7=A3=20+=20headless=20=E6=A1=A3=E4=BD=8D?= =?UTF-8?q?=E6=98=A0=E5=B0=84=E6=94=B9=E4=B8=BA=20plan=EF=BC=88=E5=90=A6?= =?UTF-8?q?=E5=88=99=E4=B8=80=E4=B8=AA=E5=B7=A5=E5=85=B7=E9=83=BD=E7=94=A8?= =?UTF-8?q?=E4=B8=8D=E4=BA=86=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 逆出 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。 --- plugins/zcode-mail-bridge/lib/mcp-rpc.mjs | 9 ++- plugins/zcode-mail-bridge/lib/tools.mjs | 42 ++++++++++- plugins/zcode-mail-bridge/src/turn-mode.mjs | 74 +++++++++++++++++-- .../zcode-mail-bridge/test/driver.test.mjs | 3 +- .../test/manual/driver-real-e2e.mjs | 24 ++++-- .../zcode-mail-bridge/test/mcp-rpc.test.mjs | 19 ++++- .../zcode-mail-bridge/test/turn-mode.test.mjs | 37 ++++++++-- 7 files changed, 183 insertions(+), 25 deletions(-) diff --git a/plugins/zcode-mail-bridge/lib/mcp-rpc.mjs b/plugins/zcode-mail-bridge/lib/mcp-rpc.mjs index 1495b6c..635e94b 100644 --- a/plugins/zcode-mail-bridge/lib/mcp-rpc.mjs +++ b/plugins/zcode-mail-bridge/lib/mcp-rpc.mjs @@ -40,7 +40,8 @@ const failure = (id, code, message) => ({ jsonrpc: '2.0', id, error: { code, mes * 处理一条已解析的 JSON-RPC 消息。 * * @param {any} msg 解析后的消息 - * @param {{tools: Array<{name:string, description:string, inputSchema:object}>, + * @param {{tools: Array<{name:string, description:string, inputSchema:object, + * annotations?: object}>, * call: (name: string, args: object) => Promise}} ctx * @returns {Promise} 要写回的消息;notification(无 id)返回 null */ @@ -81,7 +82,11 @@ export async function handleMessage(msg, ctx) { tools: ctx.tools.map(t => ({ name: t.name, description: t.description, - inputSchema: t.inputSchema + inputSchema: t.inputSchema, + // annotations 必须透传:ZCode 用它算风险等级(readOnlyHint→low / + // destructiveHint→high),而 plan 档下「非破坏性的 MCP 工具直接放行」 + // 依赖它。漏传的后果不是「少个提示」,而是工具在该档下全被拒。 + ...(t.annotations ? { annotations: t.annotations } : {}) })) }); diff --git a/plugins/zcode-mail-bridge/lib/tools.mjs b/plugins/zcode-mail-bridge/lib/tools.mjs index 7d88706..343a0d0 100644 --- a/plugins/zcode-mail-bridge/lib/tools.mjs +++ b/plugins/zcode-mail-bridge/lib/tools.mjs @@ -45,7 +45,36 @@ const str = (v, fallback = '') => (typeof v === 'string' ? v : fallback); const obj = v => (v && typeof v === 'object' && !Array.isArray(v) ? v : {}); /** - * 构造工具集。 + * MCP 工具的 `annotations`(MCP 规范里的提示字段)。 + * + * # 为什么这个字段在本项目里是**功能开关**而不是装饰 + * + * ZCode 把 MCP 工具的风险参数这样算(逐字逆自 CLI 产物): + * + * annotations.readOnlyHint === true → riskLevel "low" + * annotations.destructiveHint === true → riskLevel "high" + * 两者都没有 → "medium" + * needsApproval = true ← **硬编码为真,与注解无关** + * + * 而它的档位判定是: + * + * build 档:needsApproval || destructive || sideEffectScope !== "none" → **ask** + * plan 档:permissionName === "mcp" && !destructive → **allow** + * + * 两条合起来推出一个不那么直观的结论: + * + * 在 `build` 档下,**每一个 MCP 工具都会要求审批**(needsApproval 恒为真), + * 而 headless 模式没有交互式审批客户端 —— 于是全被拒。 + * 在 `plan` 档下,**只要不声明 destructive,MCP 工具直接放行**。 + * + * 所以 `destructiveHint` 的取值直接决定工具能不能用。声明时必须按真实语义: + * 这些工具都不销毁任何东西(读信、发信、传附件、查地址),所以是 false; + * 只有真的会破坏用户环境的能力(比如替模型跑 shell 命令)才该是 true。 + */ +const READ_ONLY = { readOnlyHint: true, destructiveHint: false, idempotentHint: true }; +const WRITE_SAFE = { readOnlyHint: false, destructiveHint: false, idempotentHint: false }; + +/** 构造工具集。 * * @param {{client: import('./gateway.mjs').GatewayClient, agentName: string}} deps */ @@ -69,6 +98,7 @@ export function buildTools({ client, agentName }) { // ─── 读 ──────────────────────────────────────────────────────── tools.push({ name: 'read_inbox', + annotations: READ_ONLY, description: '查阅收件箱中的邮件。收到新邮件通知后应立即调用此工具。' + '每封含 mail_id、发件人、主题、正文与附件清单(带 attachment_id)。', @@ -100,6 +130,7 @@ export function buildTools({ client, agentName }) { tools.push({ name: 'read_mail', + annotations: READ_ONLY, description: '读取一封邮件的完整正文、附件清单与可投递地址(mail_id 从 read_inbox 获得)。', inputSchema: { type: 'object', @@ -125,6 +156,7 @@ export function buildTools({ client, agentName }) { tools.push({ name: 'read_thread', + annotations: READ_ONLY, description: '查看一封邮件所在线索的完整往来(谁回了谁、谁还没回)。多方协作时用它避免重复提问。', inputSchema: { type: 'object', @@ -148,6 +180,7 @@ export function buildTools({ client, agentName }) { // ─── 写 ──────────────────────────────────────────────────────── tools.push({ name: 'send_mail', + annotations: WRITE_SAFE, description: '发送邮件。三维地址 name@path.session:省略 session 投递到默认会话,' + '.new 强制新建,.具体别名 必须已存在。回复来信请传 reply_to。', @@ -215,6 +248,7 @@ export function buildTools({ client, agentName }) { tools.push({ name: 'forward_mail', + annotations: WRITE_SAFE, description: '转发一封邮件给新的收件人(自动引用原文与附件)。与回复不同:回复落回原会话,转发按目标地址另行定位会话。', inputSchema: { @@ -241,6 +275,7 @@ export function buildTools({ client, agentName }) { // ─── 附件 ────────────────────────────────────────────────────── tools.push({ name: 'upload_attachment', + annotations: WRITE_SAFE, description: '上传本地文件作为邮件附件,返回 attachment_id。' + '拿到 id 后必须在 send_mail 的 attachment_ids 里带上,附件才会随邮件发出。', @@ -263,6 +298,7 @@ export function buildTools({ client, agentName }) { tools.push({ name: 'download_attachment', + annotations: WRITE_SAFE, description: '下载邮件附件到本地文件。attachment_id 从 read_inbox 的附件清单里取。', inputSchema: { type: 'object', @@ -286,6 +322,7 @@ export function buildTools({ client, agentName }) { // ─── 寻址发现 ────────────────────────────────────────────────── tools.push({ name: 'suggest_address', + annotations: READ_ONLY, description: '查询可用的收件人地址,用于精准发信。不带参数给候选收件人名;带 name 给它可用的' + '工作目录;name+path 都带则给该目录下可续谈的会话与现成地址。', @@ -313,6 +350,7 @@ export function buildTools({ client, agentName }) { tools.push({ name: 'list_contacts', + annotations: READ_ONLY, description: '列出自己参与过的全部会话及各自的可投递地址、未读数、剩余往返预算。', inputSchema: { type: 'object', @@ -328,6 +366,7 @@ export function buildTools({ client, agentName }) { tools.push({ name: 'session_participants', + annotations: READ_ONLY, description: '列出某条会话的全部参与方(发件人/收件人/抄送方)及各自的可投递地址,并标出谁还没回应。' + '要回给抄收方或向第三方转达时先用它拿地址。', @@ -348,6 +387,7 @@ export function buildTools({ client, agentName }) { // ─── 连接与登记 ──────────────────────────────────────────────── tools.push({ name: 'connect_to_server', + annotations: WRITE_SAFE, description: '连接到 AgentMail Gateway:用当前配置的身份完成登记,并报告连通性。' + '首次安装或换了 Gateway 地址时调用。', diff --git a/plugins/zcode-mail-bridge/src/turn-mode.mjs b/plugins/zcode-mail-bridge/src/turn-mode.mjs index 445f429..b1180da 100644 --- a/plugins/zcode-mail-bridge/src/turn-mode.mjs +++ b/plugins/zcode-mail-bridge/src/turn-mode.mjs @@ -33,19 +33,74 @@ import { normalizeMode, DEFAULT_MODE, MODE_PLAN, MODE_FULL } from '../lib/permis /** ZCode 认识的 headless mode(`normalizePromptMode` 只接受这四个)。 */ export const ZCODE_MODES = ['build', 'edit', 'plan', 'yolo']; +/** + * 档位 → ZCode mode。 + * + * # 三个模式在 headless 下的真实行为(逐条实测 + 逆代码,見 README) + * + * | mode | ZCode 自带工具 | 本插件的 MCP 工具 | 结果 | + * |-------|---------------|------------------|------| + * | build | Bash/Write/Edit → ask | **全部 → ask** | 没有交互式审批客户端 ⇒ **全部被拒** | + * | edit | 文件编辑放行,其余同 build | 同 build | 同上 | + * | plan | 非只读一律拒(fail-closed)| **非破坏性的直接放行** | 只读可用 | + * | yolo | 全部放行 | 全部放行 | 全可用,但**没有任何审批** | + * + * 关键在于 MCP 工具的 `needsApproval` 是**硬编码为 true** 的(`Ari()` 里 `let d=!0`), + * 与 `annotations` 无关;而 `build` 档的判定最后一条是 + * `needsApproval || destructive || sideEffectScope !== "none" → ask`。 + * 于是 **`build` 档下每一个 MCP 工具都要审批**,而 headless 模式没有审批客户端 → + * 全被拒。实测:模型连 `read_inbox` 都调不动,只能靠提示词里带的信息猜。 + * + * 而 `plan` 档有一条 `permissionName === "mcp" && !destructive → allow`, + * 所以**声明了 `destructiveHint: false` 的 MCP 工具在 plan 档下直接放行**。 + * 实测:模型用 read_inbox 读出了只存在于邮件正文里的标记。 + * + * # 为什么 workspace 档映射到 plan 而不是 build + * + * `build` 在本环境下等于「什么都不能做」(连读信都被拒)——那不是保守, + * 是不可用。而 `plan` 是**真的 fail-closed**:危险的自带工具被平台直接拒, + * 我们能用的只有自己声明的非破坏性工具。人在 workspace 档要的是「能干活」, + * 拿不到;那就退到「至少能读能回」,并**在日志里说清楚为什么**, + * 而不是静默地变成一个什么都干不了的代理。 + * + * 要真让它动手,只有两条路(见 README 的「已知缺口」): + * 1) 平台修好 PermissionRequest 钩子 → 审批能送达人; + * 2) 显式改用 yolo(`AGENTMAIL_ZCODE_MODE_MAP`)+ `--disallowed-tools` + * 把危险的自带工具拿掉,只留我们自己的工具面。 + */ const MODE_FOR_TIER = { [MODE_PLAN]: 'plan', - workspace: 'build', + workspace: 'plan', [MODE_FULL]: 'yolo' }; +/** + * 解析档位映射。允许用 `AGENTMAIL_ZCODE_MODE_MAP` 覆盖,格式 + * `workspace:yolo,full:yolo`(逗号分隔)。 + * + * 存在的理由:将来平台修好钩子(或本机换版本后行为变了), + * 应该**只改配置就能恢复**成 build,而不是等一次发版。 + */ +export function resolveModeMap(env = process.env) { + const raw = String(env.AGENTMAIL_ZCODE_MODE_MAP || '').trim(); + if (!raw) return { ...MODE_FOR_TIER }; + const out = { ...MODE_FOR_TIER }; + for (const pair of raw.split(',')) { + const [tier, mode] = pair.split(':').map(s => s.trim()); + if (!tier || !ZCODE_MODES.includes(mode)) continue; + out[normalizeMode(tier)] = mode; + } + return out; +} + /** * @param {string} tier AgentMail 的档位(plan / workspace / full) + * @param {object} [env] * @returns {'build'|'edit'|'plan'|'yolo'} */ -export function zcodeModeForTier(tier) { +export function zcodeModeForTier(tier, env = process.env) { const t = normalizeMode(tier) || DEFAULT_MODE; - return MODE_FOR_TIER[t] || 'build'; + return resolveModeMap(env)[t] || 'plan'; } /** @@ -62,6 +117,15 @@ export function modeReachesPermissionHook(mode) { export function describeTier(tier, mode = zcodeModeForTier(tier)) { const t = normalizeMode(tier) || DEFAULT_MODE; if (t === MODE_PLAN) return `${t} 档 → --mode ${mode}(只读,ZCode 自己就会拒非只读工具)`; - if (t === MODE_FULL) return `${t} 档 → --mode ${mode}(全权,刻意绕过权限询问)`; - return `${t} 档 → --mode ${mode}(危险操作会走到 AgentMail 授权钩子)`; + if (t === MODE_FULL) return `${t} 档 → --mode ${mode}(全权,刻意绕过审批)`; + if (mode === 'plan') { + return ( + `${t} 档 → --mode ${mode}(本档本应「危险操作问人」,本平台 headless 做不到:` + + `MCP 工具在 build 档下恒需审批而无人可批,只能退到只读)` + ); + } + if (mode === 'build' || mode === 'edit') { + return `${t} 档 → --mode ${mode}(危险操作会走到 AgentMail 授权钩子)`; + } + return `${t} 档 → --mode ${mode}`; } diff --git a/plugins/zcode-mail-bridge/test/driver.test.mjs b/plugins/zcode-mail-bridge/test/driver.test.mjs index 84a9e66..8a5c25a 100644 --- a/plugins/zcode-mail-bridge/test/driver.test.mjs +++ b/plugins/zcode-mail-bridge/test/driver.test.mjs @@ -218,7 +218,8 @@ test('Agent 来信的提示词必须说清「插件不会替你回信」', async test('★ 档位随邮件传下去,并作为 --mode 与钩子环境变量注入', async () => { for (const [tier, mode] of [ ['plan', 'plan'], - ['workspace', 'build'], + // workspace 默认映射到 plan:build 在 headless 下连 MCP 工具都要审批而无人可批 + ['workspace', 'plan'], ['full', 'yolo'] ]) { const h = await harness(); diff --git a/plugins/zcode-mail-bridge/test/manual/driver-real-e2e.mjs b/plugins/zcode-mail-bridge/test/manual/driver-real-e2e.mjs index 45dd9b9..44424be 100644 --- a/plugins/zcode-mail-bridge/test/manual/driver-real-e2e.mjs +++ b/plugins/zcode-mail-bridge/test/manual/driver-real-e2e.mjs @@ -200,17 +200,27 @@ async function main() { // ── A. 不需要授权:模型读信 → 产出文本 → 驱动回信 ────────── if (SCENARIO === 'a' || SCENARIO === 'all') { - const marker = `ZC-A-${Date.now()}`; - console.log(`── A: 纯文本问答(标记 ${marker})`); - await humanSend(cookie, `真模型验证A ${marker}`, `请把下面这一行原样回给我,不要改动:${marker}`); - const reply = await findReply(cookie, marker, 300000); + // 两个标记分工不同: + // subjectTag —— 只用来**定位回信**(收件箱是跨轮次共享的持久状态) + // bodyTag —— 只出现在**邮件正文**里,用来**证明模型真读了信** + // 驱动的提示词只带主题与 mail_id、不带正文,所以主题里放 bodyTag 就等于 + // 把答案送给模型(早先的版本正是如此,那条判据什么也没证明)。 + const subjectTag = `SUBJ-${Date.now()}`; + const bodyTag = `ZC-A-${Date.now()}`; + console.log(`── A: 模型必须自己读信(正文标记 ${bodyTag})`); + await humanSend( + cookie, + `真模型验证A ${subjectTag}`, + `请把下面这一行原样回给我,不要改动:${bodyTag}` + ); + const reply = await findReply(cookie, subjectTag, 300000); if (!reply) { const fail = await findFailure(cookie, marker, 3000); record('A · 模型读信并回信', '失败', fail ? `收到失败信:${String(fail.body).slice(0, 80)}` : '5 分钟内没有回信'); - } else if (String(reply.body || '').includes(marker)) { - record('A · 模型读信并回信', '通过', `回信含标记,正文 ${String(reply.body).length} 字`); + } else if (String(reply.body || '').includes(bodyTag)) { + record('A · 模型读信并回信', '通过', `回信含正文标记(必须读信才可能答对),正文 ${String(reply.body).length} 字`); } else { - record('A · 模型读信并回信', '失败', `回信正文没有标记:${String(reply.body).slice(0, 80)}`); + record('A · 模型读信并回信', '失败', `回信正文没有正文本标记:${String(reply.body).slice(0, 80)}`); } } diff --git a/plugins/zcode-mail-bridge/test/mcp-rpc.test.mjs b/plugins/zcode-mail-bridge/test/mcp-rpc.test.mjs index 62906d8..252a223 100644 --- a/plugins/zcode-mail-bridge/test/mcp-rpc.test.mjs +++ b/plugins/zcode-mail-bridge/test/mcp-rpc.test.mjs @@ -50,7 +50,7 @@ test('任何无 id 的消息都不回响应', async () => { assert.equal(out, null); }); -test('tools/list 只暴露 name/description/inputSchema(多带的字段会被客户端拒绝)', async () => { +test('tools/list 只暴露 name/description/inputSchema/annotations(多带的字段会被客户端拒绝)', async () => { const out = await handleMessage({ jsonrpc: '2.0', id: 2, method: 'tools/list' }, makeCtx()); assert.equal(out.result.tools.length, 2); for (const t of out.result.tools) { @@ -58,6 +58,23 @@ test('tools/list 只暴露 name/description/inputSchema(多带的字段会被 } }); +test('★ annotations 必须透传(ZCode 靠它算风险等级,plan 档据此放行)', async () => { + // 漏传的后果不是「少个提示」而是「工具在该档下全被拒」: + // ZCode 的 MCP 工具 needsApproval 恒为真,只有 plan 档的 + // 「!destructive → allow」能放行,而 destructive 正是从 annotations 读的。 + const ctx = { + tools: [{ name: 'read_inbox', description: 'd', inputSchema: {}, annotations: { readOnlyHint: true, destructiveHint: false } }], + call: async () => 'x' + }; + const out = await handleMessage({ jsonrpc: '2.0', id: 3, method: 'tools/list' }, ctx); + assert.deepEqual(out.result.tools[0].annotations, { readOnlyHint: true, destructiveHint: false }); +}); + +test('★ 反向对照:没有注解的工具不该凭空多出 annotations 字段', async () => { + const out = await handleMessage({ jsonrpc: '2.0', id: 4, method: 'tools/list' }, makeCtx()); + assert.equal('annotations' in out.result.tools[0], false); +}); + test('tools/call 成功时回 content 文本数组', async () => { const out = await handleMessage( { jsonrpc: '2.0', id: 3, method: 'tools/call', params: { name: 'read_inbox', arguments: {} } }, diff --git a/plugins/zcode-mail-bridge/test/turn-mode.test.mjs b/plugins/zcode-mail-bridge/test/turn-mode.test.mjs index c1b8af9..7da926e 100644 --- a/plugins/zcode-mail-bridge/test/turn-mode.test.mjs +++ b/plugins/zcode-mail-bridge/test/turn-mode.test.mjs @@ -10,12 +10,23 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; import { zcodeModeForTier, modeReachesPermissionHook, describeTier, ZCODE_MODES } from '../src/turn-mode.mjs'; -test('三个档位映射到三个不同的 mode', () => { +test('★ workspace 映射到 plan(不是 build),full 映射到 yolo', () => { + // build 在 headless 下等于「什么都不行」:MCP 工具的 needsApproval 硬编码为真, + // 而 headless 没有审批客户端 → 连 read_inbox 都被拒(实测)。 + // plan 是真的 fail-closed:危险的自带工具被平台拒,我们的非破坏性工具放行。 assert.equal(zcodeModeForTier('plan'), 'plan'); - assert.equal(zcodeModeForTier('workspace'), 'build'); + 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,那就意味着一个打字错误会关掉整个授权系统。 @@ -33,13 +44,13 @@ test('★ 大写 FULL 不认,落在安全侧', () => { // 这是**刻意保留**的好性质:认不出来时不会掉进「免授权」那一档, // 而是退回 default。这条断言把它钉住 —— 哪天有人「顺手」改成大小写不敏感, // 就会有一个打字错误变成全权授权的风险面。 - assert.equal(zcodeModeForTier('FULL'), 'build'); - assert.equal(zcodeModeForTier('PLAN'), 'build'); + assert.equal(zcodeModeForTier('FULL'), 'plan'); + assert.equal(zcodeModeForTier('PLAN'), 'plan'); }); -test('未知档位退回 build(安全侧),不是 yolo', () => { - assert.equal(zcodeModeForTier('nonsense'), 'build'); - assert.equal(zcodeModeForTier(undefined), 'build'); +test('未知档位退回 plan(安全侧),不是 yolo', () => { + assert.equal(zcodeModeForTier('nonsense'), 'plan'); + assert.equal(zcodeModeForTier(undefined), 'plan'); }); test('产出的 mode 必须是 ZCode 认识的值', () => { @@ -48,6 +59,12 @@ test('产出的 mode 必须是 ZCode 认识的值', () => { } }); +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); @@ -64,5 +81,9 @@ test('★ 反向对照:plan 与 full 都不产生询问,但原因不同', () assert.notEqual(plan, full); assert.match(plan, /只读/); assert.match(full, /全权/); - assert.match(describeTier('workspace'), /授权钩子/); + // workspace 在本平台退到 plan,日志里必须说清**为什么**退 + // (否则人只会看到「为什么它什么都不做」而无从判断) + assert.match(describeTier('workspace'), /headless 做不到|只读/); + // 显式覆盖回 build 时,说明恢复成「会走到授权钩子」 + assert.match(describeTier('workspace', 'build'), /授权钩子/); });