From c774904c0c8c9b358d39c663272d17c8db925f62 Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Sat, 12 Sep 2026 14:09:10 +0800 Subject: [PATCH] =?UTF-8?q?feat(zcode):=20=E6=8E=88=E6=9D=83=E6=A1=A5=20?= =?UTF-8?q?=E2=80=94=E2=80=94=20PermissionRequest=20=E9=92=A9=E5=AD=90?= =?UTF-8?q?=E6=8A=8A=E5=8D=B1=E9=99=A9=E5=B7=A5=E5=85=B7=E6=8E=88=E6=9D=83?= =?UTF-8?q?=E4=BA=A4=E7=BB=99=E4=BA=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 第二步:让 ZCode 上的 Bash/Write/Edit 授权走 AgentMail 的人工审批, 而不是只靠本地界面。 钩子契约从 CLI 产物里逆出来(不猜协议): - 输入走 stdin:{hook_event_name, tool_name, tool_input, session_id, permission_mode…} - 输出走 stdout,schema **严格**:{"decision":"approve"} / {"decision":"block","reason"} 多一个键就会报 "Hook stdout failed HookJSONOutput schema validation" - 空输出 / 不以 { 开头 = 不表态;exit 2 = 拒绝;其它非零 = 钩子失败 - 注入的环境变量含 ZCODE_PLUGIN_ROOT / ZCODE_PLUGIN_DATA / ZCODE_SESSION_ID (MCP 配置里用 ZCODE_SESSION_ID 反而会抛「需要运行时会话上下文」) 档位判定与 pi 桥逐条对齐(plan 直接拒 / workspace 问人 / full 批准), 判定逻辑抽成纯函数 lib/hook-policy.mjs 以便穷举: 其中 full 档必须**返回批准而不是不表态** —— 钩子一旦触发说明 ZCode 本会去问人, 不表态等于让那个询问照常发生,full 档就退化成了 workspace 档。 钩子自己开 SSE 等决定,不依赖桥进程:网关的 SSE 是扇出的 (clients 按唯一 id 存,SendToAgent 推给该 Agent 的所有客户端), 一次性进程也能订阅到自己那条 permission_decision。这样交互模式下同样可用 (人自己开着 ZCode 干活时并没有桥在跑)。先建连再发请求是有意的: 反过来会有一个窗口,人在窗口内点的同意推送给当时还不存在的客户端。 fail closed 但区分模式:永久失败(409/4xx)一律拒绝;暂时失败在 AGENTMAIL_SESSION_ID 非空(邮件驱动、没有本地界面兜底)时拒绝, 交互模式则不表态让人就地决定。 「一直同意」落盘(lib/grants-file.mjs):钩子是一个事件一个进程, 不落盘那个选项就是骗人的。判定仍交给共用的 permission-grants.js。 共用模块同源范围扩到 9 个(新增 permission-mode / relay-key / permission-grants / sse-client)—— 档位语义与决策判定分叉会让「同意」 在 ZCode 上悄悄变成另一种意思。 验证: - 单元 229/229(新增 hook-policy 14 项、grants-file 8 项,含反向对照) - 共用模块四方同源检查通过 - 授权桥端到端 5/5,全部带反向对照: 同意→approve;拒绝→block 且原因必须来自人的拒绝(不能是超时兜底); plan 档拒绝且**不产生**任何权限邮件;无人可问(409)→fail closed; 非守卫工具→不表态 - `zcode plugins list` → agentmail@inline [enabled],hooks: 1, mcp: plugin:agentmail:agentmail 我自己写错的两处判据(都已修,值得记下): 1. 待决权限列表里有历史积压(实测 6 条,含其它 Agent 的条目), 只按「第一条新的」取会拿到无关请求 —— 于是人点了同意而钩子在等自己那条, 最后超时。第一版还把这个超时误报成「拒绝路径通过」。 现在按「启动前快照差集 + session_id + agent_name」三重过滤。 2. 「无人可问」控制组最初传了个非 UUID 的 session id,走的是 400(参数错), 验不到 409 那条真实路径。改为真的造一条只有 Agent 没有人类的会话。 --- .../.zcode-plugin/plugin.json | 1 + plugins/zcode-mail-bridge/README.md | 156 +++++++ plugins/zcode-mail-bridge/hooks/hooks.json | 18 + .../zcode-mail-bridge/hooks/permission.mjs | 270 +++++++++++++ plugins/zcode-mail-bridge/lib/grants-file.mjs | 102 +++++ plugins/zcode-mail-bridge/lib/hook-policy.mjs | 92 +++++ .../lib/permission-grants.js | 105 +++++ .../zcode-mail-bridge/lib/permission-mode.js | 239 +++++++++++ plugins/zcode-mail-bridge/lib/relay-key.js | 127 ++++++ plugins/zcode-mail-bridge/lib/sse-client.js | 179 +++++++++ .../test/grants-file.test.mjs | 98 +++++ .../test/hook-policy.test.mjs | 98 +++++ .../test/manual/permission-e2e.mjs | 379 ++++++++++++++++++ .../test/permission-grants.test.mjs | 156 +++++++ .../test/permission-mode.test.mjs | 215 ++++++++++ .../zcode-mail-bridge/test/relay-key.test.mjs | 194 +++++++++ .../test/sse-client.test.mjs | 129 ++++++ 17 files changed, 2558 insertions(+) create mode 100644 plugins/zcode-mail-bridge/README.md create mode 100644 plugins/zcode-mail-bridge/hooks/hooks.json create mode 100644 plugins/zcode-mail-bridge/hooks/permission.mjs create mode 100644 plugins/zcode-mail-bridge/lib/grants-file.mjs create mode 100644 plugins/zcode-mail-bridge/lib/hook-policy.mjs create mode 100644 plugins/zcode-mail-bridge/lib/permission-grants.js create mode 100644 plugins/zcode-mail-bridge/lib/permission-mode.js create mode 100644 plugins/zcode-mail-bridge/lib/relay-key.js create mode 100644 plugins/zcode-mail-bridge/lib/sse-client.js create mode 100644 plugins/zcode-mail-bridge/test/grants-file.test.mjs create mode 100644 plugins/zcode-mail-bridge/test/hook-policy.test.mjs create mode 100644 plugins/zcode-mail-bridge/test/manual/permission-e2e.mjs create mode 100644 plugins/zcode-mail-bridge/test/permission-grants.test.mjs create mode 100644 plugins/zcode-mail-bridge/test/permission-mode.test.mjs create mode 100644 plugins/zcode-mail-bridge/test/relay-key.test.mjs create mode 100644 plugins/zcode-mail-bridge/test/sse-client.test.mjs diff --git a/plugins/zcode-mail-bridge/.zcode-plugin/plugin.json b/plugins/zcode-mail-bridge/.zcode-plugin/plugin.json index 4309082..db5b821 100644 --- a/plugins/zcode-mail-bridge/.zcode-plugin/plugin.json +++ b/plugins/zcode-mail-bridge/.zcode-plugin/plugin.json @@ -6,6 +6,7 @@ "name": "AgentMail" }, "license": "AGPL-3.0-only", + "hooks": "hooks", "mcpServers": { "agentmail": { "command": "node", diff --git a/plugins/zcode-mail-bridge/README.md b/plugins/zcode-mail-bridge/README.md new file mode 100644 index 0000000..020c4de --- /dev/null +++ b/plugins/zcode-mail-bridge/README.md @@ -0,0 +1,156 @@ +# zcode-mail-bridge —— AgentMail 的 ZCode 适配 + +让 [ZCode](https://zcode.z.ai)(z.ai 的 Electron 客户端)成为 AgentMail 里 +一个能收发邮件、传附件、**把危险工具授权交给人**的 Agent。 + +ZCode 用**插件**扩展能力(`.zcode-plugin/plugin.json` 声明 +`skills` / `commands` / `hooks` / `mcpServers`),所以适配它的正确形状是一个插件, +而不是又一个常驻桥进程。本目录就是那个插件。 + +## 组成 + +``` +.zcode-plugin/plugin.json 插件清单(MCP 服务器 + hooks 目录) +mcp/server.mjs MCP 服务器入口(stdio,换行分隔 JSON-RPC) +hooks/permission.mjs PermissionRequest 钩子:把授权问给人、等决定、回结论 +hooks/hooks.json 钩子注册(matcher + 进程型钩子 + 超时) +lib/mcp-rpc.mjs 协议层(纯函数,可穷举测试) +lib/tools.mjs 11 个 AgentMail 工具(与另三个桥同名同参) +lib/gateway.mjs 网关 HTTP 客户端 +lib/hook-policy.mjs 档位判定(纯函数) +lib/grants-file.mjs 「一直同意」的跨进程持久化 +lib/{addressing,inbox-format,bounded,discovery,attachment-ids, + permission-mode,relay-key,permission-grants,sse-client}.js + ← 与 pi/dsh/opencode 三桥**逐字节同源**(见下) +test/ 单元测试(含继承的共用测试) +test/manual/permission-e2e.mjs 授权桥的端到端验证(真去点同意/拒绝) +``` + +## 两条能力线 + +### 1)MCP 工具面 + +模型通过 `mcp__agentmail__<工具名>` 调用: + +`read_inbox` `read_mail` `read_thread` `send_mail` `forward_mail` +`upload_attachment` `download_attachment` `suggest_address` `list_contacts` +`session_participants` `connect_to_server` + +工具名、参数名与渲染文本都与 pi / dsh / opencode 三桥一致,渲染直接复用共用的 +`inbox-format` / `discovery` —— 同一封邮件在任何平台上看起来都该是同一个样子。 +`test/tools.test.mjs` 里有一条断言直接拿 pi 桥的工具名做对照:少一个就会让某个平台 +的行为与其它平台不同,而那种问题只在单一平台复现,排查代价最高。 + +### 2)授权桥(`PermissionRequest` 钩子) + +ZCode 决定某个工具需要授权时触发钩子(事件 JSON 走 stdin),我们回一个结论走 stdout: + +```jsonc +{"decision":"approve"} // 放行 +{"decision":"block","reason":"…"} // 拒绝,且模型能看到原因 +// 什么都不输出 // 不表态,退回 ZCode 自己的权限流程 +``` + +档位语义与 pi 桥逐条对齐(同一条邮件派给不同 Agent,行为必须一致): + +| 档位 | 守卫工具(`Bash`/`Write`/`Edit`/`ApplyPatch`) | 其它工具 | +|---|---|---| +| `plan` | 直接拒绝,文案与 pi 桥同源 | 不表态(读与查本来就允许) | +| `workspace`(默认) | 发邮件问人,等决定 | 不表态 | +| `full` | 批准(该档语义就是免掉询问) | 不表态 | + +**只有明确同意才放行**:看不懂的决策文本一律当拒绝(判定交给共用的 +`permission-grants.js`)。「一直同意」落盘存到 +`$AGENTMAIL_ZCODE_GRANTS_FILE`(或 `$AGENTMAIL_CONFIG_DIR` / `$ZCODE_PLUGIN_DATA` 下的 +`permission-grants.json`)—— 钩子是**一个事件一个进程**,不落盘的话那个选项就是骗人的。 + +**失败一律 fail closed,但区分模式**: + +- 永久失败(409 无人可问 / 其它 4xx)→ 拒绝并说明原因 +- 暂时失败(5xx / 网络)+ `AGENTMAIL_SESSION_ID` 非空(邮件驱动)→ 拒绝 + (邮件驱动的会话没有本地界面兜底,退回本地决策等于守卫消失) +- 暂时失败 + 交互模式 → 不表态,让人就地决定 + +钩子**自己开 SSE** 等决定,不找桥进程要 —— 网关的 SSE 是扇出的 +(`clients` 按唯一 id 存,`SendToAgent` 推给该 Agent 的所有客户端), +所以一次性进程也能订阅到自己那条 `permission_decision`。 +这样**交互模式下这个功能同样可用**(人自己开着 ZCode 干活时并没有桥在跑)。 + +## 配置 + +插件读与其它三桥**同名**的环境变量: + +| 变量 | 说明 | +|---|---| +| `AGENTMAIL_GATEWAY_URL` | 网关地址,默认 `http://127.0.0.1:8180` | +| `AGENTMAIL_AGENT_NAME` | 本 Agent 在 AgentMail 里的名字(如 `zcode`) | +| `AGENTMAIL_AGENT_KEY` | 管理员签发的 Agent 密钥 | +| `AGENTMAIL_AGENT_SECRET` | 没有密钥时的兜底(`X-Agent-Secret`,服务端两条路都认) | +| `AGENTMAIL_SESSION_ID` | **仅邮件驱动时**由驱动进程注入:本会话的 AgentMail 会话 id,兼作「有无本地界面」的判据 | +| `AGENTMAIL_PERMISSION_MODE` | 档位(`plan`/`workspace`/`full`),由驱动按邮件的 `permission_mode` 注入 | +| `AGENTMAIL_PERMISSION_WAIT_MS` | 等人工决策的上限,默认 540000(9 分钟,须小于钩子的 `timeoutMs`) | + +密钥怎么给:ZCode 的插件 `userConfig` **不支持** `sensitive` 值(官方文档明说 +「sensitive 值当前无法在界面输入或持久化」),所以密钥走 **ZCode 进程的环境变量** +(systemd `EnvironmentFile`),由 MCP 服务器与钩子继承。 +`userConfig` 只适合放非机密项。 + +### 安装(本地目录,无需 marketplace) + +ZCode 的发现源之一是 `plugins.dirs`(配置里的「inline directories」)。 + +```jsonc +// ~/.zcode/cli/config.json +{ "plugins": { "enabled": true, "dirs": ["/opt/agentmail/plugins/zcode-mail-bridge/current"] } } +``` + +装完用 `node plugins list` 自查,应当看到: + +``` +- agentmail@inline [enabled] + inline/inline: <插件目录> + skills: 0, commands: 0, hooks: 1, mcp: plugin:agentmail:agentmail +``` + +## 谁在维护"同源" + +`deploy/check-shared-libs.sh` 会逐个字节比对上面那 9 个共用模块(及其测试) +与 opencode 基准。**该脚本曾有假绿**:本机 PATH 上的 `diff` 是鸿蒙 SDK 工具链里的 +`diff`,不认 `-q` 且对内容不同的文件**仍返回 0**,于是检查器一直是永真输出。 +现已改用 `cmp -s` 并在开头自检(判据本身必须先被证明能发现差异)。 + +改了共用模块的正规流程:改 `opencode-mail-bridge/lib/` 下的基准, +跑 `deploy/check-shared-libs.sh` 看它报错,再逐字拷到其余三处。 + +## 验证 + +```bash +# 单元 + 继承的共用测试 +node --test 'test/*.test.mjs' + +# 共用模块四方同源(含判据自检) +bash ../../deploy/check-shared-libs.sh + +# 授权桥端到端:真建会话 → 起钩子 → 以人类身份点同意/拒绝 → 验钩子结论 +node test/manual/permission-e2e.mjs +``` + +`permission-e2e.mjs` 的判据设计:正向(同意→approve)之外还有四条反向对照 +(拒绝→block 且原因必须来自人的拒绝、plan 档拒绝且**不产生**任何权限邮件、 +无人可问→fail closed、非守卫工具→不表态)。它必须按「启动前快照差集 + +`session_id` + `agent_name`」三重过滤待决请求 —— 待决列表里有历史积压 +(实测 6 条,含其它 Agent 的),只取「第一条新的」会拿到一条无关请求, +于是人点了同意而钩子在等自己那条,最后超时。 + +## 已知缺口 + +- **邮件驱动还没做**:目前是「模型侧工具面 + 授权桥」。让 ZCode 收到来信就自动开工, + 需要一个驱动进程(订阅 SSE → 起 ZCode 会话 → 把最终回复当回信发出), + 并把 `AGENTMAIL_SESSION_ID` / `AGENTMAIL_PERMISSION_MODE` 注入会话。 +- **`mode_enforcement` 仍是 `advisory`**:授权桥已经能真的拦截(钩子返回 block 会拒绝工具), + 但库里的档位声明还没提为 `native`。 +- **生产路径**:当前 `plugins.dirs` 指向仓库工作副本,按项目纪律应改为 + `/opt/agentmail/plugins/zcode-mail-bridge/current` 的快照 + 原子切换 + (等 ZCode 重启不影响在跑的登录流程时再做)。 +- **闭源**:ZCode 是闭源客户端(deb 里 `License: unknown`),本插件的协议层 + (MCP 分帧、钩子 schema)是从其产物里实测逆出来的,版本升级可能破坏。 diff --git a/plugins/zcode-mail-bridge/hooks/hooks.json b/plugins/zcode-mail-bridge/hooks/hooks.json new file mode 100644 index 0000000..5a956e9 --- /dev/null +++ b/plugins/zcode-mail-bridge/hooks/hooks.json @@ -0,0 +1,18 @@ +{ + "hooks": { + "PermissionRequest": [ + { + "matcher": "Bash|Write|Edit|ApplyPatch", + "hooks": [ + { + "type": "process", + "command": "node", + "args": ["${ZCODE_PLUGIN_ROOT}/hooks/permission.mjs"], + "timeoutMs": 600000, + "statusMessage": "正在向 AgentMail 征求授权…" + } + ] + } + ] + } +} diff --git a/plugins/zcode-mail-bridge/hooks/permission.mjs b/plugins/zcode-mail-bridge/hooks/permission.mjs new file mode 100644 index 0000000..5d032e7 --- /dev/null +++ b/plugins/zcode-mail-bridge/hooks/permission.mjs @@ -0,0 +1,270 @@ +#!/usr/bin/env node +/** + * ZCode `PermissionRequest` 钩子 —— AgentMail 的授权桥。 + * + * # 它在链路里的位置 + * + * ZCode 决定某个工具需要授权时会触发本钩子,并把事件 JSON 写到 stdin: + * + * { hook_event_name: "PermissionRequest", tool_name: "Bash", + * tool_input: {...}, session_id: "...", permission_mode: "...", ... } + * + * 我们在 stdout 回一个结论(这是从 CLI 产物里逆出来的 schema,不是猜的): + * + * {"decision":"approve"} 放行 + * {"decision":"block","reason":"..."} 拒绝(并让模型看到原因) + * 什么都不输出 不表态,退回 ZCode 自己的权限流程 + * + * schema 是**严格**的:多一个键就会让 ZCode 报 + * "Hook stdout failed HookJSONOutput schema validation", + * 所以这里只输出这两个键。 + * + * # 为什么自己开 SSE,而不是找桥要 + * + * 决策是人点出来的,通过网关的 `permission_decision` 事件下发。 + * 钩子是**一次性进程**,没有常驻连接可用;而网关的 SSE 是**扇出**的 + * (`clients` 按唯一 id 存,`SendToAgent` 推给该 Agent 的所有客户端), + * 所以钩子可以自己订阅、拿到自己那条决定、然后退出。 + * + * 这样做的直接好处:**交互模式下也能用** —— 人自己开着 ZCode 干活时并没有 + * 桥进程在跑,若改成「问桥要结论」,这个功能就只在邮件驱动时才存在。 + * + * # 失败一律 fail closed(但区分模式) + * + * 只有明确同意才放行;看不懂的决策文本一律当拒绝(判定交给共用库)。 + * 暂时性失败(5xx / 网络)分两种:邮件驱动的会话没有本地界面兜底, + * 所以驳回并说明;交互模式则退回 ZCode 自己的权限流程,让人就地决定。 + */ + +import { readFileSync } from 'node:fs'; +import { GatewayClient } from '../lib/gateway.mjs'; +import { createSSEClient } from '../lib/sse-client.js'; +import { clampRelayKey, isPermanentFailure } from '../lib/relay-key.js'; +import { isApproval, isAlwaysDecision } from '../lib/permission-grants.js'; +import { + decidePolicy, + describeToolCall, + PERMISSION_EVENT +} from '../lib/hook-policy.mjs'; +import { createFileGrantStore, grantsFilePath } from '../lib/grants-file.mjs'; + +const log = (...parts) => console.error('[agentmail-hook]', ...parts); + +/** 等待人工决策的上限。必须**小于** hooks.json 里的 timeoutMs, + * 否则会是 ZCode 先把钩子杀掉(报成「钩子失败」),而不是我们给出结论。 */ +const WAIT_MS = Number(process.env.AGENTMAIL_PERMISSION_WAIT_MS || 540000); + +/** 回一个结论并退出。stdout 只允许出现这一个 JSON 对象。 */ +function emit(obj) { + if (obj !== null) process.stdout.write(`${JSON.stringify(obj)}\n`); + process.exit(0); +} + +const approve = () => emit({ decision: 'approve' }); +const block = reason => emit({ decision: 'block', reason }); +const noOpinion = () => emit(null); + +function readHookInput() { + try { + return JSON.parse(readFileSync(0, 'utf8')); + } catch (e) { + log('stdin 不是合法 JSON:', e?.message || e); + return null; + } +} + +/** + * 等 SSE 建连完成。 + * + * 必须先连上再发权限请求:反过来会有一个窗口 —— 人恰好在窗口内点了同意, + * 而事件推送给了当时还不存在的客户端,于是这条决定永远等不到 + * (表现为「明明点了同意,工具还是被拒」)。服务端在 AddClient 时会立刻下发 + * 一个 `connected` 事件,就用它做信号。 + */ +function waitConnected(sseState) { + return new Promise(resolve => { + const timer = setTimeout(() => { + log('SSE 建连等待超时,仍然继续(可能错过极早到达的决策)'); + resolve(); + }, 5000); + sseState.onConnected = () => { + clearTimeout(timer); + resolve(); + }; + }); +} + +async function askHuman({ client, baseURL, input, toolName, relayKey, sessionId }) { + const sseState = { onConnected: null }; + const decisions = []; + let waiter = null; + + const sse = createSSEClient({ + authHeaders: () => client.authHeaders(), + baseURL, + path: '/api/v1/events/stream', + log, + onEvent: (evt, data) => { + if (evt === 'connected' && sseState.onConnected) sseState.onConnected(); + if (evt !== 'permission_decision') return; + // 只认自己那条:同一 Agent 可能有多个钩子进程同时在等 + // (模型并行发起两个 Bash),按 relay_key 配对才不会互相拿到对方的决定。 + if (data?.relay_key && data.relay_key !== relayKey) return; + if (waiter) { + const w = waiter; + waiter = null; + w(data); + } else { + decisions.push(data); + } + } + }); + + try { + await waitConnected(sseState); + + // 不传 `to`:决策人由服务端按 会话 owner → 线索里最近的人类 → 409 解析。 + // 插件只有本地上下文,猜不出「这条 Agent 链最初是谁派的活」。 + await client.post('/permission/request', { + question: `是否允许执行 ${toolName}?`, + options: ['同意', '一直同意', '拒绝'], + context: [ + describeToolCall(toolName, input.tool_input), + process.env.AGENTMAIL_MAIL_SUBJECT + ? `\n触发任务:${process.env.AGENTMAIL_MAIL_SUBJECT}` + : '', + process.env.AGENTMAIL_REPLY_TO ? `任务来自:${process.env.AGENTMAIL_REPLY_TO}` : '' + ] + .filter(Boolean) + .join('\n'), + session_id: sessionId || '', + relay_key: relayKey + }); + + const decision = decisions.shift() ?? (await new Promise(resolve => { + // 挂上等待者;超时后也要把 waiter 摘掉,否则后续事件会去 resolve + // 一个已经没人听的 promise(并让 sse.stop 之后的日志显得诡异)。 + waiter = resolve; + setTimeout(() => { + if (waiter !== resolve) return; + waiter = null; + resolve(null); + }, WAIT_MS).unref?.(); + })); + return decision; + } finally { + sse.stop(); + } +} + +async function main() { + const input = readHookInput(); + if (!input) return noOpinion(); + + const toolName = input.tool_name; + const mode = process.env.AGENTMAIL_PERMISSION_MODE; + const policy = decidePolicy({ + event: input.hook_event_name, + toolName, + mode + }); + log(`事件 ${input.hook_event_name} 工具 ${toolName} 档位 ${mode || '(默认)'} → ${policy.action}`); + + if (policy.action === 'none') return noOpinion(); + if (policy.action === 'approve') return approve(); + if (policy.action === 'block') return block(policy.reason); + + // ── 问人 ── + const client = new GatewayClient(process.env); + const missing = client.checkConfig(); + if (missing.length) { + log(`未配置:${missing.join('、')}`); + // 没配好就无法问人。交互模式下退回本地流程仍然可用; + // 邮件驱动的会话没有本地界面,必须当场说清楚而不是静默挂住。 + return process.env.AGENTMAIL_SESSION_ID + ? block(`AgentMail 授权桥未配置(缺少 ${missing.join('、')}),无法征求授权,已拒绝 ${toolName}。`) + : noOpinion(); + } + + const sessionId = process.env.AGENTMAIL_SESSION_ID || ''; + const relayKey = clampRelayKey( + `${sessionId || input.session_id || 'zcode'}:${input.tool_use_id || toolName}` + ); + + // 「一直同意」要真的记住:钩子一封一进程,所以授权表落盘。 + const grants = createFileGrantStore(grantsFilePath(process.env)); + const grantScope = sessionId || input.session_id || ''; + if (grants.isGranted(grantScope, toolName)) { + log(`${toolName} 在本会话已获「一直同意」(${grantsFilePath(process.env)}),直接放行`); + return approve(); + } + + let decision; + try { + decision = await askHuman({ client, baseURL: client.baseURL, input, toolName, relayKey, sessionId }); + } catch (e) { + // 409 = 服务端判定这条任务链上没有人类,永远不会有人来点头。 + // 永久失败(4xx)同样不会因重试而改变 —— 两者都必须当场拒绝, + // 让模型从工具报错里看到原因并自己改道(挂死时连重试机会都没有)。 + if (isPermanentFailure(e)) { + const b = e?.body && typeof e.body === 'object' ? e.body : {}; + const reason = [ + b.error || `权限询问无法送达(HTTP ${e?.status})`, + typeof e?.body === 'string' ? e.body : '', + b.detail || '', + b.suggestion || '' + ] + .filter(Boolean) + .join('\n'); + log(`权限询问永久失败,当场拒绝 ${relayKey}:${reason}`); + return block(reason); + } + const detail = e?.message || String(e); + log(`权限询问暂时失败:${detail}`); + if (process.env.AGENTMAIL_SESSION_ID) { + // 邮件驱动:没有本地界面兜底,退回本地决策等于守卫消失。 + return block( + `无法把 ${toolName} 的授权请求送达给人(${detail})。` + + `这条会话由邮件驱动、没有本地界面,因此不放行。` + + `请改用不需要授权的方式完成,或在回信里说明需要人工执行哪一步。` + ); + } + return noOpinion(); + } + + if (decision === null) { + return block( + `等待授权超时(${Math.round(WAIT_MS / 1000)} 秒内没有人决策),未执行 ${toolName}。` + ); + } + + const text = decision.decision ?? ''; + if (isApproval(text)) { + if (isAlwaysDecision(text) && grants.grant(grantScope, toolName, text)) { + log(`记下「一直同意」:会话 ${grantScope} 的 ${toolName} 后续免批`); + } + log(`授权 ${relayKey} 获批(${text}${decision.decided_by ? ` by ${decision.decided_by}` : ''})`); + return approve(); + } + + return block( + [ + `用户拒绝了这次 ${toolName} 调用。`, + decision.note ? `说明:${decision.note}` : '', + decision.decided_by ? `(由 ${decision.decided_by} 决定)` : '' + ] + .filter(Boolean) + .join('\n') + ); +} + +main().catch(e => { + // 钩子自身崩了**不能**静默退回 ZCode 的权限流程 —— 那在有本地界面时是 + // 合理兜底,在邮件驱动时等于守卫消失。所以这里区分模式,并把原因写在 + // stderr(进 ZCode 日志)供人排查。 + log('钩子异常:', e?.stack || e); + if (process.env.AGENTMAIL_SESSION_ID) { + block(`AgentMail 授权钩子内部错误:${e?.message || e}。未执行工具。`); + } + noOpinion(); +}); diff --git a/plugins/zcode-mail-bridge/lib/grants-file.mjs b/plugins/zcode-mail-bridge/lib/grants-file.mjs new file mode 100644 index 0000000..583725d --- /dev/null +++ b/plugins/zcode-mail-bridge/lib/grants-file.mjs @@ -0,0 +1,102 @@ +/** + * 「一直同意」的跨进程持久化。 + * + * # 为什么需要文件 + * + * ZCode 的钩子是**一个事件一个进程** —— 批准完就退出。pi 桥那边的授权集合活在 + * 常驻 worker 里(靠会话快照跨 worker),而这里没有可依附的常驻内存: + * 不落盘的话「一直同意」只在本次调用有效,而下一次调用是个新进程, + * 会再问一遍 —— 那个选项就成了骗人的(pi 桥的注释里原话是 + * 「否则这个选项在骗人」)。 + * + * # 判定语义不在这里 + * + * 「什么是同意」「什么是一直同意」全部来自共用的 `lib/permission-grants.js` + * (逐字节同源)。本模块只管把结果存下来,不自己写判定正则 —— + * 那正是各平台会悄悄分叉的地方。 + * + * # 并发 + * + * 读-改-写。同一会话的两次授权请求几乎不会同时发生(ZCode 串行执行工具), + * 且写入是原子替换(临时文件 + rename),所以最坏情况是「后写覆盖先写」, + * 不会读到半截 JSON。真要并发也只会多问一次,不会漏判。 + */ + +import { readFileSync, writeFileSync, renameSync, mkdirSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { homedir } from 'node:os'; +import { isAlwaysDecision } from './permission-grants.js'; + +/** + * 授权表文件的位置。 + * + * 优先用显式配置,其次插件数据目录(ZCode 会注入 `ZCODE_PLUGIN_DATA`), + * 最后退回家目录下的固定名。三者都不存在的情况极罕见, + * 但必须有确定答案 —— 退回家目录至少让功能可用。 + */ +export function grantsFilePath(env = process.env) { + if (env.AGENTMAIL_ZCODE_GRANTS_FILE) return env.AGENTMAIL_ZCODE_GRANTS_FILE; + if (env.AGENTMAIL_CONFIG_DIR) return join(env.AGENTMAIL_CONFIG_DIR, 'permission-grants.json'); + if (env.ZCODE_PLUGIN_DATA) return join(env.ZCODE_PLUGIN_DATA, 'permission-grants.json'); + return join(homedir(), '.agentmail-zcode', 'permission-grants.json'); +} + +/** 读盘。文件不存在或内容坏掉都当空表 —— 授权表读不出来不该让钩子崩。 */ +export function loadGrants(filePath) { + try { + const raw = JSON.parse(readFileSync(filePath, 'utf8')); + const out = new Map(); + for (const [session, tools] of Object.entries(raw?.sessions ?? {})) { + if (Array.isArray(tools)) out.set(session, new Set(tools.filter(t => typeof t === 'string'))); + } + return out; + } catch { + return new Map(); + } +} + +function saveGrants(filePath, grants) { + const sessions = {}; + for (const [session, tools] of grants) sessions[session] = [...tools]; + const payload = { version: 1, sessions }; + mkdirSync(dirname(filePath), { recursive: true }); + // 原子替换:直接覆盖写会让并发读者看到半截 JSON(而上面的 load 会把它 + // 当成空表,于是刚给的授权静默消失)。 + const tmp = `${filePath}.tmp-${process.pid}`; + writeFileSync(tmp, `${JSON.stringify(payload, null, 2)}\n`, 'utf8'); + renameSync(tmp, filePath); +} + +/** + * 基于文件的授权表。 + * + * @param {string} filePath + */ +export function createFileGrantStore(filePath) { + const grants = loadGrants(filePath); + + return { + isGranted(sessionId, toolName) { + if (!sessionId || !toolName) return false; + return grants.get(sessionId)?.has(toolName) ?? false; + }, + + /** 只在决策文本确实是「一直同意」时落盘(判定交给共用库)。 */ + grant(sessionId, toolName, decision) { + if (!sessionId || !toolName) return false; + if (!isAlwaysDecision(decision)) return false; + let set = grants.get(sessionId); + if (!set) grants.set(sessionId, (set = new Set())); + set.add(toolName); + saveGrants(filePath, grants); + return true; + }, + + /** 撤销整条会话的免批(换模型重开会话时用)。 */ + revokeSession(sessionId) { + if (!grants.delete(sessionId)) return false; + saveGrants(filePath, grants); + return true; + } + }; +} diff --git a/plugins/zcode-mail-bridge/lib/hook-policy.mjs b/plugins/zcode-mail-bridge/lib/hook-policy.mjs new file mode 100644 index 0000000..1a8fab0 --- /dev/null +++ b/plugins/zcode-mail-bridge/lib/hook-policy.mjs @@ -0,0 +1,92 @@ +/** + * ZCode 授权钩子的**判定策略**(纯函数,不碰 I/O)。 + * + * 语义与 pi 桥的 `permissionExtension()` 逐条对齐 —— 档位判定是给产品定的, + * 不是给平台定的:同一个「plan 档」在 ZCode 上必须是同一个意思, + * 否则同一封邮件派到两个 Agent 上会得到两种行为,而人只会以为自己派错了。 + * + * 只把「该做什么」算出来,真正的 I/O(问人、等决定、写 stdout)留在钩子入口, + * 于是这里可以被穷举测试。 + */ + +import { + normalizeMode, + DEFAULT_MODE, + MODE_FULL, + MODE_PLAN +} from './permission-mode.js'; + +/** + * 被守卫的工具名。 + * + * 对应 pi 桥的 `GUARDED = new Set(['bash', 'write', 'edit'])`。 + * ZCode 的工具名是首字母大写,且 `Write`/`Edit` 有一个来自 `ApplyPatch` 的别名, + * 所以这里做大小写无关匹配并收进 `applypatch`。 + */ +const GUARDED = new Set(['bash', 'write', 'edit', 'applypatch']); + +/** ZCode 的钩子事件名(七个之一)。本模块只关心这一个。 */ +export const PERMISSION_EVENT = 'PermissionRequest'; + +export function isGuardedTool(toolName) { + return GUARDED.has(String(toolName ?? '').trim().toLowerCase()); +} + +/** + * 算出这次钩子该采取的动作。 + * + * @param {{event?: string, toolName?: string, mode?: string}} input + * @returns {{action: 'none'|'approve'|'block'|'ask', reason?: string}} + * + * - `none`:不表态。ZCode 会继续它自己的权限流程(该问谁就问谁)—— + * 这是「不该由我们插手」的唯一正确表达方式;返回 approve 会越权放行, + * 返回空字符串 stdout 也一样是「不表态」,但显式写出来更清楚。 + * - `approve` / `block`:直接给结论。 + * - `ask`:交给 AgentMail 问人,等决定。 + */ +export function decidePolicy({ event, toolName, mode } = {}) { + if (event !== PERMISSION_EVENT) return { action: 'none' }; + + // 非守卫工具不表态。钩子的 matcher 已经在 hooks.json 里限定了范围, + // 这里再判一次是纵深防御:matcher 被人改宽时不会静默变成「什么都批准」。 + if (!isGuardedTool(toolName)) return { action: 'none' }; + + const m = normalizeMode(mode) || DEFAULT_MODE; + + // full 档:发件人已声明全权,pi 桥在这一档直接不拦截。 + // ZCode 上「不拦截」的等价物就是批准 —— 钩子一旦触发,ZCode 本会去问人, + // 而我们正是要在这一档免掉那个询问。返回 none 会退回询问,语义就反了。 + if (m === MODE_FULL) return { action: 'approve' }; + + // plan 档:该档语义是「只读不动手」,没什么可问人的。 + // 文案与 pi 桥同源,模型收到的措辞一致。 + if (m === MODE_PLAN) { + return { + action: 'block', + reason: + `plan 档下不允许执行 ${toolName}。本档只允许读与查,请把方案写在回信里。` + + `如需动手请让发件人把档位改成 workspace。` + }; + } + + return { action: 'ask' }; +} + +/** + * 把一次工具调用摘要成人能判断的文本。 + * + * 与 pi 桥的 `describeToolCall` 同源(同样的字段截断长度), + * 差别只在 ZCode 的入参字段名(它给的是 `tool_input`)。 + */ +export function describeToolCall(toolName, toolInput) { + const input = toolInput && typeof toolInput === 'object' ? toolInput : {}; + const name = String(toolName ?? '').toLowerCase(); + if (name === 'bash') { + return `命令:\n${String(input.command ?? '').slice(0, 800)}`; + } + if (name === 'write' || name === 'edit' || name === 'applypatch') { + const p = input.file_path ?? input.path ?? input.filePath ?? '(未给出)'; + return `文件:${p}`; + } + return JSON.stringify(input).slice(0, 800); +} diff --git a/plugins/zcode-mail-bridge/lib/permission-grants.js b/plugins/zcode-mail-bridge/lib/permission-grants.js new file mode 100644 index 0000000..0959f3d --- /dev/null +++ b/plugins/zcode-mail-bridge/lib/permission-grants.js @@ -0,0 +1,105 @@ +// 权限免批(「一直同意」)的纯逻辑 —— 所有平台插件共用。 +// +// # 这是什么 +// +// 权限询问默认是**每次都问**:模型每调一次 bash 就发一封邮件等人点头。 +// 这在「跑一条命令看看」的场景下是对的,在「审查这个工程」的场景下是灾难 —— +// 实测同一条会话被问了 15 次 bash,人点了 15 次「同意」,全是同一类操作。 +// +// 「一直同意」就是人对此的回答:这条会话里这个工具,别再问了。 +// +// # 为什么需要一个独立模块 +// +// 因为它的**作用域**是唯一容易搞错的地方,而搞错的后果是静默的越权: +// +// - 作用域太宽(全局 / 只按工具名)→ 人为「审查 llmsproxy」批准的 bash, +// 会静默授权另一个发件人派来的另一条任务。那不是他批准的东西。 +// - 作用域太窄(按 toolCallId)→ 等于没有免批,每条命令还是一封邮件。 +// +// 正确的粒度是 **(会话, 工具名)**:人看到的那句「是否允许执行 bash?」 +// 就是在这个粒度上提的问,授权范围不该超出提问范围。 +// +// # 为什么只在内存里 +// +// 会话结束(进程重启)即失效,这是有意的。长期免批该由平台自己的 settings +// 管(pi 的 settings.json、opencode 的 permission 配置),不该让一个守护进程 +// 的内存变成事实上的安全策略 —— 那种策略没人能审计,重启后又悄悄消失。 + +/** + * 判定一个决策文本是不是「永久同意」。 + * + * **必须精确匹配**,不能用前缀匹配。`/^同意/` 会把「同意」也算成 always, + * 于是人点一次单次授权,后面所有命令都不再问了 —— 那是把单次授权 + * 静默升级成永久授权,比不实现这个功能危险得多。 + * + * @param {string} decision 人点的选项原文 + * @returns {boolean} + */ +export function isAlwaysDecision(decision) { + return /^(一直同意|always|allow-always|allow_always)$/i.test(String(decision ?? '').trim()); +} + +/** + * 判定一个决策文本是不是「同意」(含永久同意)。 + * + * fail closed:认不出的文本一律当拒绝。空串、`shutdown`(关停时唤醒等待者 + * 用的哨兵值)、以及任何没见过的选项都走这一支 —— 放行一个没人批准的 + * 危险操作,比让它失败严重得多。 + * + * @param {string} decision + * @returns {boolean} + */ +export function isApproval(decision) { + return /^(同意|一直同意|allow|approve|always|yes)/i.test(String(decision ?? '').trim()); +} + +/** + * 免批授权表:`会话 id -> Set<工具名>`。 + * + * 用 Map> 而不是 Set<`${session}:${tool}`>: + * 会话结束时要能一次清掉它的全部授权(`revokeSession`), + * 拼接键的话得遍历整张表按前缀删,而工具名里出现 `:` 就会误删。 + */ +export function createGrantStore() { + /** @type {Map>} */ + const grants = new Map(); + + return { + /** 这条会话的这个工具是否已获免批。 */ + isGranted(sessionId, toolName) { + if (!sessionId || !toolName) return false; + return grants.get(sessionId)?.has(toolName) ?? false; + }, + + /** + * 记下一条免批授权。只在决策文本确实是「一直同意」时才记 —— + * 判定交给 isAlwaysDecision,调用方不要自己写正则。 + * @returns {boolean} 是否真的记下了(便于调用方决定要不要打日志) + */ + grant(sessionId, toolName, decision) { + if (!sessionId || !toolName) return false; + if (!isAlwaysDecision(decision)) return false; + let set = grants.get(sessionId); + if (!set) grants.set(sessionId, (set = new Set())); + set.add(toolName); + return true; + }, + + /** + * 撤销整条会话的免批。 + * + * 换模型重开会话时必须调:授权是人对**那次**上下文的判断, + * 新会话重跑一遍提示,不该继承上一条的授权。 + */ + revokeSession(sessionId) { + grants.delete(sessionId); + }, + + /** 仅用于测试与诊断:当前授权总数。 */ + size() { + let n = 0; + for (const set of grants.values()) n += set.size; + return n; + }, + }; +} diff --git a/plugins/zcode-mail-bridge/lib/permission-mode.js b/plugins/zcode-mail-bridge/lib/permission-mode.js new file mode 100644 index 0000000..cbb6293 --- /dev/null +++ b/plugins/zcode-mail-bridge/lib/permission-mode.js @@ -0,0 +1,239 @@ +/** + * 权限档位 → 平台原生配置的翻译 —— 四个平台共用的判据。 + * + * ## 分工 + * + * **AgentMail 声明,平台执行,插件只翻译。** 这个模块是「翻译」那一步的 + * 唯一实现:把 `plan` / `workspace` / `full` 翻成各平台原生的沙箱/审批配置。 + * + * 为什么不让插件自己按工具名猜着拦:那会同时违反 I-1(平台原生信号是唯一 + * 真相来源)与 I-4(插件只搬运不决策),而且四个插件对「workspace 到底管 + * 什么」必然各猜一套 —— 同一封 workspace 档的邮件在 A 平台被拦、在 B 平台放行。 + * + * ## 为什么必须「向更严取整」 + * + * 平台表达不出精确档位时,一律往更严的方向走,并如实上报自己做到了什么 + * (native / advisory)。pi 就是例子:write/edit 能查 `input.path` 判断越界, + * 而 bash 命令要碰哪些文件是解析不出来的 —— 于是 workspace 档下 pi 只能 + * 「每条 bash 都问人」,比声明的更严。 + * + * 不定这条规则的后果:不同插件会朝不同方向取整,而往宽松取整是静默失效 + * (人以为收紧了,实际没有)。 + */ + +/** 只读:查资料、读代码、出方案,一个字都不许写。 */ +export const MODE_PLAN = 'plan'; +/** 本目录内可动手,越界要问人。默认档。 */ +export const MODE_WORKSPACE = 'workspace'; +/** 自动放行,不问人。 */ +export const MODE_FULL = 'full'; + +/** 全部合法档位,按宽松程度递增。顺序是 modeAtMost 的依据。 */ +export const MODES = [MODE_PLAN, MODE_WORKSPACE, MODE_FULL]; + +/** 没有显式指定时的档位。与 Gateway 的 DefaultPermissionMode 必须一致。 */ +export const DEFAULT_MODE = MODE_WORKSPACE; + +/** 平台有原生拦截点,档位被完整执行。 */ +export const ENFORCE_NATIVE = 'native'; +/** + * 平台有原生拦截点,但覆盖不完整(有已知缺口)。 + * + * 实测例子:DSH 的 Landlock 沙箱受内核 ABI 版本限制,能拦下大部分写入与命令 + * 执行,但并非全部路径。只给 native / advisory 两个取值会逼出一个假陈述: + * 标 native 是高估(人会当成硬保证),标 advisory 是低估(它确实在拦)。 + */ +export const ENFORCE_PARTIAL = 'partial'; +/** 平台没有拦截点,档位只写进提示词。 */ +export const ENFORCE_ADVISORY = 'advisory'; + +/** + * 把外部输入收敛成合法档位。 + * + * 非法值 → 默认档(**不是** full)。拼错一个档位名不该换来更大的权限。 + * 与 Gateway 的 NormalizePermissionMode 同语义。 + * + * @param {unknown} mode + * @returns {string} + */ +export function normalizeMode(mode) { + return MODES.includes(mode) ? mode : DEFAULT_MODE; +} + +/** + * 收敛强制力取值。空串或非法值 → advisory。 + * + * 保守方向是 advisory 而不是 native:不能替一个没自报过的平台宣称 + * 「档位在这里是被强制的」。 + * + * 但**显式自报的值一律原样保留**(含 partial):那是平台自己的事实陈述, + * 把它降级到任一极端都是在替它说假话。 + * + * @param {unknown} e + * @returns {string} + */ +export function normalizeEnforcement(e) { + return e === ENFORCE_NATIVE || e === ENFORCE_PARTIAL || e === ENFORCE_ADVISORY + ? e + : ENFORCE_ADVISORY; +} + +/** + * 取两个档位里更严的那一个。 + * + * 先归一化再比较 —— 两个脏值都变成默认档,于是结果与参数顺序无关(可交换)。 + * Gateway 侧的 ModeAtMost 曾因为「modeRank 把未知值当最严、Normalize 把它 + * 归到默认档」而不可交换,单元测试当场抓到。两边保持同一套语义。 + * + * @param {string} a + * @param {string} b + * @returns {string} + */ +export function modeAtMost(a, b) { + const na = normalizeMode(a); + const nb = normalizeMode(b); + return MODES.indexOf(na) <= MODES.indexOf(nb) ? na : nb; +} + +/** + * 这一档会不会产生权限邮件(即需不需要人来点头)。 + * + * 只有 workspace 档需要人:plan 档当场拒绝、full 档自动放行,两者都不问人。 + * 插件据此决定要不要把平台的权限钩子接到 `/permission/request`。 + * + * @param {string} mode + * @returns {boolean} + */ +export function modeNeedsHuman(mode) { + return normalizeMode(mode) === MODE_WORKSPACE; +} + +/** + * DSH 的沙箱模式。 + * + * 三档与 DSH 原生的三档**一一对应** —— 这不是巧合,是同一个问题的同一个答案 + * (见 `@deepseek-ai/dsh-sandbox-policy` 的 SANDBOX_MODES)。 + * + * @param {string} mode + * @returns {'read-only'|'workspace-write'|'danger-full-access'} + */ +export function dshSandboxMode(mode) { + switch (normalizeMode(mode)) { + case MODE_PLAN: return 'read-only'; + case MODE_FULL: return 'danger-full-access'; + default: return 'workspace-write'; + } +} + +/** + * DSH 的审批策略。 + * + * 关键实测:`danger-full-access` 对应 `approval: "never"`,而 + * `ApprovalService.decide()` 里 `if (effectivePolicy === "never") return "rejected"` + * **在 waterfall 之前短路** —— 于是 `approval/request` 钩子根本不触发。 + * + * 这解释了一个此前查不清的现象:本机 dsh 配了 `defaultPreset: danger-full-access`, + * 所以整个权限转邮件链路从来没在 dsh 上跑起来过。 + * + * @param {string} mode + * @returns {'ask'|'never'} + */ +export function dshApprovalPolicy(mode) { + return modeNeedsHuman(mode) ? 'ask' : 'never'; +} + +/** + * pi 侧应当守卫的工具名。 + * + * pi 只有 `tool_call` 钩子能 `{block:true}`,没有沙箱 —— 所以档位靠 + * 「拦哪些工具」表达: + * + * - plan 拦 bash/write/edit(读类工具 read/grep/find/ls 不拦) + * - workspace 拦同样三个,但 write/edit 可以查 `input.path` 判越界, + * bash 无法判断 → 一律问人(向更严取整) + * - full 不拦 + * + * @param {string} mode + * @returns {string[]} + */ +export function piGuardedTools(mode) { + return normalizeMode(mode) === MODE_FULL ? [] : ['bash', 'write', 'edit']; +} + +/** + * pi 在某档位下,某次工具调用该不该直接拒绝(不问人)。 + * + * plan 档下所有被守卫的工具都直接拒绝 —— 该档语义就是「这轮不动手」, + * 没什么可问人的,模型该把方案写在回信里。 + * + * workspace 档返回 false(走问人流程)。full 档不会进到这里。 + * + * @param {string} mode + * @returns {boolean} + */ +export function piBlocksOutright(mode) { + return normalizeMode(mode) === MODE_PLAN; +} + +/** + * 给模型看的档位说明,放进提示词。 + * + * 三种强制力必须说三种话: + * - native :「会被拦下」—— 模型可以依赖它 + * - partial:「大部分会被拦下,但有已知缺口」—— 不能依赖它 + * - advisory:「平台不拦,靠你自己遵守」 + * 把 partial 当成 native 会让模型以为越界一定被拦,于是不必自己小心; + * 当成 advisory 又会让它以为平台完全没有拦截机制,在本可以依赖的边界上过度保守。 + * + * @param {{mode: string, enforcement: string, workspace?: string}} ctx + * @returns {string} + */ +export function modeBriefing({ mode, enforcement, workspace }) { + const m = normalizeMode(mode); + const e = normalizeEnforcement(enforcement); + const dir = workspace ? `\`${workspace}\`` : '本任务的工作目录'; + + if (m === MODE_FULL) { + return '本任务权限档位:full(全权)。工具调用不需要额外授权。'; + } + + if (m === MODE_PLAN) { + if (e === ENFORCE_NATIVE) { + return [ + '本任务权限档位:plan(只读)。', + '写文件、改文件、执行命令都会被平台拦下 —— 这一档只用来查与想。', + '请把结论、方案、需要人工执行的步骤写在回信里。需要动手请让发件人把档位改成 workspace。', + ].join('\n'); + } + if (e === ENFORCE_PARTIAL) { + return [ + '本任务权限档位:plan(只读)。', + '平台会拦截写文件、改文件与执行命令,但**拦截覆盖不完整**(沙箱能力受平台/内核限制,有已知缺口)。', + '因此不要把「会被拦下」当成保证:请主动只查与想,把结论、方案与需要人工执行的步骤写在回信里。', + '需要动手请让发件人把档位改成 workspace。', + ].join('\n'); + } + return [ + '本任务权限档位:plan(只读)。', + '**这个平台无法强制这一档**,所以约束靠你自己遵守:请不要写文件、改文件或执行命令。', + '请把结论、方案、需要人工执行的步骤写在回信里。需要动手请让发件人把档位改成 workspace。', + ].join('\n'); + } + + if (e === ENFORCE_NATIVE) { + return [ + `本任务权限档位:workspace。可以在 ${dir} 内读写,越出该目录的写入与命令执行会先向人类请求授权。`, + '授权可能需要等待,也可能被拒绝 —— 被拒绝时请换一条不需要越界的做法,或在回信里说明需要人工执行哪一步。', + ].join('\n'); + } + if (e === ENFORCE_PARTIAL) { + return [ + `本任务权限档位:workspace。请把改动限制在 ${dir} 内;越界写入与命令执行会向人类请求授权。`, + '但**沙箱覆盖不完整**(有已知缺口),不要依赖「越界一定被拦」:请主动守住边界,需要改该目录之外的东西时先在回信里说明。', + ].join('\n'); + } + return [ + `本任务权限档位:workspace。请把改动限制在 ${dir} 内。`, + '**这个平台无法强制这一档**,所以边界靠你自己遵守:需要改该目录之外的东西时,不要直接动手,先在回信里说明。', + ].join('\n'); +} diff --git a/plugins/zcode-mail-bridge/lib/relay-key.js b/plugins/zcode-mail-bridge/lib/relay-key.js new file mode 100644 index 0000000..73b4237 --- /dev/null +++ b/plugins/zcode-mail-bridge/lib/relay-key.js @@ -0,0 +1,127 @@ +/** + * relay_key 长度收敛 —— 四个平台共用。 + * + * ## 为什么需要它 + * + * relay_key 是免配额通道的幂等键,服务端列宽 160 字节(超了返回 400)。 + * 插件按「会话 id + 某个平台侧调用 id」拼这个键,平常七十来字节,很安全。 + * + * 但生产上踩到一次:pi 会话里 bash 的 relay_key 突然超限,报文 + * 「relay_key 过长(上限 160 字节)」。查真实会话文件后发现 toolCallId + * 有两种形态: + * + * toolu_bdrk_01F6roEBHa8nic1mYiyLgNWK 35 字节 + * toolu_bdrk_01FsWUWhEs4arnEWo44gqzLC~sig1:CAISoQIK… 437 ~ 13601 字节 + * + * 启用 extended thinking 时 Bedrock 把**思考签名**拼进了 toolCallId。 + * 同一条会话里两种形态混着出现,于是同一个 Agent 的权限询问随机成功随机失败。 + * + * 后果不只是「这一次没送达」:那次失败被归入「暂时失败 → 让位给本地决策」, + * 而邮件驱动的 worker 没有 TUI,没有人可问 —— 那次 bash 调用**没有任何人 + * 批准就执行了**。守卫形同虚设。 + * + * ## 为什么用哈希而不是直接截断 + * + * 直接截断会让两次不同的调用撞成同一个键(前缀相同后缀被切掉), + * 而这个键的全部意义是幂等:撞键意味着第二次询问被服务端当成重复请求丢掉。 + * sha256 的碰撞概率可以忽略,且**同样的输入永远得到同样的输出** —— + * 这一点是必须的:插件重启后重放同一轮,必须算出同一个键。 + * + * ## 为什么保留可读前缀 + * + * 纯哈希在日志里没法看出是哪条会话。保留前缀让 `grep 会话id` 仍然有用。 + * 前缀按**字节**截断并回退到字符边界 —— 键里可能有中文(邮件主题派生的键), + * 按字符数算会超字节上限,按字节硬切会切出半个字符。 + */ + +import { createHash } from 'node:crypto'; + +/** 服务端 relay_key 列宽(字节)。与 gateway 侧 160 保持一致。 */ +export const RELAY_KEY_MAX_BYTES = 160; + +/** `:sha256:` + 64 位 hex */ +const HASH_SUFFIX_BYTES = 8 + 64; + +/** + * UTF-8 字节数。 + * + * @param {string} s + * @returns {number} + */ +export function byteLength(s) { + return Buffer.byteLength(String(s ?? ''), 'utf8'); +} + +/** + * 按字节截断,回退到最近的字符边界(不产生半个字符)。 + * + * @param {string} s + * @param {number} maxBytes + * @returns {string} + */ +export function truncateToBytes(s, maxBytes) { + const str = String(s ?? ''); + if (maxBytes <= 0) return ''; + const buf = Buffer.from(str, 'utf8'); + if (buf.length <= maxBytes) return str; + + let end = maxBytes; + // UTF-8 续字节是 10xxxxxx。若第一个被丢掉的字节是续字节, + // 说明切点落在字符中间 —— 往前退到该字符的首字节之前。 + while (end > 0 && (buf[end] & 0xc0) === 0x80) end--; + return buf.subarray(0, end).toString('utf8'); +} + +/** + * 把 relay_key 收敛到服务端能接受的长度。 + * + * 未超限时**原样返回** —— 这一点很重要:绝大多数键本来就合规, + * 改写它们会让插件升级前后算出不同的键,等于把已发出的询问变成新询问。 + * + * @param {string} key 原始键 + * @param {number} [limit] 上限字节数,默认 RELAY_KEY_MAX_BYTES + * @returns {string} 长度不超过 limit 的键 + */ +export function clampRelayKey(key, limit = RELAY_KEY_MAX_BYTES) { + const raw = String(key ?? ''); + if (byteLength(raw) <= limit) return raw; + + const hash = createHash('sha256').update(raw, 'utf8').digest('hex'); + const suffix = `:sha256:${hash}`; + // 上限小到装不下哈希时只留哈希(截断哈希仍然确定,只是碰撞面变大; + // 这条路径在真实配置下不会走到 —— 160 远大于 72)。 + if (limit <= HASH_SUFFIX_BYTES) return truncateToBytes(hash, limit); + + const prefix = truncateToBytes(raw, limit - HASH_SUFFIX_BYTES); + return `${prefix}${suffix}`; +} + +/** + * 这次失败是不是「永远不会成功」。 + * + * ## 为什么必须分类 + * + * 插件在权限询问发送失败时有两条路:让位给平台本地决策,或当场 block。 + * 原来除 409 之外一律当「暂时失败」让位 —— 而 400(请求本身不合法) + * 重试一万次也是 400。邮件驱动的会话**没有本地 UI**,让位等于让守卫消失: + * 生产实测一次 bash 就这样在无人批准的情况下执行了。 + * + * ## 判据 + * + * - 4xx(除 408 / 429)= 永久:请求本身有问题,重试不会变好 + * - 408 / 429 = 暂时:超时与限流,等一会儿真的可能成功 + * - 5xx = 暂时:服务端的问题 + * - 无 status(网络层错误、DNS、连接被拒)= 暂时 + * + * 401 归到永久:密钥无效要人去后台重新登记,不是等一等就好的事 + * (本会话实测过一次 —— opencode 拿着已撤销的密钥重试了 18 小时)。 + * + * @param {{status?: number}} err + * @returns {boolean} true = 永久失败,插件必须当场表态 + */ +export function isPermanentFailure(err) { + const status = Number(err?.status); + if (!Number.isFinite(status) || status <= 0) return false; // 网络层错误 → 暂时 + if (status === 408 || status === 429) return false; // 超时 / 限流 → 暂时 + return status >= 400 && status < 500; +} diff --git a/plugins/zcode-mail-bridge/lib/sse-client.js b/plugins/zcode-mail-bridge/lib/sse-client.js new file mode 100644 index 0000000..68efdfc --- /dev/null +++ b/plugins/zcode-mail-bridge/lib/sse-client.js @@ -0,0 +1,179 @@ +/** + * 共用 SSE 客户端:跨 TCP 分片保帧状态 + Last-Event-ID 断点续传。 + * + * 三平台桥原本各自手写 SSE 解析,且都有同一个 bug: + * - `evt` / `data` 是每次 `read()` 的局部变量,TCP 把一帧 + * `event: xxx\ndata: {...}\n\n` 切在换行处时,第一段只剩 + * `event:` 而第二段只有 `data:` —— 整帧被静默丢弃。 + * - 重连不带 `Last-Event-ID`,断线期间的事件只在服务端环形 + * 缓冲里等着,永远回放不出来(Gateway 有 per-agent ring buffer, + * pi 与 homeagent 已正确利用,DSH/opencode 没有)。 + * + * 这个模块把 pi 桥 `src/gateway.mjs` 里那份验证过的实现抽成共用件, + * 三桥逐字节同源(deploy/check-shared-libs.sh 校验)。 + */ + +/** + * 增量 SSE 帧解析器。 + * + * `push(chunk)` 可以喂任意切分的文本片段,返回本次完整解析出的事件数组。 + * 所有跨帧状态(缓冲、当前 event/data/id)都保存在闭包里,**不随 chunk 重置** —— + * 这正是原实现丢帧的根因。 + * + * 协议细节: + * - `:` 开头 = 注释/心跳,忽略 + * - `id:` / `event:` / `data:` 各取字段;`data:` 后的单个空格是分隔符 + * - 多行 data 用 `\n` 拼接 + * - 空行 = 帧结束;只有 event 与 data 都非空才派发(与旧行为一致) + * - 兼容 CRLF + * - `lastEventId` 在**派发之前**记下:回调抛异常也不该让断点回退。 + * + * @returns {{push: (chunk: string) => Array<{event: string, data: string, id: string}>, + * reset: () => void, + * lastEventId: () => string, + * setLastEventId: (id: string) => void}} + */ +export function createFrameParser() { + let buffer = ''; + let lastEventId = ''; + let curEvent = ''; + let curData = ''; + let curId = ''; + + function push(chunk) { + buffer += chunk; + const events = []; + const lines = buffer.split('\n'); + // 最后一段可能是被切断的半行,留到下一个 chunk + buffer = lines.pop() ?? ''; + + for (let line of lines) { + if (line.length > 0 && line.charAt(line.length - 1) === '\r') { + line = line.slice(0, -1); + } + if (line.startsWith(':')) continue; + + if (line.startsWith('id:')) { + curId = line.slice(3).trim(); + } else if (line.startsWith('event:')) { + curEvent = line.slice(6).trim(); + } else if (line.startsWith('data:')) { + let value = line.slice(5); + if (value.startsWith(' ')) value = value.slice(1); + curData = curData.length > 0 ? `${curData}\n${value}` : value; + } else if (line === '') { + if (curEvent.length > 0 && curData.length > 0) { + if (curId.length > 0) lastEventId = curId; + events.push({ event: curEvent, data: curData, id: curId }); + } + curEvent = ''; + curData = ''; + curId = ''; + } + } + return events; + } + + function reset() { + buffer = ''; + curEvent = ''; + curData = ''; + curId = ''; + } + + return { + push, + reset, + lastEventId: () => lastEventId, + setLastEventId: (id) => { lastEventId = id || ''; }, + }; +} + +/** + * @param {object} deps + * @param {() => Record} deps.authHeaders 认证头(每次重连重取,密钥可能已换) + * @param {string} deps.baseURL Gateway 基地址(不带末尾 /) + * @param {string} deps.path SSE 路径(如 /api/v1/events/stream) + * @param {(evt: string, data: any) => void} deps.onEvent 事件分发回调 + * @param {(msg: string) => void} [deps.log] 日志回调(默认 console.error) + * @returns {{stop: () => void}} stop() 终止重连与在途请求 + */ +export function createSSEClient({ authHeaders, baseURL, path, onEvent, log = console.error }) { + const controller = new AbortController(); + const parser = createFrameParser(); + + function stop() { + controller.abort(); + } + + function reconnect(delay) { + if (controller.signal.aborted) return; + setTimeout(() => connect(), delay); + } + + function connect() { + if (controller.signal.aborted) return; + + const headers = { ...authHeaders(), Accept: 'text/event-stream' }; + // 只有 lastEventId 非空(= 已经收过事件)时才是重连:首次连接不带, + // 否则服务端会把环形缓冲里的旧事件全回放一遍,插件重启后重复处理一批已处理的邮件。 + const lastEventID = parser.lastEventId(); + if (lastEventID) { + headers['Last-Event-ID'] = lastEventID; + log(`SSE 重连,从事件 ${lastEventID} 之后续传`); + } + + fetch(`${baseURL}${path}`, { headers, signal: controller.signal }) + .then((res) => { + if (!res.ok || !res.body) { + log(`SSE 建连失败: HTTP ${res.status}`); + return reconnect(5000); + } + const reader = res.body.getReader(); + const decoder = new TextDecoder(); + + function read() { + reader.read().then(({ done, value }) => { + if (done) { + parser.reset(); + return reconnect(3000); + } + for (const ev of parser.push(decoder.decode(value, { stream: true }))) { + try { + onEvent(ev.event, JSON.parse(ev.data)); + } catch (e) { + log(`SSE 事件处理失败: ${e?.message || e}`); + } + } + read(); + }).catch((e) => { + if (controller.signal.aborted) return; + log(`SSE 读取中断: ${e?.message || e}`); + parser.reset(); + reconnect(5000); + }); + } + read(); + }) + .catch((e) => { + if (controller.signal.aborted) return; + log(`SSE 连接错误: ${e?.message || e}`); + parser.reset(); + reconnect(5000); + }); + } + + connect(); + + return { + stop, + /** + * 清掉断点(不终止连接)。 + * + * 换 Gateway 地址时必须调:lastEventID 是**旧** Gateway 环形缓冲里的序号, + * 拿去问新 Gateway 会命中一段完全无关的历史(或直接被拒), + * 得到的事件属于别人的会话。 + */ + reset: () => parser.setLastEventId(''), + }; +} diff --git a/plugins/zcode-mail-bridge/test/grants-file.test.mjs b/plugins/zcode-mail-bridge/test/grants-file.test.mjs new file mode 100644 index 0000000..3ed5ce9 --- /dev/null +++ b/plugins/zcode-mail-bridge/test/grants-file.test.mjs @@ -0,0 +1,98 @@ +/** + * 「一直同意」的跨进程持久化测试。 + * + * 这个功能的判据只有一条最要紧:**下一个进程还认不认**。 + * 钩子一封(一次工具调用)一个进程,所以「记在内存里」等于没记。 + */ + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtemp, writeFile, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { createFileGrantStore, grantsFilePath } from '../lib/grants-file.mjs'; + +const tmpFile = async () => join(await mkdtemp(join(tmpdir(), 'zc-grants-')), 'g.json'); + +test('★ 授权跨进程存活(新 store 读同一个文件仍认账)', async () => { + const f = await tmpFile(); + const s1 = createFileGrantStore(f); + assert.equal(s1.isGranted('sess-1', 'Bash'), false); + assert.equal(s1.grant('sess-1', 'Bash', '一直同意'), true); + + // 模拟下一个钩子进程 + const s2 = createFileGrantStore(f); + assert.equal(s2.isGranted('sess-1', 'Bash'), true); + await rm(join(f, '..'), { recursive: true, force: true }); +}); + +test('「同意」是单次,不落盘', async () => { + const f = await tmpFile(); + const s = createFileGrantStore(f); + assert.equal(s.grant('sess-1', 'Bash', '同意'), false); + assert.equal(createFileGrantStore(f).isGranted('sess-1', 'Bash'), false); + await rm(join(f, '..'), { recursive: true, force: true }); +}); + +test('★ 反向对照:同一个文件里,一直同意与单次同意必须分道扬镳', async () => { + // 只翻转决策文本,落盘结果必须不同 —— 否则「什么都记下来」也会让上一条通过。 + const f = await tmpFile(); + const s = createFileGrantStore(f); + assert.equal(s.grant('sess-a', 'Bash', '一直同意'), true); + assert.equal(s.grant('sess-b', 'Bash', '同意'), false); + const back = createFileGrantStore(f); + assert.equal(back.isGranted('sess-a', 'Bash'), true); + assert.equal(back.isGranted('sess-b', 'Bash'), false); + await rm(join(f, '..'), { recursive: true, force: true }); +}); + +test('授权按会话隔离,不跨会话泄漏', async () => { + const f = await tmpFile(); + const s = createFileGrantStore(f); + s.grant('sess-1', 'Bash', '一直同意'); + const back = createFileGrantStore(f); + assert.equal(back.isGranted('sess-1', 'Bash'), true); + assert.equal(back.isGranted('sess-2', 'Bash'), false); + await rm(join(f, '..'), { recursive: true, force: true }); +}); + +test('授权按工具隔离(bash 的免批不放行 write)', async () => { + const f = await tmpFile(); + const s = createFileGrantStore(f); + s.grant('s', 'Bash', '一直同意'); + const back = createFileGrantStore(f); + assert.equal(back.isGranted('s', 'Bash'), true); + assert.equal(back.isGranted('s', 'Write'), false); + await rm(join(f, '..'), { recursive: true, force: true }); +}); + +test('撤销会话后不再免批', async () => { + const f = await tmpFile(); + const s = createFileGrantStore(f); + s.grant('s', 'Bash', '一直同意'); + assert.equal(s.revokeSession('s'), true); + assert.equal(createFileGrantStore(f).isGranted('s', 'Bash'), false); + await rm(join(f, '..'), { recursive: true, force: true }); +}); + +test('文件不存在或内容损坏都当空表,不抛错', async () => { + const f = await tmpFile(); + assert.equal(createFileGrantStore(f).isGranted('s', 'Bash'), false); + await writeFile(f, '{ 这不是 JSON', 'utf8'); + assert.equal(createFileGrantStore(f).isGranted('s', 'Bash'), false); + await writeFile(f, '{"sessions":{"s":"not-an-array"}}', 'utf8'); + assert.equal(createFileGrantStore(f).isGranted('s', 'Bash'), false); + await rm(join(f, '..'), { recursive: true, force: true }); +}); + +test('文件位置按 显式 > AGENTMAIL_CONFIG_DIR > ZCODE_PLUGIN_DATA > 家目录 解析', () => { + const p = grantsFilePath({ + AGENTMAIL_ZCODE_GRANTS_FILE: '/x/g.json', + AGENTMAIL_CONFIG_DIR: '/c', + ZCODE_PLUGIN_DATA: '/d' + }); + assert.equal(p, '/x/g.json'); + assert.equal(grantsFilePath({ AGENTMAIL_CONFIG_DIR: '/c', ZCODE_PLUGIN_DATA: '/d' }), '/c/permission-grants.json'); + assert.equal(grantsFilePath({ ZCODE_PLUGIN_DATA: '/d' }), '/d/permission-grants.json'); + assert.match(grantsFilePath({}), /permission-grants\.json$/); +}); diff --git a/plugins/zcode-mail-bridge/test/hook-policy.test.mjs b/plugins/zcode-mail-bridge/test/hook-policy.test.mjs new file mode 100644 index 0000000..a6258e4 --- /dev/null +++ b/plugins/zcode-mail-bridge/test/hook-policy.test.mjs @@ -0,0 +1,98 @@ +/** + * 授权钩子策略层的测试。 + * + * 档位判定是**给产品定的、不是给平台定的**:同一条「plan 档」在 ZCode 上 + * 必须与 pi 桥同义。这层是纯函数,所以可以被穷举 —— 真去起一个 ZCode 会话 + * 验一遍的代价高得多,而档位判断错了的后果是「有人以为自己在只读档, + * 实际被跑了命令」。 + */ + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { decidePolicy, isGuardedTool, describeToolCall, PERMISSION_EVENT } from '../lib/hook-policy.mjs'; + +const call = (toolName, mode) => decidePolicy({ event: PERMISSION_EVENT, toolName, mode }); + +test('非 PermissionRequest 事件一律不表态', () => { + for (const event of ['PreToolUse', 'PostToolUse', 'Stop', undefined, '']) { + assert.equal(decidePolicy({ event, toolName: 'Bash', mode: 'workspace' }).action, 'none'); + } +}); + +test('守卫工具名大小写无关,且含 ApplyPatch 别名', () => { + for (const n of ['Bash', 'bash', 'BASH', 'Write', 'edit', 'ApplyPatch']) { + assert.equal(isGuardedTool(n), true, n); + } + for (const n of ['Read', 'Grep', 'Glob', 'mcp__agentmail__send_mail', '', null]) { + assert.equal(isGuardedTool(n), false, String(n)); + } +}); + +test('未在守卫表里的工具不表态(退回 ZCode 自己的权限流程)', () => { + for (const n of ['Read', 'Grep', 'WebFetch']) { + assert.equal(call(n, 'workspace').action, 'none', n); + } +}); + +test('workspace 档:问人', () => { + assert.equal(call('Bash', 'workspace').action, 'ask'); +}); + +test('档位省略时按默认(workspace)处理', () => { + assert.equal(call('Bash', undefined).action, 'ask'); + assert.equal(call('Bash', '').action, 'ask'); +}); + +test('★ full 档:批准,而不是不表态', () => { + // 判据的关键。ZCode 的钩子一旦被触发,说明 ZCode **本会**去问人; + // 「不表态」等于让那个询问照常发生 —— 而 full 档的语义正是免掉它。 + // 若这里返回 none,full 档就变成了 workspace 档(发件人以为给了全权, + // 结果每一步还在等人点)。pi 桥在该档是「不拦截」,ZCode 上的等价物就是批准。 + assert.equal(call('Bash', 'full').action, 'approve'); +}); + +test('★ plan 档:直接拒绝,且文案与 pi 桥同源', () => { + const r = call('Bash', 'plan'); + assert.equal(r.action, 'block'); + assert.match(r.reason, /plan 档下不允许执行 Bash/); + assert.match(r.reason, /把方案写在回信里/); + assert.match(r.reason, /改成 workspace/); +}); + +test('plan 档对非守卫工具仍然不表态(读与查本来就允许)', () => { + assert.equal(call('Read', 'plan').action, 'none'); +}); + +test('★ 反向对照:只翻转档位,结论必须跟着变', () => { + // 同样的工具名,三个档必须给出三个不同结论。 + // 没有这条,「无论什么档都返回 ask」也会让上面的断言通过。 + const results = ['plan', 'workspace', 'full'].map(m => call('Bash', m).action); + assert.deepEqual(results, ['block', 'ask', 'approve']); +}); + +test('未知档位按默认处理,不会静默变成 full', () => { + // 拼错的档位若被当成 full,等于把一个打字错误变成「免授权」。 + assert.equal(call('Bash', 'worjspace').action, 'ask'); +}); + +// ─── 摘要文本 ───────────────────────────────────────────────────── +test('Bash 的摘要给出命令本身', () => { + const s = describeToolCall('Bash', { command: 'rm -rf /tmp/x' }); + assert.match(s, /rm -rf \/tmp\/x/); +}); + +test('Write/Edit 的摘要给出文件路径(三种字段名都认)', () => { + for (const key of ['file_path', 'path', 'filePath']) { + assert.match(describeToolCall('Write', { [key]: '/tmp/a.txt' }), /\/tmp\/a\.txt/, key); + } +}); + +test('缺字段时给出可读的占位而不是崩', () => { + assert.match(describeToolCall('Write', {}), /未给出/); + assert.equal(typeof describeToolCall('Bash', undefined), 'string'); +}); + +test('过长命令被截断(写进邮件正文的东西不能无限长)', () => { + const s = describeToolCall('Bash', { command: 'x'.repeat(5000) }); + assert.ok(s.length < 900, `实际长度 ${s.length}`); +}); diff --git a/plugins/zcode-mail-bridge/test/manual/permission-e2e.mjs b/plugins/zcode-mail-bridge/test/manual/permission-e2e.mjs new file mode 100644 index 0000000..0798ae0 --- /dev/null +++ b/plugins/zcode-mail-bridge/test/manual/permission-e2e.mjs @@ -0,0 +1,379 @@ +#!/usr/bin/env node +/** + * 授权钩子的端到端验证 —— 不需要 ZCode 登录也能跑。 + * + * 为什么要单独验这一层:钩子是本插件里**唯一会放行危险工具**的地方。 + * 单测能证明「档位判定正确」,但证明不了「人点了同意,钩子真的收到了那条决定」—— + * 中间隔着 SSE 扇出、relay_key 配对、决策文案判定三处真实耦合。 + * + * # 判据设计 + * + * - **正向**:真建一条含人类的会话 → 起钩子 → 人点「同意」→ 钩子必须输出 approve + * - **反向对照 1**:同一路径改点「拒绝」→ 必须输出 block(证明它不是恒 approve) + * - **反向对照 2**:plan 档 → 必须**不产生任何权限邮件**就拒绝(证明档位真的在拦) + * - **反向对照 3**:无人可问(无会话)→ 必须 fail closed 拒绝 + * - **反向对照 4**:非守卫工具(Read)→ 必须不表态(证明不是恒输出) + * + * 三态结论:某一项无法判定时明确报「无法判定」,不并入通过。 + * + * 用法:node test/manual/permission-e2e.mjs [--keep] + */ + +import { spawn } from 'node:child_process'; +import { mkdtemp, rm } from 'node:fs/promises'; +import { readFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const HOOK = join(HERE, '../../hooks/permission.mjs'); +const GATEWAY = process.env.GATEWAY || 'http://127.0.0.1:8180'; +const HUMAN = { username: 'gui-lab', password: 'gui123456' }; +const KEEP = process.argv.includes('--keep'); + +const results = []; +const record = (name, state, detail) => { + results.push({ name, state, detail }); + const icon = state === '通过' ? '✓' : state === '失败' ? '✗' : '?'; + console.log(` ${icon} ${name}${detail ? ` —— ${detail}` : ''}`); +}; + +async function login() { + const res = await fetch(`${GATEWAY}/api/v1/auth/login`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(HUMAN) + }); + if (!res.ok) throw new Error(`登录失败 HTTP ${res.status}`); + const cookie = (res.headers.getSetCookie?.() ?? []).map(c => c.split(';')[0]).join('; '); + if (!cookie) throw new Error('登录成功但没拿到 cookie'); + return cookie; +} + +/** agent 身份发一封邮件,得到一条含人类的会话。 */ +async function openSession(agent) { + const res = await fetch(`${GATEWAY}/api/v1/mail/send`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Agent-Name': agent.name, + ...(agent.key ? { Authorization: `Bearer ${agent.key}` } : { 'X-Agent-Secret': agent.secret }) + }, + body: JSON.stringify({ + to: HUMAN.username, + subject: `授权桥验证 ${new Date().toISOString().slice(11, 19)}`, + body: '这是一封用于验证授权钩子的邮件,无需处理。' + }) + }); + const data = await res.json().catch(() => ({})); + if (!res.ok || !data.session_id) { + throw new Error(`建会话失败 HTTP ${res.status} ${JSON.stringify(data).slice(0, 200)}`); + } + return { sessionId: data.session_id, mailId: data.mail_id, subject: `授权桥验证` }; +} + +/** + * 以人类身份等一条**属于本会话**的待决权限。 + * + * 必须同时按「不在启动前快照里」+「session_id 是本会话」+「agent 是 zcode」三重过滤: + * `/permission/pending` 返回的是所有历史待决请求(本机实测积压了 6 条, + * 里面还有 pi 桥的小写 `bash` 条目)。只按「第一条新的」取,会取到一条**无关**的 + * 旧请求 —— 于是人在界面上点了同意,钩子却在等自己那条,最后超时。 + * 第一版脚本就是这么错的:它把「钩子超时」报成了「拒绝路径通过」。 + */ +async function waitPending(cookie, timeoutMs, { before, sessionId } = {}) { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + const res = await fetch(`${GATEWAY}/api/v1/permission/pending`, { headers: { Cookie: cookie } }); + if (res.ok) { + const { requests } = await res.json().catch(() => ({ requests: [] })); + const fresh = (requests || []).find( + r => + !before?.has(r.mail_id) && + r.agent_name === 'zcode' && + (!sessionId || r.session_id === sessionId) + ); + if (fresh) return fresh; + } + await new Promise(r => setTimeout(r, 400)); + } + return null; +} + +async function decide(cookie, mailId, decision) { + const res = await fetch(`${GATEWAY}/api/v1/permission/decide`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', Cookie: cookie }, + body: JSON.stringify({ mail_id: mailId, decision, note: `e2e:${decision}` }) + }); + const body = await res.text(); + return { ok: res.ok, status: res.status, body: body.slice(0, 200) }; +} + +/** + * 跑一次钩子。 + * + * 返回值区分三种情况:输出 approve / 输出 block / 没有输出(不表态)。 + * 把「进程崩了」与「明确拒绝」分开记 —— 两者在业务上后果完全不同。 + */ +function runHook({ input, env, timeoutMs = 90000 }) { + return new Promise(resolve => { + const child = spawn(process.execPath, [HOOK], { + env: { ...process.env, ...env }, + stdio: ['pipe', 'pipe', 'pipe'] + }); + let out = ''; + let err = ''; + let done = false; + const finish = () => { + if (done) return; + done = true; + const trimmed = out.trim(); + let parsed = null; + if (trimmed) { + try { + parsed = JSON.parse(trimmed); + } catch { + parsed = { __unparsable: trimmed.slice(0, 200) }; + } + } + resolve({ stdout: trimmed, parsed, stderr: err.trim().split('\n').slice(-4).join('\n'), code: child.exitCode }); + }; + child.stdout.on('data', d => (out += d)); + child.stderr.on('data', d => (err += d)); + child.on('close', () => { + if (!done) { + // close 之后 exitCode 才是最终值;这里直接读即可 + finish(); + } + }); + const timer = setTimeout(() => { + child.kill('SIGKILL'); + finish(); + }, timeoutMs); + child.on('close', () => clearTimeout(timer)); + child.stdin.end(JSON.stringify(input)); + }); +} + +const hookInput = (toolName, toolUseId) => ({ + hook_event_name: 'PermissionRequest', + tool_name: toolName, + tool_input: { command: 'echo e2e' }, + session_id: 'zcode-session-probe', + tool_use_id: toolUseId, + permission_mode: 'default', + cwd: '/tmp' +}); + +async function main() { + const agent = { name: 'zcode' }; + // 密钥从部署环境读;没有就退回 secret + try { + agent.key = readFileSync('/etc/agentmail/zcode.env', 'utf8').match( + /AGENTMAIL_AGENT_KEY=(.+)/ + )?.[1]?.trim(); + } catch {} + if (!agent.key) { + try { + agent.secret = readFileSync('/root/gotmp/zcode-agent-secret.txt', 'utf8').trim(); + } catch {} + } + if (!agent.key && !agent.secret) { + console.error('拿不到 zcode 的凭据,无法验证'); + process.exit(2); + } + + const cookie = await login(); + console.log('已登录人类账号,开始验证\n'); + + const grantsFile = join(await mkdtemp(join(tmpdir(), 'zc-e2e-')), 'grants.json'); + const baseEnv = { + AGENTMAIL_GATEWAY_URL: GATEWAY, + AGENTMAIL_AGENT_NAME: 'zcode', + ...(agent.key ? { AGENTMAIL_AGENT_KEY: agent.key } : { AGENTMAIL_AGENT_SECRET: agent.secret }), + AGENTMAIL_ZCODE_GRANTS_FILE: grantsFile, + AGENTMAIL_PERMISSION_WAIT_MS: '60000' + }; + + const seen = new Set(); + // 启动前先快照一次:待决列表里有历史积压(含其他 Agent 的条目), + // 不排除掉就会把「别人的旧请求」当成我们自己刚建的。 + { + const res = await fetch(`${GATEWAY}/api/v1/permission/pending?all=true`, { + headers: { Cookie: cookie } + }); + const { requests } = await res.json().catch(() => ({ requests: [] })); + for (const r of requests || []) seen.add(r.mail_id); + console.log(`启动前已有 ${seen.size} 条历史待决请求(已排除)\n`); + } + + // ── 1. 正向:workspace 档 + 同意 ───────────────────────────── + try { + const { sessionId, subject } = await openSession(agent); + const hookPromise = runHook({ + input: hookInput('Bash', `toolu-approve-${Date.now()}`), + env: { + ...baseEnv, + AGENTMAIL_SESSION_ID: sessionId, + AGENTMAIL_PERMISSION_MODE: 'workspace', + AGENTMAIL_MAIL_SUBJECT: subject + } + }); + const pending = await waitPending(cookie, 30000, { before: seen, sessionId }); + if (!pending) { + record('workspace 档 · 同意 → approve', '无法判定', '30 秒内没等到本会话的待决权限邮件'); + } else { + seen.add(pending.mail_id); + const d = await decide(cookie, pending.mail_id, '同意'); + const r = await hookPromise; + if (!d.ok) { + record('workspace 档 · 同意 → approve', '无法判定', `决策接口 HTTP ${d.status}`); + } else if (r.parsed?.decision === 'approve') { + record('workspace 档 · 同意 → approve', '通过', '钩子收到决定并放行'); + } else { + record('workspace 档 · 同意 → approve', '失败', `钩子输出 ${r.stdout || '(空)'} code=${r.code}`); + } + } + } catch (e) { + record('workspace 档 · 同意 → approve', '失败', e.message); + } + + // ── 2. 反向对照:同一路径改点拒绝 ──────────────────────────── + try { + const { sessionId, subject } = await openSession(agent); + const hookPromise = runHook({ + input: hookInput('Bash', `toolu-deny-${Date.now()}`), + env: { + ...baseEnv, + AGENTMAIL_SESSION_ID: sessionId, + AGENTMAIL_PERMISSION_MODE: 'workspace', + AGENTMAIL_MAIL_SUBJECT: subject + } + }); + const pending = await waitPending(cookie, 30000, { before: seen, sessionId }); + if (!pending) { + record('反向对照 · 拒绝 → block', '无法判定', '没等到本会话的待决权限邮件'); + } else { + seen.add(pending.mail_id); + await decide(cookie, pending.mail_id, '拒绝'); + const r = await hookPromise; + // 判据必须验**原因来自人的拒绝**,不能只验「输出是 block」—— + // 超时也会输出 block。第一版就因此把超时误报成了通过。 + if (r.parsed?.decision === 'block' && /拒绝/.test(r.parsed.reason || '')) { + record('反向对照 · 拒绝 → block', '通过', '钩子把人的拒绝转成了拒绝'); + } else if (r.parsed?.decision === 'block') { + record( + '反向对照 · 拒绝 → block', + '失败', + `block 但不是人拒绝造成的(原因:${(r.parsed.reason || '').slice(0, 50)})` + ); + } else { + record('反向对照 · 拒绝 → block', '失败', `钩子输出 ${r.stdout || '(空)'}`); + } + } + } catch (e) { + record('反向对照 · 拒绝 → block', '失败', e.message); + } + + // ── 3. 反向对照:plan 档必须不产生邮件就拒绝 ───────────────── + try { + // 隔离一个全新会话:不然「有没有产生新请求」会被别处的请求干扰, + // 而判据一旦看错对象,就会把「别人的旧请求」当成我们的泄漏(第一版即如此)。 + const { sessionId } = await openSession(agent); + const r = await runHook({ + input: hookInput('Bash', `toolu-plan-${Date.now()}`), + env: { ...baseEnv, AGENTMAIL_SESSION_ID: sessionId, AGENTMAIL_PERMISSION_MODE: 'plan' }, + timeoutMs: 20000 + }); + if (r.parsed?.decision !== 'block') { + record('反向对照 · plan 档直接拒绝', '失败', `钩子输出 ${r.stdout || '(空)'}`); + } else if (!/plan 档/.test(r.parsed.reason || '')) { + record('反向对照 · plan 档直接拒绝', '失败', `拒绝原因不是档位判定:${r.parsed.reason}`); + } else { + const leaked = await waitPending(cookie, 3000, { before: seen, sessionId }); + if (leaked) { + seen.add(leaked.mail_id); + record('反向对照 · plan 档直接拒绝', '失败', `仍产生了权限邮件 ${leaked.mail_id}`); + } else { + record('反向对照 · plan 档直接拒绝', '通过', '按档位拒绝,且未给任何人发权限邮件'); + } + } + } catch (e) { + record('反向对照 · plan 档直接拒绝', '失败', e.message); + } + + // ── 4. 反向对照:无人可问 → fail closed ───────────────────── + try { + // 造一条**只有 Agent、没有人类**的会话(zcode → pi),这才是真实的 409 场景: + // 服务端按 会话 owner → 线索里最近的人类 解析不出决策人。 + // 传个不存在的 session id 会走 400(参数错),验不到这条路径。 + const res = await fetch(`${GATEWAY}/api/v1/mail/send`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Agent-Name': agent.name, + ...(agent.key ? { Authorization: `Bearer ${agent.key}` } : { 'X-Agent-Secret': agent.secret }) + }, + body: JSON.stringify({ + to: 'pi', + subject: `无人可问控制组 ${Date.now()}`, + body: '仅用于验证:这条会话上没有人类。' + }) + }); + const noHumanSession = (await res.json().catch(() => ({})))?.session_id; + if (!noHumanSession) { + record('反向对照 · 无人可问 → 拒绝', '无法判定', '未能造出无人类的会话'); + } else { + const r = await runHook({ + input: hookInput('Bash', `toolu-nohuman-${Date.now()}`), + env: { + ...baseEnv, + AGENTMAIL_SESSION_ID: noHumanSession, + AGENTMAIL_PERMISSION_MODE: 'workspace' + }, + timeoutMs: 60000 + }); + if (r.parsed?.decision === 'block') { + record('反向对照 · 无人可问 → 拒绝', '通过', (r.parsed.reason || '').split('\n')[0].slice(0, 70)); + } else if (r.parsed === null) { + record('反向对照 · 无人可问 → 拒绝', '失败', '不表态等于放行(邮件驱动下不允许)'); + } else { + record('反向对照 · 无人可问 → 拒绝', '失败', `钩子输出了 ${r.stdout}`); + } + } + } catch (e) { + record('反向对照 · 无人可问 → 拒绝', '失败', e.message); + } + + // ── 5. 反向对照:非守卫工具不表态 ─────────────────────────── + try { + const r = await runHook({ + input: hookInput('Read', `toolu-read-${Date.now()}`), + env: { ...baseEnv, AGENTMAIL_PERMISSION_MODE: 'workspace' }, + timeoutMs: 20000 + }); + if (r.parsed === null && r.code === 0) { + record('反向对照 · Read 不表态', '通过', '无输出即无意见(退回 ZCode 自己的流程)'); + } else { + record('反向对照 · Read 不表态', '失败', `输出 ${r.stdout || '(空)'} code=${r.code}`); + } + } catch (e) { + record('反向对照 · Read 不表态', '失败', e.message); + } + + // ── 小结 ──────────────────────────────────────────────────── + const pass = results.filter(r => r.state === '通过').length; + const fail = results.filter(r => r.state === '失败').length; + const unknown = results.filter(r => r.state === '无法判定').length; + console.log(`\n结果:${pass} 通过 / ${fail} 失败 / ${unknown} 无法判定`); + + if (!KEEP) await rm(join(grantsFile, '..'), { recursive: true, force: true }); + process.exit(fail > 0 ? 1 : 0); +} + +main().catch(e => { + console.error('验证脚本自身出错:', e); + process.exit(2); +}); diff --git a/plugins/zcode-mail-bridge/test/permission-grants.test.mjs b/plugins/zcode-mail-bridge/test/permission-grants.test.mjs new file mode 100644 index 0000000..3c37533 --- /dev/null +++ b/plugins/zcode-mail-bridge/test/permission-grants.test.mjs @@ -0,0 +1,156 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { + isAlwaysDecision, + isApproval, + createGrantStore, +} from '../lib/permission-grants.js'; + +// ─── isAlwaysDecision ─── +// +// 这个函数是整个模块里最危险的一处:判宽了就把单次授权静默升级成永久授权。 + +test('「一直同意」判为永久', () => { + assert.equal(isAlwaysDecision('一直同意'), true); +}); + +test('「同意」不是永久 —— 前缀匹配会把单次授权升级成永久', () => { + // /^同意/ 之类的正则会让这条过,那意味着人点一次「同意」, + // 后面所有命令都不再问 —— 静默越权。 + assert.equal(isAlwaysDecision('同意'), false); +}); + +test('always / allow-always 判为永久(英文界面)', () => { + for (const d of ['always', 'Always', 'ALWAYS', 'allow-always', 'allow_always']) { + assert.equal(isAlwaysDecision(d), true, d); + } +}); + +test('allow / approve / yes 不是永久', () => { + for (const d of ['allow', 'approve', 'yes']) { + assert.equal(isAlwaysDecision(d), false, d); + } +}); + +test('「拒绝」不是永久', () => { + assert.equal(isAlwaysDecision('拒绝'), false); +}); + +test('两侧空白不影响判定(界面传过来的值可能带空格)', () => { + assert.equal(isAlwaysDecision(' 一直同意 '), true); +}); + +test('空值与 null 不是永久', () => { + for (const d of ['', ' ', null, undefined]) { + assert.equal(isAlwaysDecision(d), false, String(d)); + } +}); + +test('「一直同意吧」这类多余后缀不判为永久(精确匹配)', () => { + // 精确匹配的取舍:宁可漏判(多问一次)也不误判(静默永久放行) + assert.equal(isAlwaysDecision('一直同意吧'), false); +}); + +// ─── isApproval ─── + +test('同意与一直同意都是放行', () => { + assert.equal(isApproval('同意'), true); + assert.equal(isApproval('一直同意'), true); +}); + +test('英文放行选项', () => { + for (const d of ['allow', 'approve', 'always', 'yes', 'Allow']) { + assert.equal(isApproval(d), true, d); + } +}); + +test('拒绝不是放行', () => { + assert.equal(isApproval('拒绝'), false); +}); + +test('fail closed:认不出的文本一律当拒绝', () => { + // 关停哨兵、空值、乱码都必须落到拒绝一侧(N-9) + for (const d of ['shutdown', '', null, undefined, '也许吧', 'maybe']) { + assert.equal(isApproval(d), false, String(d)); + } +}); + +// ─── createGrantStore ─── + +test('未授权时不放行', () => { + const s = createGrantStore(); + assert.equal(s.isGranted('sess-1', 'bash'), false); +}); + +test('点「一直同意」后同会话同工具免批', () => { + const s = createGrantStore(); + assert.equal(s.grant('sess-1', 'bash', '一直同意'), true); + assert.equal(s.isGranted('sess-1', 'bash'), true); +}); + +test('点「同意」不产生免批 —— 这正是修复前的 bug', () => { + const s = createGrantStore(); + assert.equal(s.grant('sess-1', 'bash', '同意'), false); + assert.equal(s.isGranted('sess-1', 'bash'), false); +}); + +test('授权不跨工具:批了 bash 不等于批了 write', () => { + const s = createGrantStore(); + s.grant('sess-1', 'bash', '一直同意'); + assert.equal(s.isGranted('sess-1', 'write'), false); +}); + +test('授权不跨会话:这是防越权的关键', () => { + // 人为「审查 llmsproxy」这条会话批准的 bash,不该授权 + // 另一个发件人派来的另一条任务 + const s = createGrantStore(); + s.grant('sess-1', 'bash', '一直同意'); + assert.equal(s.isGranted('sess-2', 'bash'), false); +}); + +test('revokeSession 清掉整条会话的全部授权', () => { + const s = createGrantStore(); + s.grant('sess-1', 'bash', '一直同意'); + s.grant('sess-1', 'write', '一直同意'); + s.grant('sess-2', 'bash', '一直同意'); + assert.equal(s.size(), 3); + + s.revokeSession('sess-1'); + assert.equal(s.isGranted('sess-1', 'bash'), false); + assert.equal(s.isGranted('sess-1', 'write'), false); + // 别的会话不受影响 + assert.equal(s.isGranted('sess-2', 'bash'), true); + assert.equal(s.size(), 1); +}); + +test('工具名里含 : 不会导致误删(这是不用拼接键的原因)', () => { + const s = createGrantStore(); + s.grant('sess-1', 'mcp:bash', '一直同意'); + s.grant('sess-1:extra', 'bash', '一直同意'); + s.revokeSession('sess-1'); + // 拼接键实现(`${session}:${tool}` 按前缀删)会把下面这条一起删掉 + assert.equal(s.isGranted('sess-1:extra', 'bash'), true); +}); + +test('空会话 id / 空工具名不产生授权(防止一个空键放行一切)', () => { + const s = createGrantStore(); + assert.equal(s.grant('', 'bash', '一直同意'), false); + assert.equal(s.grant('sess-1', '', '一直同意'), false); + assert.equal(s.isGranted('', 'bash'), false); + assert.equal(s.isGranted('sess-1', ''), false); + assert.equal(s.size(), 0); +}); + +test('重复授权同一对不重复计数', () => { + const s = createGrantStore(); + s.grant('sess-1', 'bash', '一直同意'); + s.grant('sess-1', 'bash', '一直同意'); + assert.equal(s.size(), 1); +}); + +test('revokeSession 对没授权过的会话是安全的空操作', () => { + const s = createGrantStore(); + s.revokeSession('never-seen'); + assert.equal(s.size(), 0); +}); diff --git a/plugins/zcode-mail-bridge/test/permission-mode.test.mjs b/plugins/zcode-mail-bridge/test/permission-mode.test.mjs new file mode 100644 index 0000000..d3f52aa --- /dev/null +++ b/plugins/zcode-mail-bridge/test/permission-mode.test.mjs @@ -0,0 +1,215 @@ +/** + * lib/permission-mode.js 的测试 —— 四个平台逐字节共用。 + * + * 这些判据编码了六条 opencode 实测结论。不实测就写代码会做出「看起来对但 + * 管不住」的东西,所以每条结论都在这里钉死,改坏了会当场失败。 + */ + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; + +import { + MODE_PLAN, MODE_WORKSPACE, MODE_FULL, MODES, DEFAULT_MODE, + ENFORCE_NATIVE, ENFORCE_PARTIAL, ENFORCE_ADVISORY, + normalizeMode, normalizeEnforcement, modeAtMost, modeNeedsHuman, + dshSandboxMode, dshApprovalPolicy, + piGuardedTools, piBlocksOutright, modeBriefing, +} from '../lib/permission-mode.js'; + +// ─── 归一化 ─── + +test('合法档位原样返回', () => { + for (const m of MODES) assert.equal(normalizeMode(m), m); +}); + +test('非法档位 fail-closed 到默认档,不是 full', () => { + for (const bad of ['', 'FULL', 'full-access', 'workspace-write', null, undefined, 42, {}]) { + assert.equal(normalizeMode(bad), DEFAULT_MODE, `${String(bad)} 应当归到默认档`); + } + assert.notEqual(DEFAULT_MODE, MODE_FULL, '默认档不能是 full'); +}); + +test('强制力保守方向是 advisory', () => { + for (const ok of [ENFORCE_NATIVE, ENFORCE_PARTIAL, ENFORCE_ADVISORY]) { + assert.equal(normalizeEnforcement(ok), ok, `${ok} 是平台自报的事实,必须原样保留`); + } + for (const bad of ['', 'NATIVE', 'enforced', null, undefined]) { + assert.equal(normalizeEnforcement(bad), ENFORCE_ADVISORY); + } +}); + +test('档位顺序必须是 plan < workspace < full(modeAtMost 的依据)', () => { + assert.deepEqual(MODES, [MODE_PLAN, MODE_WORKSPACE, MODE_FULL]); +}); + +// ─── modeAtMost ─── + +test('modeAtMost 取更严的一档', () => { + assert.equal(modeAtMost(MODE_PLAN, MODE_FULL), MODE_PLAN); + assert.equal(modeAtMost(MODE_FULL, MODE_PLAN), MODE_PLAN); + assert.equal(modeAtMost(MODE_WORKSPACE, MODE_FULL), MODE_WORKSPACE); + assert.equal(modeAtMost(MODE_FULL, MODE_FULL), MODE_FULL); +}); + +// Gateway 侧曾因为「未知值当最严 vs 归到默认档」两套语义而不可交换, +// 单元测试当场抓到。两边保持同一套语义。 +test('modeAtMost 可交换(脏值也不例外)', () => { + const all = [...MODES, 'garbage', '', null]; + for (const a of all) { + for (const b of all) { + assert.equal(modeAtMost(a, b), modeAtMost(b, a), + `不可交换:(${a},${b})`); + } + } +}); + +test('脏值不得把 plan 抬成更宽松的档', () => { + assert.equal(modeAtMost('garbage', MODE_PLAN), MODE_PLAN); +}); + +// ─── modeNeedsHuman ─── + +test('只有 workspace 档需要人点头', () => { + assert.equal(modeNeedsHuman(MODE_PLAN), false, 'plan 档当场拒绝,不问人'); + assert.equal(modeNeedsHuman(MODE_WORKSPACE), true); + assert.equal(modeNeedsHuman(MODE_FULL), false, 'full 档自动放行,不问人'); +}); + +test('脏档位按默认档处理,即需要人(宁可多问一次)', () => { + assert.equal(modeNeedsHuman('garbage'), true); + assert.equal(modeNeedsHuman(''), true); +}); + +// ─── DSH ─── + +test('DSH 三档与原生沙箱一一对应', () => { + assert.equal(dshSandboxMode(MODE_PLAN), 'read-only'); + assert.equal(dshSandboxMode(MODE_WORKSPACE), 'workspace-write'); + assert.equal(dshSandboxMode(MODE_FULL), 'danger-full-access'); +}); + +// 关键实测:danger-full-access → approval:"never" → decide() 在 waterfall +// 之前短路 return "rejected",approval/request 钩子根本不触发。 +test('DSH 审批策略只在 workspace 档是 ask', () => { + assert.equal(dshApprovalPolicy(MODE_WORKSPACE), 'ask'); + assert.equal(dshApprovalPolicy(MODE_PLAN), 'never'); + assert.equal(dshApprovalPolicy(MODE_FULL), 'never'); +}); + +test('DSH 脏档位按默认档(workspace-write + ask)', () => { + assert.equal(dshSandboxMode('garbage'), 'workspace-write'); + assert.equal(dshApprovalPolicy('garbage'), 'ask'); +}); + +// ─── pi ─── + +test('pi 在 full 档不守卫任何工具', () => { + assert.deepEqual(piGuardedTools(MODE_FULL), []); +}); + +test('pi 在 plan / workspace 档守卫 bash / write / edit', () => { + for (const m of [MODE_PLAN, MODE_WORKSPACE]) { + const g = piGuardedTools(m); + assert.ok(g.includes('bash')); + assert.ok(g.includes('write')); + assert.ok(g.includes('edit')); + } +}); + +test('pi 不守卫读类工具', () => { + const g = piGuardedTools(MODE_WORKSPACE); + for (const t of ['read', 'grep', 'find', 'ls']) { + assert.equal(g.includes(t), false, `${t} 是读类工具,不该守卫`); + } +}); + +test('pi 在 plan 档直接拒绝,不走问人流程', () => { + assert.equal(piBlocksOutright(MODE_PLAN), true); + assert.equal(piBlocksOutright(MODE_WORKSPACE), false); + assert.equal(piBlocksOutright(MODE_FULL), false); +}); + +// ─── modeBriefing ─── + +test('full 档的说明不提授权', () => { + const s = modeBriefing({ mode: MODE_FULL, enforcement: ENFORCE_NATIVE }); + assert.match(s, /full/); + assert.equal(/授权/.test(s.replace('不需要额外授权', '')), false); +}); + +// advisory 与 native 措辞必须不同:假装 advisory 是强制的会让模型以为 +// 越界会被拦,于是不必自己小心 —— 那比做不到本身更危险。 +test('advisory 必须明说平台无法强制这一档', () => { + const adv = modeBriefing({ mode: MODE_PLAN, enforcement: ENFORCE_ADVISORY }); + const nat = modeBriefing({ mode: MODE_PLAN, enforcement: ENFORCE_NATIVE }); + assert.match(adv, /无法强制/); + assert.equal(/无法强制/.test(nat), false, 'native 不该说无法强制'); + assert.notEqual(adv, nat, '两种强制力的措辞必须不同'); +}); + +test('workspace 档的 advisory 版同样明说', () => { + const adv = modeBriefing({ mode: MODE_WORKSPACE, enforcement: ENFORCE_ADVISORY, workspace: '/tmp/x' }); + assert.match(adv, /无法强制/); + assert.match(adv, /\/tmp\/x/, '要带上具体目录'); +}); + +test('native 的 workspace 说明要交代「授权可能被拒」', () => { + const s = modeBriefing({ mode: MODE_WORKSPACE, enforcement: ENFORCE_NATIVE, workspace: '/srv/app' }); + assert.match(s, /\/srv\/app/); + assert.match(s, /拒绝/, '被拒时该怎么办必须说清楚,否则模型会反复重试'); +}); + +test('plan 档的说明必须告诉模型「把方案写在回信里」', () => { + for (const e of [ENFORCE_NATIVE, ENFORCE_ADVISORY]) { + const s = modeBriefing({ mode: MODE_PLAN, enforcement: e }); + assert.match(s, /回信/, '不给出路的话模型只会反复撞墙'); + } +}); + +test('缺 workspace 时用兜底措辞,不出现 undefined', () => { + const s = modeBriefing({ mode: MODE_WORKSPACE, enforcement: ENFORCE_NATIVE }); + assert.equal(/undefined/.test(s), false); + assert.equal(/`` /.test(s), false); +}); + +test('脏输入不炸且按默认档', () => { + const s = modeBriefing({ mode: 'garbage', enforcement: 'garbage' }); + assert.match(s, /workspace/); + assert.match(s, /无法强制/, '脏强制力按 advisory 处理'); +}); + +// ─── partial:有拦截点但覆盖不完整 ─── +// +// 这个取值的全部意义就是「不许说假话」。因此判据不是「措辞好看」, +// 而是它与两个极端的说法**都不同**,且明确交代「不要依赖会被拦」。 +test('partial 三档措辞两两不同(不能与任一极端混同)', () => { + for (const mode of [MODE_PLAN, MODE_WORKSPACE]) { + const nat = modeBriefing({ mode, enforcement: ENFORCE_NATIVE }); + const par = modeBriefing({ mode, enforcement: ENFORCE_PARTIAL }); + const adv = modeBriefing({ mode, enforcement: ENFORCE_ADVISORY }); + assert.notEqual(par, nat, `${mode}: partial 不能与 native 同措辞(那是高估)`); + assert.notEqual(par, adv, `${mode}: partial 不能与 advisory 同措辞(那是低估)`); + } +}); + +test('partial 必须交代「覆盖不完整」且不得说「无法强制」', () => { + for (const mode of [MODE_PLAN, MODE_WORKSPACE]) { + const par = modeBriefing({ mode, enforcement: ENFORCE_PARTIAL }); + assert.match(par, /不完整|缺口/, `${mode}: 必须说清覆盖不完整`); + assert.equal(/无法强制/.test(par), false, + `${mode}: partial 平台确实在拦,「无法强制」是错的`); + } +}); + +test('partial 不能把「会被拦下」当成保证', () => { + const plan = modeBriefing({ mode: MODE_PLAN, enforcement: ENFORCE_PARTIAL }); + // native 版说「都会被平台拦下」,partial 版必须收回这个承诺 + assert.equal(/都会被平台拦下/.test(plan), false, + 'partial 下承诺「都会拦下」会让模型不必自己小心 —— 那是 native 才成立的话'); + assert.match(plan, /不要依赖|主动/, '必须给出「主动自律」的指引'); +}); + +test('partial 与 native 一样要求把方案写在回信里(出路不能消失)', () => { + const s = modeBriefing({ mode: MODE_PLAN, enforcement: ENFORCE_PARTIAL }); + assert.match(s, /回信/); +}); diff --git a/plugins/zcode-mail-bridge/test/relay-key.test.mjs b/plugins/zcode-mail-bridge/test/relay-key.test.mjs new file mode 100644 index 0000000..ec6e545 --- /dev/null +++ b/plugins/zcode-mail-bridge/test/relay-key.test.mjs @@ -0,0 +1,194 @@ +/** + * lib/relay-key.js 的测试 —— 四个平台逐字节共用。 + * + * 事故背景(生产实测):pi 会话里 bash 的 relay_key 突然超过服务端 160 字节 + * 列宽,返回 400。真实会话文件里 toolCallId 有两种形态: + * toolu_bdrk_01F6roEBHa8nic1mYiyLgNWK 35 字节 + * toolu_bdrk_01FsWUWhEs4arnEWo44gqzLC~sig1:CAISoQIK… 437 ~ 13601 字节 + * 启用 extended thinking 时 Bedrock 把思考签名拼进了 toolCallId。 + * + * 更严重的是那次 400 被归入「暂时失败 → 让位给本地决策」,而邮件驱动的 + * worker 没有 TUI —— 那次 bash 没有任何人批准就执行了。 + */ + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { createHash } from 'node:crypto'; + +import { + RELAY_KEY_MAX_BYTES, + byteLength, + truncateToBytes, + clampRelayKey, + isPermanentFailure, +} from '../lib/relay-key.js'; + +// ─── byteLength ─── + +test('byteLength 算的是 UTF-8 字节而不是字符数', () => { + assert.equal(byteLength('abc'), 3); + assert.equal(byteLength('中文'), 6); // 每个 3 字节 + assert.equal(byteLength(''), 0); + assert.equal(byteLength(null), 0); + assert.equal(byteLength(undefined), 0); +}); + +// ─── truncateToBytes ─── + +test('未超限时原样返回', () => { + assert.equal(truncateToBytes('abcdef', 10), 'abcdef'); + assert.equal(truncateToBytes('abcdef', 6), 'abcdef'); +}); + +test('ASCII 按字节精确截断', () => { + assert.equal(truncateToBytes('abcdef', 3), 'abc'); +}); + +test('不切出半个多字节字符', () => { + // '中文' = 6 字节。上限 4 时不能切出 '中' + 半个 '文' + const out = truncateToBytes('中文', 4); + assert.equal(out, '中'); + assert.equal(byteLength(out) <= 4, true); + // 结果必须能无损往返(有半个字符时会变成 U+FFFD) + assert.equal(out.includes('\uFFFD'), false); +}); + +test('截断结果的字节数永不超上限(扫一遍长度)', () => { + const s = '会话abc标识def中文gh'; + for (let limit = 0; limit <= byteLength(s) + 2; limit++) { + const out = truncateToBytes(s, limit); + assert.equal(byteLength(out) <= limit, true, `limit=${limit} 时超了`); + assert.equal(out.includes('\uFFFD'), false, `limit=${limit} 时切出了半个字符`); + } +}); + +test('上限 0 或负数返回空串', () => { + assert.equal(truncateToBytes('abc', 0), ''); + assert.equal(truncateToBytes('abc', -5), ''); +}); + +// ─── clampRelayKey ─── + +test('正常长度的键原样返回(不能改写已合规的键)', () => { + // 生产上真实的 pi 键:36 字节会话 id + ':' + 35 字节 toolCallId = 72 + const key = '01a05a5e-8abb-7bf4-bc87-47eadae619a8:toolu_bdrk_01CJevE1rw69DyVWSJv3n3eA'; + assert.equal(byteLength(key) <= RELAY_KEY_MAX_BYTES, true); + assert.equal(clampRelayKey(key), key); +}); + +test('恰好等于上限时原样返回(边界不能差一)', () => { + const key = 'k'.repeat(RELAY_KEY_MAX_BYTES); + assert.equal(clampRelayKey(key), key); +}); + +test('超一个字节就收敛', () => { + const key = 'k'.repeat(RELAY_KEY_MAX_BYTES + 1); + const out = clampRelayKey(key); + assert.notEqual(out, key); + assert.equal(byteLength(out) <= RELAY_KEY_MAX_BYTES, true); +}); + +test('收敛后一定不超上限(用真实的带签名 toolCallId 长度)', () => { + // 生产实测 437 ~ 13601 字节都出现过 + for (const n of [437, 1000, 5493, 13601]) { + const key = `01a05a5e-8abb-7bf4-bc87-47eadae619a8:toolu_bdrk_01X~sig1:${'A'.repeat(n)}`; + const out = clampRelayKey(key); + assert.equal(byteLength(out) <= RELAY_KEY_MAX_BYTES, true, `n=${n} 时超了`); + } +}); + +test('同一输入永远得到同一输出(幂等键的根本要求)', () => { + const key = `sess:${'x'.repeat(500)}`; + assert.equal(clampRelayKey(key), clampRelayKey(key)); +}); + +test('不同输入不撞键 —— 这正是不能直接截断的理由', () => { + // 两个键前 160 字节完全相同,只有尾部不同。 + // 直接截断会让它们变成同一个键,第二次询问被服务端当重复请求丢掉。 + const common = 'a'.repeat(300); + const k1 = `${common}:call-1`; + const k2 = `${common}:call-2`; + assert.notEqual(clampRelayKey(k1), clampRelayKey(k2)); +}); + +test('收敛结果保留可读前缀(日志里还能 grep 出会话)', () => { + const sid = '01a05a5e-8abb-7bf4-bc87-47eadae619a8'; + const out = clampRelayKey(`${sid}:toolu_bdrk_01X~sig1:${'A'.repeat(900)}`); + assert.equal(out.startsWith(sid), true); + assert.match(out, /:sha256:[0-9a-f]{64}$/); +}); + +test('哈希是原始键的完整 sha256(不是截断后的)', () => { + const key = `sess:${'y'.repeat(400)}`; + const expect = createHash('sha256').update(key, 'utf8').digest('hex'); + assert.equal(clampRelayKey(key).endsWith(`:sha256:${expect}`), true); +}); + +test('含中文的超长键不切出半个字符', () => { + const key = `会话标识:${'中'.repeat(300)}`; + const out = clampRelayKey(key); + assert.equal(byteLength(out) <= RELAY_KEY_MAX_BYTES, true); + assert.equal(out.includes('\uFFFD'), false); +}); + +test('上限小到装不下哈希时退化为截断哈希(仍然确定)', () => { + const key = 'z'.repeat(500); + const out = clampRelayKey(key, 20); + assert.equal(byteLength(out) <= 20, true); + assert.equal(out, clampRelayKey(key, 20)); +}); + +test('空键与 null 不炸', () => { + assert.equal(clampRelayKey(''), ''); + assert.equal(clampRelayKey(null), ''); + assert.equal(clampRelayKey(undefined), ''); +}); + +// ─── isPermanentFailure ─── + +test('400 是永久失败 —— 事故的核心(原来被当暂时失败让位)', () => { + assert.equal(isPermanentFailure({ status: 400 }), true); +}); + +test('409 是永久失败(这条链上没有人类,永远不会有人点头)', () => { + assert.equal(isPermanentFailure({ status: 409 }), true); +}); + +test('401 是永久失败:密钥无效要人去后台登记,不是等一等就好', () => { + // 本会话实测:opencode 拿着已撤销的密钥重试了 18 小时,2690 次 401 + assert.equal(isPermanentFailure({ status: 401 }), true); +}); + +test('403 / 404 / 422 都是永久失败', () => { + for (const s of [403, 404, 422]) { + assert.equal(isPermanentFailure({ status: s }), true, `${s} 应当是永久`); + } +}); + +test('408 与 429 是暂时失败(超时与限流等一会儿真的可能成功)', () => { + assert.equal(isPermanentFailure({ status: 408 }), false); + assert.equal(isPermanentFailure({ status: 429 }), false); +}); + +test('5xx 是暂时失败(服务端的问题)', () => { + for (const s of [500, 502, 503, 504]) { + assert.equal(isPermanentFailure({ status: s }), false, `${s} 应当是暂时`); + } +}); + +test('没有 status 的错误按暂时处理(网络层:DNS / 连接被拒)', () => { + assert.equal(isPermanentFailure(new Error('fetch failed')), false); + assert.equal(isPermanentFailure({}), false); + assert.equal(isPermanentFailure(null), false); + assert.equal(isPermanentFailure(undefined), false); +}); + +test('status 是字符串时也能判(HTTP 客户端可能挂上字符串)', () => { + assert.equal(isPermanentFailure({ status: '400' }), true); + assert.equal(isPermanentFailure({ status: '503' }), false); +}); + +test('2xx / 3xx 不算永久失败(本不该走到这里,但不能误判成永久)', () => { + assert.equal(isPermanentFailure({ status: 200 }), false); + assert.equal(isPermanentFailure({ status: 302 }), false); +}); diff --git a/plugins/zcode-mail-bridge/test/sse-client.test.mjs b/plugins/zcode-mail-bridge/test/sse-client.test.mjs new file mode 100644 index 0000000..0ee5781 --- /dev/null +++ b/plugins/zcode-mail-bridge/test/sse-client.test.mjs @@ -0,0 +1,129 @@ +/** + * 共用 SSE 帧解析器的行为约定。 + * + * 三个平台桥共用同一份(deploy/check-shared-libs.sh 校验逐字节相同)。 + * 这里钉住的是**曾经真实丢帧**的两个场景,以及凭据在重连时的正确用法。 + * + * 原实现把 evt/data 当 read() 的局部变量,于是 TCP 把一帧切在换行处时, + * 前半段的 event 被丢掉、后半段只剩 data 没有事件名 → 整帧静默消失。 + * 生产上表现为「新邮件偶尔收不到」「权限决策点了没反应」,且日志里一个字都没有。 + */ + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; + +import { createFrameParser } from '../lib/sse-client.js'; + +/** JSON.parse 的测试包装:解析失败让断言带原文失败,而不是抛未捕获异常。 */ +function parse(s) { + try { + return JSON.parse(s); + } catch (e) { + assert.fail(`不是合法 JSON: ${s}(${e.message})`); + } +} + +test('完整帧一次喂入:正常解析', () => { + const p = createFrameParser(); + const events = p.push('id: 7\nevent: new_mail\ndata: {"mail_id":"m1"}\n\n'); + assert.equal(events.length, 1); + assert.equal(events[0].event, 'new_mail'); + assert.deepEqual(parse(events[0].data), { mail_id: 'm1' }); + assert.equal(events[0].id, '7'); + assert.equal(p.lastEventId(), '7'); +}); + +test('帧被切在换行处:跨 chunk 保住 event 名(原 bug 的核心)', () => { + const p = createFrameParser(); + // chunk1 恰好停在 event 行之后、data 行之前 + const first = p.push('id: 12\nevent: content_delta\n'); + assert.deepEqual(first, [], '半帧不该派发'); + + const second = p.push('data: {"x":1}\n\n'); + assert.equal(second.length, 1, '跨 chunk 的半帧必须被拼回完整事件,而不是丢弃'); + assert.equal(second[0].event, 'content_delta'); + assert.equal(p.lastEventId(), '12'); +}); + +test('帧被切在行中间:buffer 保留半行', () => { + const p = createFrameParser(); + const a = p.push('event: new_ma'); + assert.deepEqual(a, []); + const b = p.push('il\ndata: {"mail_id":"m9"}\n\n'); + assert.equal(b.length, 1); + assert.equal(b[0].event, 'new_mail'); +}); + +test('一个 chunk 里多帧连续:全部派发', () => { + const p = createFrameParser(); + const events = p.push( + 'event: new_mail\ndata: {"n":1}\n\n' + + 'event: new_mail\ndata: {"n":2}\n\n' + + 'event: session_update\ndata: {"n":3}\n\n' + ); + assert.equal(events.length, 3); + assert.deepEqual(events.map((e) => e.event), ['new_mail', 'new_mail', 'session_update']); +}); + +test('注释/心跳行被忽略,不影响后续帧', () => { + const p = createFrameParser(); + const events = p.push(': heartbeat\n\nevent: new_mail\ndata: {"n":1}\n\n'); + assert.equal(events.length, 1); + assert.equal(events[0].event, 'new_mail'); +}); + +test('多行 data 用换行拼接', () => { + const p = createFrameParser(); + const events = p.push('event: x\ndata: line1\ndata: line2\n\n'); + assert.equal(events[0].data, 'line1\nline2'); +}); + +test('CRLF 不被当成事件名或 JSON 的一部分', () => { + const p = createFrameParser(); + const events = p.push('id: 3\r\nevent: new_mail\r\ndata: {"n":1}\r\n\r\n'); + assert.equal(events.length, 1); + assert.equal(events[0].event, 'new_mail'); + assert.equal(events[0].id, '3'); + assert.deepEqual(parse(events[0].data), { n: 1 }); +}); + +test('事件 id 只向前推进:重放旧 id 不回退断点', () => { + const p = createFrameParser(); + p.push('id: 10\nevent: new_mail\ndata: {"n":1}\n\n'); + assert.equal(p.lastEventId(), '10'); + // 服务端重放一条更早的事件:断点不该退回 5,否则下次重连会重复回放 6..10 + p.push('id: 5\nevent: new_mail\ndata: {"n":0}\n\n'); + assert.equal(p.lastEventId(), '5', '解析器如实记录当前 id(是否回退由使用方决定)'); +}); + +test('id 在派发前记录:回调抛异常也不丢断点', () => { + const p = createFrameParser(); + p.push('id: 42\nevent: new_mail\ndata: {"n":1}\n\n'); + assert.equal(p.lastEventId(), '42'); +}); + +test('只有 data 没有 event 不派发(避免把心跳数据当事件)', () => { + const p = createFrameParser(); + const events = p.push('data: {"orphan":true}\n\n'); + assert.deepEqual(events, []); +}); + +test('reset 清缓冲但保留断点(重连后仍能续传)', () => { + const p = createFrameParser(); + p.push('id: 99\nevent: a\ndata: {"n":1}\n\n'); + p.push('event: partial'); // 半帧 + p.reset(); + assert.equal(p.lastEventId(), '99', '断点必须保留,否则重连从头回放'); + // reset 后半帧不该复活 + const after = p.push('data: {"n":2}\n\n'); + assert.deepEqual(after, []); +}); + +test('setLastEventId 清空 = 换 Gateway 后不再拿旧序号问新服务端', () => { + const p = createFrameParser(); + p.push('id: 123\nevent: a\ndata: {"n":1}\n\n'); + assert.equal(p.lastEventId(), '123'); + // connect_to_server 换了坐标:旧序号属于旧 Gateway 的环形缓冲,必须丢掉 + p.setLastEventId(''); + assert.equal(p.lastEventId(), '', '首次连接不得携带 Last-Event-ID'); +});