From d09ef395b4fe77c229fca1ee2482433b5baf36b1 Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Fri, 2 Oct 2026 14:14:06 +0800 Subject: [PATCH] =?UTF-8?q?refactor(zcode):=20=E5=88=A0=E6=8E=89=E6=9C=AC?= =?UTF-8?q?=E5=9C=B0=20MCP=20=E6=9C=8D=E5=8A=A1=E5=99=A8=E4=B8=8E=E6=95=B4?= =?UTF-8?q?=E5=A5=97=E6=89=A7=E8=A1=8C=E9=97=A8=E7=A6=81=20=E2=80=94?= =?UTF-8?q?=E2=80=94=20MCP=20=E5=B7=B2=E5=86=85=E7=BD=AE=E7=BD=91=E5=85=B3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 删了什么 **本地 MCP 服务器**(工具面已内置于网关 `POST /api/v1/mcp`,见 457d160): mcp/server.mjs stdio JSON-RPC 入口 lib/tools.mjs 11 个工具(手抄网关语义 —— 已抓到两次抄错) lib/mcp-rpc.mjs 手写协议层 test/{tools,mcp-rpc,generic-mcp}.test.mjs **执行门禁整条链**(用户裁定:直接移除): lib/action-tools.mjs run_command / write_file lib/approval.mjs 授权判定 lib/grants-file.mjs 「一直同意」跨进程持久化 lib/hook-policy.mjs 档位判定 hooks/permission.mjs PermissionRequest 钩子 hooks/hooks.json 钩子注册 test/{action-tools,approval,grants-file,hook-policy}.test.mjs test/manual/{gate-e2e.py,permission-e2e.mjs,gate-e2e-evidence.json} ## 为什么执行门禁可以整条删,而不是留着 `run_command` / `write_file` 的门禁是**双进程审批**设计:MCP 进程问人、 ZCode 钩子进程等回答、中间靠落盘授权表对齐。三样都依赖**本地 MCP 进程**。 进程没了之后: 没有任何代码装载 buildActionTools ← 实测确认(只剩测试在测它) 也就是说它已经是死代码,而死代码 + 它的判据会让人误以为「这个平台有执行面」。 留着比删掉更危险。 本平台现在的姿态是**失败关闭**:平台自带 32 项危险工具被禁用, AgentMail 侧不提供任何执行类工具 ⇒ 模型没有执行面。 ## detectModeEnforcement 重写 它原本有三条依据,现在只剩一条还成立: 1. ~~平台 PermissionRequest 钩子~~ —— 目录整个删了。**留着「钩子是否注册」 的判据只会说谎。** 2. ~~我们自己的门禁~~ —— 删了。 3. ✅ `--disallowed-tools` 禁用清单 —— 仍在,且现在是**唯一**那道。 报 `native` 的含义随之收窄为「该档位真的**没有执行面**」,而不是以前那个 「有人会来问」。降级路径(清单被清空 ⇒ advisory)仍有效,实测: 正常配置 → native | 平台自带危险工具已禁用 32 项…⇒ 模型无任何执行面 清单清空 → advisory | 禁用清单自检未通过…禁用清单就是唯一那道 ## 清单与 package.json `.zcode-plugin/plugin.json` 去掉 `mcpServers` 与 `hooks`(两者的目标都已不存在)。 `package.json` 去掉 `main` 与 `verify`(已无本地入口)。 ## 误删与自查 删 `test/permission-grants.test.mjs` 时**误删了一个仍在使用的共用库的测试** —— `lib/permission-grants.js` 四方同源,dsh / opencode / pi 都还在用。 `deploy/check-shared-libs.sh` 立刻报「共用测试缺失」把它抓出来,已恢复。 若没有那道检查,这会是一个静默的覆盖损失。 ## 验证 node --test 'test/*.test.mjs' 293/293 绿(原 352,删掉 59 格死代码判据) deploy/check-shared-libs.sh 四方同源 rc=0 detectModeEnforcement 实测 native / advisory 两条路径都对 ## 后续 本目录现在只剩**邮件驱动**(SSE 订阅 → 起一轮 → 回信)与共用库。 若将来要在 zcode 侧恢复执行能力,需要重新设计门禁 —— 现有形状不能复用, 因为它的双进程模型随本地 MCP 进程一起消失了。 --- .../.zcode-plugin/plugin.json | 16 +- plugins/zcode-mail-bridge/README.md | 208 +------- plugins/zcode-mail-bridge/hooks/hooks.json | 18 - .../zcode-mail-bridge/hooks/permission.mjs | 290 ----------- .../zcode-mail-bridge/lib/action-tools.mjs | 336 ------------ plugins/zcode-mail-bridge/lib/approval.mjs | 299 ----------- plugins/zcode-mail-bridge/lib/grants-file.mjs | 102 ---- plugins/zcode-mail-bridge/lib/hook-policy.mjs | 92 ---- plugins/zcode-mail-bridge/lib/mcp-rpc.mjs | 145 ------ plugins/zcode-mail-bridge/lib/tools.mjs | 487 ------------------ plugins/zcode-mail-bridge/mcp/server.mjs | 119 ----- plugins/zcode-mail-bridge/package.json | 6 +- plugins/zcode-mail-bridge/src/index.mjs | 77 ++- .../test/action-tools.test.mjs | 329 ------------ .../zcode-mail-bridge/test/approval.test.mjs | 354 ------------- .../test/generic-mcp.test.mjs | 169 ------ .../test/grants-file.test.mjs | 98 ---- .../test/hook-policy.test.mjs | 98 ---- .../test/inbox-session-scope.test.mjs | 32 -- .../zcode-mail-bridge/test/is-main.test.mjs | 8 +- .../zcode-mail-bridge/test/mcp-rpc.test.mjs | 212 -------- .../test/read-scope-wiring.test.mjs | 61 --- plugins/zcode-mail-bridge/test/tools.test.mjs | 244 --------- 23 files changed, 68 insertions(+), 3732 deletions(-) delete mode 100644 plugins/zcode-mail-bridge/hooks/hooks.json delete mode 100644 plugins/zcode-mail-bridge/hooks/permission.mjs delete mode 100644 plugins/zcode-mail-bridge/lib/action-tools.mjs delete mode 100644 plugins/zcode-mail-bridge/lib/approval.mjs delete mode 100644 plugins/zcode-mail-bridge/lib/grants-file.mjs delete mode 100644 plugins/zcode-mail-bridge/lib/hook-policy.mjs delete mode 100644 plugins/zcode-mail-bridge/lib/mcp-rpc.mjs delete mode 100644 plugins/zcode-mail-bridge/lib/tools.mjs delete mode 100644 plugins/zcode-mail-bridge/mcp/server.mjs delete mode 100644 plugins/zcode-mail-bridge/test/action-tools.test.mjs delete mode 100644 plugins/zcode-mail-bridge/test/approval.test.mjs delete mode 100644 plugins/zcode-mail-bridge/test/generic-mcp.test.mjs delete mode 100644 plugins/zcode-mail-bridge/test/grants-file.test.mjs delete mode 100644 plugins/zcode-mail-bridge/test/hook-policy.test.mjs delete mode 100644 plugins/zcode-mail-bridge/test/inbox-session-scope.test.mjs delete mode 100644 plugins/zcode-mail-bridge/test/mcp-rpc.test.mjs delete mode 100644 plugins/zcode-mail-bridge/test/read-scope-wiring.test.mjs delete mode 100644 plugins/zcode-mail-bridge/test/tools.test.mjs diff --git a/plugins/zcode-mail-bridge/.zcode-plugin/plugin.json b/plugins/zcode-mail-bridge/.zcode-plugin/plugin.json index 638ffbe..ed13a99 100644 --- a/plugins/zcode-mail-bridge/.zcode-plugin/plugin.json +++ b/plugins/zcode-mail-bridge/.zcode-plugin/plugin.json @@ -1,21 +1,9 @@ { "name": "agentmail", "version": "0.1.0", - "description": "AgentMail 邮件协作:让 ZCode 用邮件与其他 Agent/人收发任务、传附件、申请授权。", + "description": "AgentMail 的 ZCode 适配:邮件驱动(收到来信起一轮、回信、失败必回报)。MCP 工具面已内置于网关 POST /api/v1/mcp,本插件不再自带 MCP 服务器与授权钩子。", "author": { "name": "AgentMail" }, - "license": "AGPL-3.0-only", - "hooks": "hooks", - "mcpServers": { - "agentmail": { - "command": "node", - "args": ["${ZCODE_PLUGIN_ROOT}/mcp/server.mjs"], - "cwd": "${ZCODE_PROJECT_DIR}", - "env": { - "ZCODE_PLUGIN_ID": "agentmail" - }, - "timeoutMs": 600000 - } - } + "license": "AGPL-3.0-only" } diff --git a/plugins/zcode-mail-bridge/README.md b/plugins/zcode-mail-bridge/README.md index f444b23..4a6f88a 100644 --- a/plugins/zcode-mail-bridge/README.md +++ b/plugins/zcode-mail-bridge/README.md @@ -1,169 +1,43 @@ -# zcode-mail-bridge —— AgentMail 的通用 MCP 服务(+ ZCode 适配) +# zcode-mail-bridge —— AgentMail 的 ZCode 适配 -**`mcp/server.mjs` 是一个通用 MCP 服务器**:任何支持 MCP 的宿主挂上它就能收发邮件, -不需要为 AgentMail 写专用插件。 +让 [ZCode](https://zcode.z.ai) 作为 AgentMail 里的一个 Agent:**收到来信就起一轮, +把结论回信**。 -```bash -AGENTMAIL_GATEWAY_URL=http://127.0.0.1:8180 \ -AGENTMAIL_AGENT_NAME=my-agent \ -AGENTMAIL_AGENT_SECRET= \ -AGENTMAIL_MCP_PLATFORM=my-host \ # 可选,默认 mcp;用于服务端统计平台 -node mcp/server.mjs -``` - -宿主配置里写 `{"command":"node","args":["…/mcp/server.mjs"],"env":{…}}` 即可。 -协议是 stdio 上的换行分隔 JSON-RPC(`lib/mcp-rpc.mjs`,零依赖手写),暴露 11 个邮件 -工具。工具名、参数名与渲染文本与 pi / dsh / opencode 三桥一致。 - -## ZCode 适配(本目录其余部分) - -ZCode 用**插件**扩展能力(`.zcode-plugin/plugin.json` 声明 -`skills` / `commands` / `hooks` / `mcpServers`),所以适配它的正确形状是一个插件, -而不是又一个常驻桥进程。本目录同时还是那个插件(`src/index.mjs` 的邮件驱动、 -`hooks/` 的授权钩子都只服务 ZCode)。 +> **2026-10-02:MCP 工具面已内置于网关。** +> 原先本目录自带一个 MCP 服务器(`mcp/server.mjs`,stdio)+ 一套执行门禁 +> (`run_command` / `write_file` + PermissionRequest 钩子)。两者都已**删除**: +> +> - **MCP 工具面**现在由网关提供:`POST /api/v1/mcp`(Streamable HTTP,11 个工具)。 +> 工具直接包装网关自己的 handler,所以工作区收窄、会话收窄、配额、冷静期 +> 全部是同一份代码。接入端只需填一个 URL —— 不装插件、不起进程、不配环境变量。 +> - **执行门禁**随之删除。那套双进程审批(MCP 进程问人、ZCode 钩子进程等回答、 +> 落盘授权表对齐)依赖本地 MCP 进程;进程没了,它就成了无人装载的死代码。 +> 本平台现在**没有执行面**:平台自带的 32 项危险工具被禁用,AgentMail 侧 +> 不提供任何执行类工具 —— 这是**失败关闭**。 +> `detectModeEnforcement()` 自报的 `native` 含义也随之收窄为「模型无执行面」。 +> +> 详见网关侧 `server/internal/mcp/`。 ## 组成 ``` -.zcode-plugin/plugin.json ZCode 插件清单(MCP 服务器 + hooks 目录) -mcp/server.mjs ★ 通用 MCP 服务器(stdio,换行分隔 JSON-RPC) -hooks/permission.mjs PermissionRequest 钩子:把授权问给人、等决定、回结论 -hooks/hooks.json 钩子注册(matcher + 进程型钩子 + 超时) -src/index.mjs ★ 邮件驱动(仅 ZCode):收到来信 → 起一轮 ZCode → 回信 +.zcode-plugin/plugin.json 插件清单(仅元数据;不再声明 mcpServers / hooks) +src/index.mjs ★ 邮件驱动:SSE 订阅 → 去重 → 起一轮 → 按策略回信 src/zcode-run.mjs 跑一轮(headless CLI + stream-json 解析) src/prompt.mjs 由邮件构造提示词与回信文案 -src/turn-mode.mjs 档位 → `--mode` 映射(授权系统在不在的关键) -lib/mcp-rpc.mjs 协议层(纯函数,可穷举测试) -lib/tools.mjs 11 个 AgentMail 工具(与另三个桥同名同参) +src/turn-mode.mjs 档位 → `--mode` 映射 + 自带工具禁用清单 lib/gateway.mjs 网关 HTTP 客户端 -lib/hook-policy.mjs 档位判定(纯函数) -lib/grants-file.mjs 「一直同意」的跨进程持久化(仅 ZCode 钩子用) -lib/explicit-sends.mjs 模型自己发过信的记录(工具与驱动跨进程对齐) -lib/action-tools.mjs ⚠ 已不在 MCP 面内(见下方「为什么没有 run_command」) -lib/approval.mjs 授权判定共用库(ZCode 钩子路径) +lib/explicit-sends.mjs 模型自己发过信的记录(与工具跨进程对齐) lib/{addressing,inbox-format,bounded,discovery,attachment-ids, - permission-mode,relay-key,permission-grants,sse-client, - catchup,relay-dedup,relay-policy,workspace}.js + permission-mode,permission-grants,relay-key,sse-client, + catchup,relay-dedup,relay-policy,workspace,mail-session-id, + is-main}.js ← 与 pi/dsh/opencode 三桥**逐字节同源**(见下) -## 为什么没有 run_command / write_file +## 邮件驱动怎么工作 -`lib/action-tools.mjs` 里的两个「会动机器」工具曾挂在 MCP 面上。**2026-10-02 移除**: - -它们的门禁是为 ZCode headless 的**双进程审批**设计的(MCP 服务器进程问人、 -ZCode 钩子进程等回答、中间靠落盘授权表对齐)。一旦通用化、脱离那个宿主, -这套门禁的前提就不成立——挂在通用服务上等于提供一个没有审批的旁路。 - -现在 MCP 面只有邮件与附件能力。需要动机器的宿主请用**它自己的**工具 -(Claude Desktop / codex 等都有各自受管的执行能力),并由它们自己决定是否审批。 - -`lib/action-tools.mjs` / `lib/approval.mjs` / `lib/grants-file.mjs` 与 `hooks/` -源码仍在仓库里,供 ZCode 的**桌面模式**(人开着 ZCode 干活、平台自己问人) -继续使用;只是不再由通用 MCP 面装载。 - -`test/generic-mcp.test.mjs` 钉死了这一边界:挂回执行工具、改回硬编码平台、 -或让文案指回 ZCode 插件设置,都会让判据转红。 -test/ 单元测试(含继承的共用测试) -test/manual/permission-e2e.mjs 授权桥端到端(真去点同意/拒绝) -test/manual/driver-e2e.mjs 邮件驱动端到端(桩 CLI,真网关真邮件) -``` - -## 三条能力线(现行姿态:一条主路 + 一条桌面通路) - -### 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)执行门禁 —— 已从 MCP 面移除(2026-10-02) - -~~`lib/action-tools.mjs` + `lib/approval.mjs`~~ **不在通用 MCP 面内**。 -ZCode headless 下它曾是本平台真正的安全边界(禁用 32 项自带工具, -模型只能经 `run_command` / `write_file` 动手,两者逐次请示发件人)。 - -通用化后这套门禁**失效**:它依赖 ZCode 特有的双进程审批(MCP 进程问、 -钩子进程答、落盘表对齐)。挂在通用服务上 = 一个没有审批的旁路。 -所以移除,理由与取舍见上文「为什么没有 run_command / write_file」。 - -源码保留(桌面模式的 ZCode 仍走它),但 `mcp/server.mjs` 不再装载。 -`src/prompt.mjs` 的能力说明也已改成如实告知「本平台没有执行面」—— -否则模型会去找不存在的工具,整轮浪费在换名字重试上。 -历史档位语义(`plan` 直接拒绝 / `workspace` 请示 / `full` 直接执行) -见 `src/turn-mode.mjs` 与下文的「三道不可动摇的规矩」。 - -
-(历史,已不适用于 MCP 面)原执行门禁的三道规矩 - -1. **只有明确同意才放行** —— 判据是共用库的前缀白名单(`^同意|一直同意|allow|approve|always|yes`, - 四个桥共用同一份)。看不懂的文本、空串、`拒绝`、`deny`、平台自己的 `shutdown` 哨兵 - 全部当拒绝。判据是「在放行白名单里」,不是「不等于拒绝」。 -2. **永久失败当场拒绝** —— 409(本线索内没有可决策的人)/ 其它 4xx 不会因重试而改变; - 服务端的 `error` 与 `suggestion` 原样带回给模型,让它能改道而不是盲试。 -3. **暂时失败看有没有本地界面** —— 判据是**调用方传的会话 id**(单一事实来源, - 不再另读 `AGENTMAIL_SESSION_ID`,两个来源不一致时谁也说不清): - 邮件驱动(有会话、无界面)必须 fail closed;交互模式退回平台自己的流程。 - -**保护目录**(`PROTECTED_PREFIXES`):`/opt/agentmail/data`(网关数据库)、 -`/opt/agentmail/plugins`(我们自己的代码)、`/etc/systemd`、`/etc/agentmail`(含密钥)、 -`/root/.agentmail-zcode`、`/root/.ssh` —— 这些**无论谁批准都不写**,而且判定在门禁 -**之前**(不消耗人的注意力)。理由不是不信任人,而是防自我强化:邮件驱动的 Agent -可能被来信诱导去改网关数据库或自己的插件代码,改完下一轮就换了一套规则, -而人看到的是一封看起来合理的申请。这类改动应当是人工部署动作。 - -拒绝时**抛错**(MCP 层转成 `isError: true`)而不是返回一句「已处理」: -模型必须看见原因才有机会改道。opencode 上「工具失败但报成功」导致模型连试 6 次、 -最后放弃整个任务的教训。 - -
- -### 2b)授权桥(`PermissionRequest` 钩子)—— 交互(桌面)模式用 - -**注意:headless 驱动这条路上这个钩子不参与**(`--mode yolo` 下平台不做任何权限判定, -钩子根本不会触发)。它保留给「人自己开着 ZCode 干活」的场景,那时没有桥进程在跑, -钩子就是唯一的授权通道。 - -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 干活时并没有桥在跑)。 - -### 3)邮件驱动(`src/index.mjs`) +SSE 订阅 → 去重 → 解析工作目录与档位 → 跑一轮 → 按策略回信 → 心跳。 +一轮一次(同一目录并发跑两轮会互相踩,见「已知缺口」)。 收到来信就自动开工,不需要人先打开 ZCode: @@ -244,7 +118,7 @@ ZCode 的发现源之一是 `plugins.dirs`(配置里的「inline directories ``` - agentmail@inline [enabled] inline/inline: <插件目录> - skills: 0, commands: 0, hooks: 1, mcp: plugin:agentmail:agentmail + (旧输出曾含 hooks/mcp 计数;两者已随执行门禁与本地 MCP 一并移除) ``` ## 谁在维护"同源" @@ -266,38 +140,12 @@ node --test 'test/*.test.mjs' # 共用模块四方同源(含判据自检) bash ../../deploy/check-shared-libs.sh -# 授权桥端到端:真建会话 → 起钩子 → 以人类身份点同意/拒绝 → 验钩子结论 -node test/manual/permission-e2e.mjs # 邮件驱动端到端:桩 CLI 替掉 ZCode,真网关真邮件 node test/manual/driver-e2e.mjs -# 门禁端到端(真模型、真网关、真邮件):批了→真执行;拒了→真不执行 -# ★ 不要写成 `python3 gate-e2e.py | tee log` 再取 `$?` —— 那是 tee 的退出码(恒 0), -# 失败会被吃掉。实测踩过:脚本自己打印 5/6(有失败),后台任务却报 exit-code 0。 -bash -c 'set -o pipefail; python3 test/manual/gate-e2e.py' ``` -新增的两组测试把安全性质钉住(共 34 条): - -- `test/approval.test.mjs`:全部授权往返。重点在反向对照 —— -一句「拒绝/deny/空串/平台自己的 shutdown 哨兵都不放行」之外, -还验了「别人的决策不能拿来用(relay_key 配对)」(同一个 Agent 并行发起 -两个动作时,B 的同意不能放行 A)与「超时必须真的拒绝而不是静默放行」。 -- `test/action-tools.test.mjs`:每条拒绝场景都配一个**文件系统断言** —— -「抛错了」不等于「副作用没发生」,只看抛错发现不了「抛完错照样写文件」。 -保护目录还验了路径花招(`..`、`./`)绕不过去。 - -`driver-e2e.mjs` 用桩 CLI 把「除了模型之外」的每一环都真跑一遍:参数拼装 -(尤其是 `--mode`)、stream-json 解析、回信策略、跨进程去重、失败必回信。 -它需要 `gui-lab` 这个人类账号(去点界面/收信)与 zcode 的凭据。 - -`permission-e2e.mjs` 的判据设计:正向(同意→approve)之外还有四条反向对照 -(拒绝→block 且原因必须来自人的拒绝、plan 档拒绝且**不产生**任何权限邮件、 -无人可问→fail closed、非守卫工具→不表态)。它必须按「启动前快照差集 + -`session_id` + `agent_name`」三重过滤待决请求 —— 待决列表里有历史积压 -(实测 6 条,含其它 Agent 的),只取「第一条新的」会拿到一条无关请求, -于是人点了同意而钩子在等自己那条,最后超时。 ## 已知缺口与残余风险 diff --git a/plugins/zcode-mail-bridge/hooks/hooks.json b/plugins/zcode-mail-bridge/hooks/hooks.json deleted file mode 100644 index 5a956e9..0000000 --- a/plugins/zcode-mail-bridge/hooks/hooks.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "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 deleted file mode 100644 index f8e03e2..0000000 --- a/plugins/zcode-mail-bridge/hooks/permission.mjs +++ /dev/null @@ -1,290 +0,0 @@ -#!/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 有**两种**含义,正确反应相反(2026-09-14,与 dsh/pi 桥同一处缺陷): - * - * - 「这条链上没有人类」→ 当场拒绝(下面的理由); - * - 「**本档根本不该问**」→ 服务端在回包里带 `permission_mode`。若是 full 档, - * 这次询问本就不该发生(full 档工具调用无需审批),正确反应是**放行**。 - * - * 什么时候会走到第二种:补投路径漏传档位(`lib/catchup.js`,同日已修), - * 于是这一轮按 workspace 档拦下来问人,服务端按真实档位回 409 —— - * 旧代码把它当「无人可问」拒绝,让一条 full 档会话的工具调用**全被自己人拦死**。 - * - * **只认服务端明说的 full**:plan 档与"链上没有人类"照旧拒绝(猜宽了就是提权)。 - * 这个桥不写会话状态(没有 sandbox/approval 旋钮),所以只需修这一层; - * dsh 桥那边还要把权威档位**写回会话**,否则沙箱仍然是窄的。 - */ - if (Number(e?.status) === 409 && String(e?.body?.permission_mode || '') === 'full') { - log(`服务端判定本会话为 full 档,放行本次询问(${relayKey}):无需审批`); - return approve(); - } - - // 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/action-tools.mjs b/plugins/zcode-mail-bridge/lib/action-tools.mjs deleted file mode 100644 index 6941c94..0000000 --- a/plugins/zcode-mail-bridge/lib/action-tools.mjs +++ /dev/null @@ -1,336 +0,0 @@ -/** - * 我们自己的「会动机器」的工具 —— 每一次执行都要先过人的批准。 - * - * # 为什么要有这些工具 - * - * headless(邮件驱动)模式下,ZCode 平台的授权询问**没有客户端可以问**: - * 每个 MCP 工具的 `needsApproval` 在产物里是硬编码的 `true`,而引擎找不到 - * 审批客户端时直接判 deny(`permission.resolved: deny, "No permission client - * configured for X"`)。我们试过让平台自己问人(PermissionRequest 钩子), - * 在本版本(3.10.2 / CLI 0.16.5)**根本不可靠**:有时钩子压根不注册, - * 触发时也无条件在 ~5ms 内失败、命令从未被 spawn(用「钩子写 marker 文件」 - * 的副作用验证过)。 - * - * 所以换一条路:**让 ZCode 走 `--mode yolo`**(平台不再拦我们的工具), - * 同时用 `--disallowed-tools` 把它自带的危险工具(Bash/Write/Edit/js/…) - * 全部禁掉,只留只读的 Read/Glob/Grep。需要动手时,模型改用**我们这几个工具**, - * 而门禁就在我们自己的代码里 —— 这也是唯一能真正落地的地方: - * 我们能控制它的判据、日志与失败语义。 - * - * # 安全边界(必须诚实地说清楚) - * - * `yolo` 意味着**平台不再有任何权限判定**。安全完全来自两件事: - * - * 1. `--disallowed-tools` 清单是否完整(见 src/turn-mode.mjs 的 REVIEWED_DENYLIST)。 - * 它是一张黑名单,漏掉一个能动机器的工具就等于开一个洞。 - * 2. 本文件的门禁。默认拒绝;只有「明确同意」才放行。 - * - * # 规矩 - * - * - 只读的工具不需要批准(读信、查地址)。需要批准的是**会改变机器状态的**: - * 执行命令、写文件。 - * - 拒绝时**抛错**(MCP 层会把 `isError: true` 交给模型),而不是返回一句 - * 「已处理」。opencode 上「工具失败但报成功」导致模型连试 6 次、最后放弃 - * 整个任务的教训:失败必须让模型看见原因,它才有机会改道。 - * - 批准之前**不产生任何副作用**(不建文件、不建目录)。 - */ - -import { execFile } from 'node:child_process'; -import { mkdir, writeFile } from 'node:fs/promises'; -import { readFileSync } from 'node:fs'; -import { dirname, isAbsolute, resolve } from 'node:path'; -import { fileURLToPath } from 'node:url'; -import { requestApproval, tierOf, DEFAULT_WAIT_MS } from './approval.mjs'; - -/** 命令输出上限。整段回灌会挤掉模型真正需要的上下文(实测 1MB 的构建日志 - * 能把一轮对话直接顶爆),所以按字节截断并明确告知被截断了多少。 */ -const OUTPUT_LIMIT = 16000; - -/** - * 把一段文本包进 markdown 代码块。 - * - * 不能直接写在模板字符串里 —— 三个反引号会把模板字符串**提前结束**, - * 报出来的是「Invalid or unexpected token」而不是「你写错了引号」, - * 一眼看不出是这里。用拼接就没有这个陷阱。 - */ -const fenced = text => '```\n' + text + '\n```'; - -/** 单条命令的默认/最长超时。超时上限必须存在:没有它,一条 `sleep 1e9` - * 会让这一轮永远跑不完,而驱动的回合超时到了就杀进程 —— 人会看到 - * 「处理失败」,却不知道只是有个命令没停。 */ -const DEFAULT_CMD_TIMEOUT_MS = 120000; -const MAX_CMD_TIMEOUT_MS = 900000; - -/** - * 无论谁批准都不许写的路径。 - * - * 这不是不信任人,而是**防自我强化**:邮件驱动的 Agent 可能被来信诱导去改 - * 平台的网关数据库、systemd 单元或它自己的插件代码,改完下一轮就换了一套 - * 规则,而人类看到的是一封看起来合理的申请。这类改动应当是人工部署动作, - * 不该经授权流程走私进来。 - */ -const PROTECTED_PREFIXES = [ - '/opt/agentmail/data', // 网关数据库(邮件、授权、附件) - '/opt/agentmail/plugins', // 我们自己的插件代码 - '/etc/systemd', // 服务单元 - '/etc/agentmail', // 各桥的环境文件(含密钥) - '/root/.agentmail-zcode', // 本 Agent 的凭据与会话状态 - '/root/.ssh' -]; - -const str = (v, fallback = '') => (typeof v === 'string' ? v : fallback); - -function clamp(text, limit = OUTPUT_LIMIT) { - const s = String(text ?? ''); - if (s.length <= limit) return { text: s, truncated: 0 }; - // 留头部:报错通常出现在开头,而进度条/日志尾部噪音最多。 - return { text: s.slice(0, limit), truncated: s.length - limit }; -} - -function protectedHit(p) { - const abs = resolve(p); - return PROTECTED_PREFIXES.find(prefix => abs === prefix || abs.startsWith(`${prefix}/`)); -} - -/** - * 等人工决策的上限,必须**明显小于** MCP 调用的超时。 - * - * 这一条是实测出来的,而且它曾经以最难发现的方式失败:工具在等授权, - * 人在界面上还没来得及反应,**客户端**先把这次工具调用掐了(默认 30 秒)。 - * 模型拿到的是一句「调用超时」,于是它在回信里写「30 秒内未获批准」—— - * 看起来像人没理它,实际是**门禁的等待窗口被截断了**,而且**看起来完全正常**。 - * - * 所以这里不信任环境变量:它可能被配成一个比 MCP 超时还大的值。 - * 真正的上限是插件清单里 `mcpServers.agentmail.timeoutMs`(我们的工具就是它 - * 在调),而等待必须留出余量让门禁**自己**先 settle —— 被客户端杀掉时, - * 我们连一条「等超时了」的理由都发不出去。 - */ -const APPROVAL_MARGIN_MS = 30000; - -/** - * 从插件清单里读 MCP 服务器声明的 `timeoutMs`(本插件自己的清单)。 - * 读不到就返回 null —— 此时沿用环境变量,并在日志里说清楚没校到。 - */ -export function resolveMcpTimeoutMs(manifestPath) { - const file = - manifestPath || fileURLToPath(new URL('../.zcode-plugin/plugin.json', import.meta.url)); - try { - const cfg = JSON.parse(readFileSync(file, 'utf8')); - const t = cfg?.mcpServers?.agentmail?.timeoutMs; - return Number.isFinite(t) && t > 0 ? t : null; - } catch { - return null; - } -} - -/** - * 算出实际要等多久。 - * - * @returns {{waitMs:number, capped:boolean, mcpTimeoutMs:number|null}} - * `capped` 为真时调用方应当写一条日志 —— 被夹小意味着 - * 「人能用来批准的时间比配置里写的少」,这件事必须能被看见。 - */ -export function resolveWaitMs(env = process.env, mcpTimeoutMs = resolveMcpTimeoutMs()) { - const want = Number(env.AGENTMAIL_PERMISSION_WAIT_MS) || DEFAULT_WAIT_MS; - if (!mcpTimeoutMs) return { waitMs: want, capped: false, mcpTimeoutMs: null }; - const limit = mcpTimeoutMs - APPROVAL_MARGIN_MS; - if (limit <= 0 || want <= limit) return { waitMs: want, capped: false, mcpTimeoutMs }; - return { waitMs: limit, capped: true, mcpTimeoutMs }; -} - -/** - * 构建这些工具。 - * - * @param {object} opts - * @param {any} opts.client 网关客户端 - * @param {object} [opts.env] 环境(默认 process.env;测试可注入) - * @param {object} [opts.grants] 「一直同意」表(钩子与工具共用同一份落盘文件) - * @param {Function} [opts.log] - * @param {Function} [opts.createSSE] 仅测试用:注入假 SSE 以精确控制授权时序 - */ -export function buildActionTools({ client, env = process.env, grants = null, log = () => {}, createSSE }) { - const tier = tierOf(env); - const sessionId = str(env.AGENTMAIL_SESSION_ID).trim(); - const workspace = str(env.AGENTMAIL_WORKSPACE_ROOT) || str(env.ZCODE_PROJECT_DIR) || process.cwd(); - const wait = resolveWaitMs(env); - const waitMs = wait.waitMs; - if (wait.capped) { - log( - `授权等待被夹到 ${Math.round(waitMs / 1000)}s:` + - `MCP 调用超时只有 ${wait.mcpTimeoutMs}ms(清单里的 timeoutMs),` + - `而配置想等 ${Math.round((Number(env.AGENTMAIL_PERMISSION_WAIT_MS) || DEFAULT_WAIT_MS) / 1000)}s。` + - `不夹的话调用会先被杀掉,人会以为「没人批准」而不是「来不及」。` - ); - } - - /** 统一的问人入口:把「谁在问、问什么、上下文」凑好,交给共用模块。 */ - async function gate({ toolName, question, context }) { - const r = await requestApproval({ - client, - toolName, - question, - context, - sessionId, - waitMs, - grants, - tier, - // 幂等键带上会话与工具:网关按它去重,同一个动作重复问不会刷屏。 - relayKeySeed: `zcode-tool:${sessionId || 'local'}:${toolName}`, - log, - createSSE - }); - if (!r.allowed) { - // 抛错而不是返回字符串:让模型看见 isError 与原因。 - throw new Error(`未获批准,未执行 ${toolName}。\n${r.reason}`); - } - log(`${toolName} 获批(${r.via}${r.decidedBy ? `, ${r.decidedBy}` : ''})`); - return r; - } - - return [ - { - name: 'run_command', - // 会改变机器状态 → destructiveHint:false 但非只读。平台在 yolo 下不再判定, - // 但注解仍要如实填写:它决定别的档位/宿主下这个工具的可见性。 - annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false }, - description: - '在本机执行一条 shell 命令并返回输出。' + - '**这会先向发件人申请授权**(plan 档一律不允许,full 档免问,workspace 档现场问人)。' + - '未获批准时本工具报错且不会执行任何东西。' + - '命令在 bash 里运行;工作目录默认是本会话的工作区。' + - `输出超过 ${OUTPUT_LIMIT} 字符会被截断(会标明截断了多少)。`, - inputSchema: { - type: 'object', - properties: { - command: { type: 'string', description: '要执行的命令(经 bash -c 执行)' }, - cwd: { type: 'string', description: '工作目录,默认本会话工作区' }, - timeout_ms: { - type: 'number', - description: `超时毫秒(默认 ${DEFAULT_CMD_TIMEOUT_MS},上限 ${MAX_CMD_TIMEOUT_MS})` - }, - purpose: { - type: 'string', - description: '为什么要执行它,一句话 —— 会展示给批准人看,请写具体' - } - }, - required: ['command'] - }, - async run(args) { - const a = args && typeof args === 'object' ? args : {}; - const command = str(a.command).trim(); - if (!command) throw new Error('command 不能为空'); - const cwd = str(a.cwd) || workspace; - const timeout = Math.min( - Number.isFinite(a.timeout_ms) && a.timeout_ms > 0 ? a.timeout_ms : DEFAULT_CMD_TIMEOUT_MS, - MAX_CMD_TIMEOUT_MS - ); - const purpose = str(a.purpose).trim(); - - await gate({ - toolName: 'run_command', - question: - 'Agent 请求执行一条命令:\n\n' + - fenced(clamp(command, 2000).text) + - `\n\n目录:${cwd}`, - context: [ - purpose ? `用途:${purpose}` : '', - '批准后该命令将在本机执行。拒绝后 Agent 会收到拒绝原因,可以改道。' - ] - .filter(Boolean) - .join('\n') - }); - - const started = Date.now(); - try { - const { stdout, stderr } = await new Promise((res, rej) => { - execFile( - '/bin/bash', - ['-c', command], - { cwd, timeout, maxBuffer: 4 * 1024 * 1024, killSignal: 'SIGTERM' }, - (err, stdout, stderr) => { - if (err) return rej(Object.assign(err, { stdout, stderr })); - res({ stdout, stderr }); - } - ); - }); - return renderResult({ code: 0, stdout, stderr, ms: Date.now() - started }); - } catch (e) { - // 非零退出码与超时都不是「工具坏了」——它们是命令的真实结果, - // 必须原样告诉模型(它靠 stderr 判断下一步),所以这里不抛错。 - const o = clamp(e?.stdout); - const er = clamp(e?.stderr); - const timedOut = e?.killed || e?.signal === 'SIGTERM'; - const exit = typeof e?.code === 'number' ? e.code : e?.signal || '未知'; - return ( - `退出码:${exit}${timedOut ? `(超时被终止,上限 ${timeout}ms)` : ''}` + - `耗时:${Date.now() - started}ms\n` + - truncNote('stdout', o) + - truncNote('stderr', er) - ); - } - } - }, - { - name: 'write_file', - annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false }, - description: - '把一个文件写到本机(覆盖写,父目录会自动创建)。' + - '**这会先向发件人申请授权**;未获批准时报错且不会创建任何文件或目录。' + - '平台自身的目录(部署、数据库、服务配置)无论谁批准都拒绝写入。', - inputSchema: { - type: 'object', - properties: { - path: { type: 'string', description: '绝对路径,或相对工作区的路径' }, - content: { type: 'string', description: '文件内容(UTF-8)' }, - purpose: { type: 'string', description: '为什么要写它,一句话,会展示给批准人看' } - }, - required: ['path', 'content'] - }, - async run(args) { - const a = args && typeof args === 'object' ? args : {}; - const raw = str(a.path).trim(); - if (!raw) throw new Error('path 不能为空'); - if (typeof a.content !== 'string') throw new Error('content 必须是字符串'); - const target = isAbsolute(raw) ? resolve(raw) : resolve(workspace, raw); - - // 保护路径在我们的门禁**之前**判定:即使有人点了同意也不放行, - // 因为这类改动不该走授权流程(见 PROTECTED_PREFIXES 的注释)。 - const hit = protectedHit(target); - if (hit) { - throw new Error( - `拒绝写入 ${target}:它在平台保护目录 ${hit} 之下。` + - `这类改动必须由人工部署完成。请把要写的内容放进回信,或改写到工作区内。` - ); - } - - await gate({ - toolName: 'write_file', - question: `Agent 请求写入文件:\n\n${target}\n\n内容 ${a.content.length} 字符`, - context: [ - str(a.purpose).trim() ? `用途:${str(a.purpose).trim()}` : '', - '内容预览(前 800 字符):', - clamp(a.content, 800).text - ] - .filter(Boolean) - .join('\n') - }); - - await mkdir(dirname(target), { recursive: true }); - await writeFile(target, a.content, 'utf8'); - return `已写入 ${target}(${Buffer.byteLength(a.content, 'utf8')} 字节)。`; - } - } - ]; -} - -function truncNote(label, { text, truncated }) { - const tail = truncated ? `\n[${label} 被截断,省略 ${truncated} 字符]` : ''; - const body = text === '' ? `(${label} 为空)` : text; - return `${label}:\n${body}${tail}\n`; -} - -function renderResult({ code, stdout, stderr, ms }) { - const o = clamp(stdout); - const e = clamp(stderr); - return `退出码:${code}\n耗时:${ms}ms\n` + truncNote('stdout', o) + truncNote('stderr', e); -} diff --git a/plugins/zcode-mail-bridge/lib/approval.mjs b/plugins/zcode-mail-bridge/lib/approval.mjs deleted file mode 100644 index c47d511..0000000 --- a/plugins/zcode-mail-bridge/lib/approval.mjs +++ /dev/null @@ -1,299 +0,0 @@ -/** - * 授权往返:把一次「要不要执行这个动作」的询问发给人类,等他的决定。 - * - * # 为什么必须是独立模块 - * - * 它现在有两个调用方,而且两者的失败后果完全不同: - * - * - `hooks/permission.mjs`:ZCode 桌面(交互)模式下的 PermissionRequest 钩子 - * - `lib/action-tools.mjs`:headless 模式下我们自己的执行工具(run_command 等) - * - * 两份实现迟早会漂移,而漂移的地方恰恰是最不该出错的判定:「什么算同意」 - * 「永久失败要不要 fail closed」「超时算不算拒绝」。所以判定复用共用库的 - * `isApproval` / `isAlwaysDecision` / `isPermanentFailure`,流程只有这一份。 - * - * # 三条不可动摇的规矩 - * - * 1. **只有明确同意才放行**(共用库的 `isApproval`)。注意它实际的判据是 - * **前缀匹配** `/^(同意|一直同意|allow|approve|always|yes)/i`(四个桥共用同一份, - * 所以这里不能另立一套)。前缀里的东西(如「同意吧」)算同意, - * 而看不懂的文本、空串、`拒绝`、`deny`、平台自己的 `shutdown` 哨兵一律当拒绝 —— - * 判据是「在放行白名单里」,不是「不等于拒绝」。 - * 2. **永久失败当场拒绝**(409 无人可问、4xx 参数/权限错)。它们不会因为重试 - * 而改变,重试只会把「权限系统坏了」这件事藏起来。 - * 3. **暂时失败看有没有本地界面**:有(桌面模式)就退回平台自己的流程; - * 没有(headless 邮件驱动)必须拒绝 —— 退回等于守卫消失。 - * 判据用的是调用方传进来的 `sessionId`(会话由邮件驱动 = 没有界面), - * **不再另读 `AGENTMAIL_SESSION_ID`**:两个事实来源迟早会不一致, - * 而它们不一致时到底算有界面还是没界面,谁都说不清。 - * - * # 为什么自己开 SSE - * - * 网关的 SSE 是**扇出**的(`clients` 按唯一 id 存,`SendToAgent` 推给该 Agent - * 的所有客户端),所以一个短命的钩子进程或一次工具调用都能自己订阅、拿到 - * 自己那条决定、然后退出。先建连再发请求 —— 反过来会有一个窗口:人恰好在 - * 窗口内点了同意,而事件推给了当时还不存在的客户端,表现为「明明点了同意 - * 却被拒」。 - */ - -import { createSSEClient } from './sse-client.js'; -import { randomUUID } from 'node:crypto'; -import { clampRelayKey, isPermanentFailure, isDuplicateRelay } from './relay-key.js'; -import { isApproval, isAlwaysDecision } from './permission-grants.js'; -import { normalizeMode, DEFAULT_MODE, MODE_FULL, MODE_PLAN } from './permission-mode.js'; -/** 等待人工决策的默认上限。调用方应保证它**明显小于**自己的杀进程上限, - * 否则会在正要给出结论的瞬间被杀掉,而「不表态」与「来不及答」就分不开了。 */ -export const DEFAULT_WAIT_MS = 540000; - -/** 当前档位(来自驱动注入的环境变量)。 */ -export function tierOf(env = process.env) { - return normalizeMode(env.AGENTMAIL_PERMISSION_MODE) || DEFAULT_MODE; -} - -/** - * 「有没有本地界面可以让人就地决定」。 - * - * 判据是驱动有没有注入会话 id:邮件驱动的会话由驱动起、没有界面; - * 人自己开着 ZCode 时有界面。这个区分决定了暂时失败该 fail closed 还是让位。 - */ -export function hasLocalUi(env = process.env) { - return !String(env.AGENTMAIL_SESSION_ID || '').trim(); -} - -/** 等 SSE 建连完成(服务端在 AddClient 时立刻下发一个 connected 事件)。 */ -function waitConnected(state, timeoutMs = 5000, log = () => {}) { - return new Promise(resolve => { - const timer = setTimeout(() => { - log('SSE 建连等待超时,仍然继续(可能错过极早到达的决策)'); - resolve(); - }, timeoutMs); - state.onConnected = () => { - clearTimeout(timer); - resolve(); - }; - }); -} - -/** - * 幂等键必须**每次调用都不同**。 - * - * 这里踩过一个真坑,而且失败方式极隐蔽:键取成 `会话 + 工具` 之后, - * 同一个会话里**第二次** `run_command` 就是个「重复请求」——网关按设计 - * 返回 HTTP 200 `{status:"duplicate_relay", detail:"该权限询问已转发过,本次调用未产生新邮件"}` - * 并且**提前返回**:不建请求、不发邮件、永远不会有人来决策。 - * - * 于是工具干等(实测被 MCP 的 30 秒调用超时砍掉),模型回报 - * 「30 秒内未获批准」—— 看上去像人没理它,实际是**请求根本没出去**。 - * 而 HTTP 还全是 200,从状态码上看不出任何异常。 - * - * 所以键的语义是「**这一次调用**」(一次工具调用 = 一次询问),不是「这个会话的这个工具」。 - * 重复请求的去重需求由「一直同意」表承担(那张表是按 会话+工具 生效的,那是对的语义)。 - */ -export function relayKeyForCall({ seed, sessionId, toolName, nonce }) { - const head = seed || `${sessionId || 'zcode'}:${toolName}`; - const tail = nonce || randomUUID().slice(0, 8); - return clampRelayKey(`${head}:${tail}`); -} - -// `isDuplicateRelay` 与 `DUPLICATE_RELAY_STATUS` 已挪到 **共用库** `lib/relay-key.js`: -// 四个桥都要认这个回包,各写一份必然分叉(而这个判据是「静默挂死」与 -// 「当场拒绝」的分界)。这里只 import。 - -/** - * 询问人类。 - * - * @param {object} opts - * @param {any} opts.client 网关客户端(要 authHeaders / post / baseURL) - * @param {string} opts.toolName 工具名(同时用作「一直同意」的授权粒度) - * @param {string} opts.question 给人看的问题 - * @param {string} opts.context 给人看的上下文(命令内容/文件路径等) - * @param {string} [opts.sessionId] AgentMail 会话 id - * @param {string} [opts.relayKeySeed] 幂等键前缀(默认 session:tool);每次调用会**追加一个随机尾**, - * 见 relayKeyForCall 的注释 - * @param {string} [opts.nonce] 仅测试用:固定随机尾以便断言 - * @param {object} opts.grants createFileGrantStore 的实例(可省) - * @param {string} [opts.tier] 档位(默认从环境读) - * @param {number} [opts.waitMs] - * @param {Function} [opts.log] - * @param {Function} [opts.createSSE] 供测试注入 - * @returns {Promise<{allowed:boolean, reason:string, decidedBy:string, via:string}>} - * via 说明结论来自哪一步:tier / grant / human / permanent-failure / - * timeout / transport —— 日志与回信要能看出「当时凭什么放行」。 - */ -export async function requestApproval(opts) { - const { - client, - toolName, - question, - context = '', - sessionId = '', - relayKeySeed, - nonce, - grants = null, - tier = tierOf(), - waitMs = DEFAULT_WAIT_MS, - log = () => {}, - createSSE = createSSEClient - } = opts; - - // ① 档位:plan 档只允许读与查,没什么可问人的(该档语义就是「不动手」)。 - if (tier === MODE_PLAN) { - return { - allowed: false, - via: 'tier', - decidedBy: '', - reason: - `plan 档下不允许执行 ${toolName}。本档只允许读与查,请把方案写在回信里。` + - `如需动手请让发件人把档位改成 workspace。` - }; - } - - // ② full 档:发件人已声明全权。这一档的核心语义就是免掉询问。 - if (tier === MODE_FULL) { - return { allowed: true, via: 'tier', decidedBy: '', reason: `${tier} 档:全权,无需询问` }; - } - - const scope = sessionId || ''; - // ③ 「一直同意」:钩子是短命进程,所以这张表由文件承载(见 grants-file.mjs)。 - if (grants && grants.isGranted(scope, toolName)) { - return { allowed: true, via: 'grant', decidedBy: '', reason: `本会话的 ${toolName} 已获「一直同意」` }; - } - - const relayKey = relayKeyForCall({ - seed: relayKeySeed, - sessionId: scope, - toolName, - nonce - }); - - // 有没有本地界面:由**调用方给的会话 id** 判定(单一事实来源)。 - // 邮件驱动的会话一定带 sessionId;人自己开着 ZCode 时没有。 - const mailDriven = scope.trim() !== ''; - - // ④ 先订阅再发请求(顺序不能反,见文件头注释)。 - const state = { onConnected: null }; - let waiter = null; - const early = []; - const sse = createSSE({ - authHeaders: () => client.authHeaders(), - baseURL: client.baseURL, - path: '/api/v1/events/stream', - log, - onEvent: (evt, data) => { - if (evt === 'connected' && state.onConnected) state.onConnected(); - if (evt !== 'permission_decision') return; - // 只认自己那条:同一 Agent 可能同时有多个调用在等(模型并行发起两个动作), - // 按 relay_key 配对才不会互相拿到对方的决定。 - if (data?.relay_key && data.relay_key !== relayKey) return; - if (waiter) { - const w = waiter; - waiter = null; - w(data); - } else { - early.push(data); - } - } - }); - - try { - await waitConnected(state, 5000, log); - - try { - const accepted = await client.post('/permission/request', { - question, - options: ['同意', '一直同意', '拒绝'], - context, - session_id: scope, - relay_key: relayKey - }); - // 幂等命中 = 请求**没有**发出去,永远不会有决策事件。 - // 不把它当成失败的话,调用方会一直等到被客户端杀掉,而错误信息是 - // 「没有人批准」—— 归因完全错了。所以当场以可读的原因拒绝。 - if (isDuplicateRelay(accepted)) { - log(`授权询问被网关判为重复(relay_key=${relayKey}),本次没有产生新请求`); - return { - allowed: false, - via: 'duplicate-relay', - decidedBy: '', - reason: - `授权请求被网关当作重复请求丢弃了(${accepted.detail || 'duplicate_relay'})。` + - `这意味着**没有人会看到这次询问**,因此不放行。` + - `请重新发起(键每次调用都不同),或改用不需要授权的方式。` - }; - } - } catch (e) { - // 永久失败(409 无人可问 / 4xx)不会因重试而改变 → 当场拒绝, - // 让调用方从错误里看到原因并自己改道(挂死时连重试机会都没有)。 - if (isPermanentFailure(e)) { - const b = e?.body && typeof e.body === 'object' ? e.body : {}; - const reason = [b.error || `权限询问无法送达(HTTP ${e?.status})`, b.detail || '', b.suggestion || ''] - .filter(Boolean) - .join('\n'); - log(`权限询问永久失败,当场拒绝 ${relayKey}:${reason.split('\n')[0]}`); - return { allowed: false, via: 'permanent-failure', decidedBy: '', reason }; - } - const detail = e?.message || String(e); - log(`权限询问暂时失败:${detail}`); - if (mailDriven) { - // 邮件驱动:没有本地界面兜底,退回本地决策等于守卫消失。 - return { - allowed: false, - via: 'transport', - decidedBy: '', - reason: - `无法把 ${toolName} 的授权请求送达给人(${detail})。` + - `这条会话由邮件驱动、没有本地界面,因此不放行。` + - `请改用不需要授权的方式完成,或在回信里说明需要人工执行哪一步。` - }; - } - return { allowed: false, via: 'transport', decidedBy: '', reason: `授权询问失败:${detail}` }; - } - - const decision = - early.shift() ?? - (await new Promise(resolve => { - waiter = resolve; - setTimeout(() => { - if (waiter !== resolve) return; - waiter = null; - resolve(null); - }, waitMs); - })); - - if (decision === null) { - return { - allowed: false, - via: 'timeout', - decidedBy: '', - reason: `等待授权超时(${Math.round(waitMs / 1000)} 秒内没有人决策),未执行 ${toolName}。` - }; - } - - const text = decision.decision ?? ''; - if (isApproval(text)) { - if (isAlwaysDecision(text) && grants?.grant(scope, toolName, text)) { - log(`记下「一直同意」:会话 ${scope} 的 ${toolName} 后续免批`); - } - return { - allowed: true, - via: 'human', - decidedBy: decision.decided_by || '', - reason: `获批(${text})` - }; - } - return { - allowed: false, - via: 'human', - decidedBy: decision.decided_by || '', - reason: [ - `用户拒绝了这次 ${toolName} 调用。`, - decision.note ? `说明:${decision.note}` : '', - decision.decided_by ? `(由 ${decision.decided_by} 决定)` : '' - ] - .filter(Boolean) - .join('\n') - }; - } finally { - sse.stop(); - } -} diff --git a/plugins/zcode-mail-bridge/lib/grants-file.mjs b/plugins/zcode-mail-bridge/lib/grants-file.mjs deleted file mode 100644 index 583725d..0000000 --- a/plugins/zcode-mail-bridge/lib/grants-file.mjs +++ /dev/null @@ -1,102 +0,0 @@ -/** - * 「一直同意」的跨进程持久化。 - * - * # 为什么需要文件 - * - * 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 deleted file mode 100644 index 1a8fab0..0000000 --- a/plugins/zcode-mail-bridge/lib/hook-policy.mjs +++ /dev/null @@ -1,92 +0,0 @@ -/** - * 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/mcp-rpc.mjs b/plugins/zcode-mail-bridge/lib/mcp-rpc.mjs deleted file mode 100644 index 7ce8fb3..0000000 --- a/plugins/zcode-mail-bridge/lib/mcp-rpc.mjs +++ /dev/null @@ -1,145 +0,0 @@ -/** - * MCP(Model Context Protocol)的 stdio 传输层与 JSON-RPC 分发。 - * - * # 为什么手写而不引 `@modelcontextprotocol/sdk` - * - * 协议面很小:`initialize` / `notifications/initialized` / `tools/list` / - * `tools/call`。SDK 会带来一个 1MB 上下的打包产物与一条构建链,而本插件的 - * 其余部分(网关客户端 + 工具)本就零运行时依赖 —— 与 pi/opencode/dsh 三个桥 - * 的取向一致。手写还能让这一层成为**可单测的纯函数**,而不是只能靠连上宿主才验。 - * - * # 分帧 - * - * stdio 传输是**换行分隔的 JSON**(一行一条消息,UTF-8),不是 Content-Length 分帧。 - * 这一点是照官方插件实测确认的:它的打包产物里出现 `StdioServerTransport` 与 - * `split("\n")`,而 `Content-Length` 出现 **0 次**。 - * - * # 职责边界 - * - * 本模块只做「消息进 → 消息出」,不碰 stdin/stdout,也不认识具体工具 —— - * 于是它可以在测试里被穷举,而 I/O 只剩 server.mjs 里那一小段胶水。 - */ - -export const PROTOCOL_VERSION = '2024-11-05'; -export const SERVER_NAME = 'agentmail'; -export const SERVER_VERSION = '0.1.0'; - -/** JSON-RPC 错误码(只列我们真的会返回的)。 */ -export const RPC_ERROR = { - PARSE: -32700, - INVALID_REQUEST: -32600, - METHOD_NOT_FOUND: -32601, - INVALID_PARAMS: -32602, - INTERNAL: -32603 -}; - -const result = (id, value) => ({ jsonrpc: '2.0', id, result: value }); -const failure = (id, code, message) => ({ jsonrpc: '2.0', id, error: { code, message } }); - -/** - * 处理一条已解析的 JSON-RPC 消息。 - * - * @param {any} msg 解析后的消息 - * @param {{tools: Array<{name:string, description:string, inputSchema:object, - * annotations?: object}>, - * call: (name: string, args: object) => Promise}} ctx - * @returns {Promise} 要写回的消息;notification(无 id)返回 null - */ -export async function handleMessage(msg, ctx) { - // 通知(没有 id)不需要回复。`notifications/initialized` 就走这条 —— - // 若它也回一条,客户端会把响应与请求错配,后续调用全乱。 - const isNotification = msg === null || typeof msg !== 'object' || !('id' in msg); - const id = isNotification ? null : msg.id; - - if (typeof msg !== 'object' || msg === null || typeof msg.method !== 'string') { - return isNotification - ? null - : failure(id, RPC_ERROR.INVALID_REQUEST, '请求缺少 method'); - } - - switch (msg.method) { - case 'initialize': - return isNotification - ? null - : result(id, { - // 回显客户端给的协议版本:不认识的版本也回显,交由客户端决定是否降级 —— - // 自作主张改成我们的版本会让客户端以为协商成功而按新语义调用。 - protocolVersion: msg.params?.protocolVersion || PROTOCOL_VERSION, - capabilities: { tools: { listChanged: false } }, - serverInfo: { name: SERVER_NAME, version: SERVER_VERSION } - }); - - case 'notifications/initialized': - return null; // 纯通知 - - case 'ping': - return isNotification ? null : result(id, {}); - - case 'tools/list': - return isNotification - ? null - : result(id, { - tools: ctx.tools.map(t => ({ - name: t.name, - description: t.description, - inputSchema: t.inputSchema, - // annotations 必须透传:宿主据此算风险等级(readOnlyHint→low / - // destructiveHint→high —— ZCode 的规则是逐字逆自其 CLI 产物), - // 而 plan 档下「非破坏性的 MCP 工具直接放行」 - // 依赖它。漏传的后果不是「少个提示」,而是工具在该档下全被拒。 - ...(t.annotations ? { annotations: t.annotations } : {}) - })) - }); - - case 'tools/call': { - if (isNotification) return null; - const name = msg.params?.name; - const args = msg.params?.arguments ?? {}; - if (typeof name !== 'string' || name === '') { - return failure(id, RPC_ERROR.INVALID_PARAMS, 'tools/call 缺少 name'); - } - const known = ctx.tools.some(t => t.name === name); - if (!known) { - return failure(id, RPC_ERROR.INVALID_PARAMS, `没有名为 ${name} 的工具`); - } - try { - const text = await ctx.call(name, args); - return result(id, { content: [{ type: 'text', text: String(text ?? '') }] }); - } catch (error) { - // 工具失败**不能**回 JSON-RPC error —— 那样模型看不到失败原因, - // 只会看到一次协议错误。MCP 的约定是 result + isError:true, - // 于是错误文本进入对话,模型能据此改正(例如换一个 attachment_id)。 - return result(id, { - content: [ - { type: 'text', text: `工具 ${name} 执行失败:${error?.message || error}` } - ], - isError: true - }); - } - } - - default: - return isNotification - ? null - : failure(id, RPC_ERROR.METHOD_NOT_FOUND, `不支持的方法 ${msg.method}`); - } -} - -/** - * 把一行文本解析成消息并处理,返回要写回的行(或不返回)。 - * - * 解析失败时**必须**回一条带 id=null 的解析错误(JSON-RPC 规定), - * 否则客户端会一直等这一条的响应。 - */ -export async function handleLine(line, ctx) { - const text = String(line ?? '').trim(); - if (text === '') return null; - let msg; - try { - msg = JSON.parse(text); - } catch { - return JSON.stringify(failure(null, RPC_ERROR.PARSE, '不是合法的 JSON')); - } - const out = await handleMessage(msg, ctx); - return out === null ? null : JSON.stringify(out); -} diff --git a/plugins/zcode-mail-bridge/lib/tools.mjs b/plugins/zcode-mail-bridge/lib/tools.mjs deleted file mode 100644 index 5ebebbc..0000000 --- a/plugins/zcode-mail-bridge/lib/tools.mjs +++ /dev/null @@ -1,487 +0,0 @@ -/** - * 暴露给 MCP 宿主的 AgentMail 工具。 - * - * # 为什么工具集与另三个桥完全相同 - * - * 同一件事在不同平台上应该有同一种做法。工具名(`read_inbox` / `send_mail` / - * `download_attachment` …)、参数名、以及**渲染文本**都对齐 pi / dsh / opencode: - * 渲染走 `lib/inbox-format.js` 与 `lib/discovery.js`(逐字节同源), - * 所以模型在任一平台上看到的收件箱是同一个样子。 - * - * 一旦这里少一个参数或换一种说法,就会出现「某个平台上模型不会回信」这类 - * 只在单一平台复现的问题 —— 而排查时最费时间的正是「它到底和别的平台哪里不一样」。 - * - * # 与宿主无关 - * - * 本模块不认识任何特定宿主(ZCode / Claude Desktop / codex / 各类 agent harness…), - * 它只是一组 `{name, description, inputSchema, run(args) -> string}`。 - * 协议那层在 lib/mcp-rpc.mjs,入口在 mcp/server.mjs。 - * 配置一律走环境变量 `AGENTMAIL_*`,由宿主注入。 - */ - -import { - renderInbox, - renderMail, - idsToMarkRead, - formatSize, - DEFAULT_INBOX_STATUS, - DEFAULT_INBOX_LIMIT -} from './inbox-format.js'; -import { - renderNameSuggestions, - renderPathSuggestions, - renderSessionSuggestions, - renderParticipants, - renderContacts, - renderThread -} from './discovery.js'; -import { normalizeAttachmentIDs } from './attachment-ids.js'; -import { uploadLocalFile, downloadToFile } from './gateway.mjs'; -import { explicitSendsFile, noteExplicitSendFile } from './explicit-sends.mjs'; - -/** 正文在列表里的截断长度(与另三端一致)。 */ -const BODY_LIMIT = 200; - -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 - */ -export function buildTools({ client, agentName }) { - /** - * 每次调用前校验配置。缺密钥时在此明确报错 —— - * 否则模型看到的是一个 401,而它会去重试而不是告诉人「插件没配密钥」。 - */ - const guard = () => { - const missing = client.checkConfig(); - if (missing.length) { - throw new Error( - `AgentMail 未配置完成:缺少 ${missing.join('、')}。` + - `请设置 AGENTMAIL_AGENT_NAME / AGENTMAIL_AGENT_KEY(或 AGENTMAIL_AGENT_SECRET)环境变量后重启 MCP 服务器。` - ); - } - }; - - /** - * 读类端点的会话收窄参数。 - * - * 与 read_inbox 同一个理由(不收窄会把别会话的未读标掉 ⇒ 静默丢信), - * 但服务端现在拿它多干一件事:**由这条会话反查工作区**,只有同工作区的会话才放行。 - * 为什么必须有一维:一个 Agent 同时服务所有工作区(注册时 workspaces 为空), - * 不收窄时在 TrueAgent 里干活的 worker 能读到 agentmail 的整条线索。 - * - * 驱动每轮把本轮邮件会话注入 AGENTMAIL_SESSION_ID(授权钩子本来就用它), - * MCP 子进程继承同一个 env ⇒ 直接读即可,且天然并发安全(一轮一个进程)。 - * 在调用时读而不是 import 时读死,避免复用进程时拿到旧值。 - * 拿不到就原样返回:宁可退回旧行为(服务端会记警告),也不猜一个。 - */ - const withScope = (path) => { - const sid = process.env.AGENTMAIL_SESSION_ID || ''; - if (!sid) return path; - const sep = path.includes('?') ? '&' : '?'; - return `${path}${sep}session_id=${encodeURIComponent(sid)}`; - }; - - const tools = []; - - // ─── 读 ──────────────────────────────────────────────────────── - tools.push({ - name: 'read_inbox', - annotations: READ_ONLY, - description: - '查阅收件箱里的其它未读邮件。' + - '刚投递到本会话的那封已在投递时标为已读,不在这里 —— 读那封用 read_mail(mail_id),mail_id 就在投递提示词里。' + - '每封含 mail_id、发件人、主题、正文与附件清单(带 attachment_id)。', - inputSchema: { - type: 'object', - properties: { - status: { type: 'string', description: '过滤条件 unread|all,默认 unread' }, - limit: { type: 'number', description: '返回数量,默认 5' } - } - }, - async run(args) { - guard(); - const a = obj(args); - const status = str(a.status) || DEFAULT_INBOX_STATUS; - const limit = Number.isFinite(a.limit) ? a.limit : DEFAULT_INBOX_LIMIT; - // ★ 会话收窄:驱动每轮都会把**本轮邮件会话**注入 AGENTMAIL_SESSION_ID - // (授权钩子本来就用它),MCP 子进程继承同一个 env ⇒ 这里直接读即可, - // 而且天然并发安全(一轮一个进程)。 - // - // 缺陷(用户报的):「不同 session 的 agent 都可以看到全部邮件」:列表按 Agent 列, - // 且 read_inbox 会把列出的都标已读 ⇒ A 会话标掉 B 会话的未读 ⇒ B 之后按 - // ?status=unread 补投时再也看不到那封信(静默丢信)。 - // 在调用时读(而不是 import 时读死),避免未来复用同一进程时拿到旧值。 - const mailSessionID = process.env.AGENTMAIL_SESSION_ID || ''; - const scope = mailSessionID ? `&session_id=${encodeURIComponent(mailSessionID)}` : ''; - const { mails } = await client.get( - `/mail/inbox?status=${encodeURIComponent(status)}&limit=${limit}${scope}` - ); - const listed = renderInbox(mails, BODY_LIMIT, agentName); - - const ids = idsToMarkRead(a.status, mails); - if (ids.length) { - // 标记失败不该让读取失败:正文已经取到了,代价只是下次重复看到。 - client.post('/mail/read', { mail_ids: ids }).catch(() => {}); - } - return listed; - } - }); - - tools.push({ - name: 'read_mail', - annotations: READ_ONLY, - description: '读取一封邮件的完整正文、附件清单与可投递地址(mail_id 从 read_inbox 获得)。', - inputSchema: { - type: 'object', - properties: { mail_id: { type: 'string', description: '要读哪封' } }, - required: ['mail_id'] - }, - async run(args) { - guard(); - const id = str(obj(args).mail_id); - if (!id) throw new Error('缺少 mail_id'); - const data = await client.get(withScope(`/agent/mail/${encodeURIComponent(id)}?body_limit=0`)); - const mail = data?.mail || data; - const lines = [renderMail(mail, 0, agentName)]; - if (Array.isArray(data?.participants) && data.participants.length) { - lines.push('', renderParticipants(data)); - } - if (data?.reply_address) { - lines.push('', `回信给发件人用 ${data.reply_address},或传 reply_to=${id}。`); - } - return lines.join('\n'); - } - }); - - tools.push({ - name: 'read_thread', - annotations: READ_ONLY, - description: '查看一封邮件所在线索的完整往来(谁回了谁、谁还没回)。多方协作时用它避免重复提问。', - inputSchema: { - type: 'object', - properties: { - mail_id: { type: 'string', description: '线索中任一封邮件的 ID' }, - offset: { type: 'number', description: '分页偏移,续取时传上次返回的 next_offset' } - }, - required: ['mail_id'] - }, - async run(args) { - guard(); - const a = obj(args); - const id = str(a.mail_id); - if (!id) throw new Error('缺少 mail_id'); - const qs = Number.isFinite(a.offset) ? `?offset=${a.offset}` : ''; - const data = await client.get(withScope(`/agent/mail/${encodeURIComponent(id)}/thread${qs}`)); - return renderThread(data, agentName); - } - }); - - // ─── 写 ──────────────────────────────────────────────────────── - tools.push({ - name: 'send_mail', - annotations: WRITE_SAFE, - description: - '发送邮件。三维地址 name@path.session:省略 session 投递到默认会话,' + - '.new 强制新建,.具体别名 必须已存在。回复来信请传 reply_to。', - inputSchema: { - type: 'object', - properties: { - to: { type: 'string', description: '收件人三维地址,如 admin@/home/program/x' }, - subject: { type: 'string', description: '邮件主题' }, - body: { type: 'string', description: '邮件正文(Markdown)' }, - cc: { type: 'string', description: '抄送,逗号分隔多个三维地址' }, - reply_to: { type: 'string', description: '回复某封邮件时传其 mail_id' }, - session_alias: { type: 'string', description: '给新会话命名(仅 .new 时生效)' }, - attachment_ids: { - // 声明成「数组或字符串」而不是纯数组: - // 模型常把数组写成 JSON 字符串(`"[\"id\"]"`), - // opencode 上就是这样连试 6 次失败、最后放弃整个任务。 - // 声明放宽 + 下面归一,两条一起才拦得住。 - description: '附件 ID 列表(先用 upload_attachment 取得)', - anyOf: [ - { type: 'array', items: { type: 'string' } }, - { type: 'string', description: '单个 ID,或形如 ["a","b"] 的 JSON 数组字符串' } - ] - }, - max_rounds: { type: 'number', description: '给这条新会话设定往返预算(仅新建时有效)' } - }, - required: ['to', 'subject', 'body'] - }, - async run(args) { - guard(); - const a = obj(args); - const to = str(a.to); - const subject = str(a.subject); - const body = str(a.body); - if (!to || !subject || !body) throw new Error('缺少必填字段:to, subject, body'); - const payload = { to, subject, body }; - if (str(a.cc)) payload.cc = str(a.cc); - if (str(a.reply_to)) payload.reply_to = str(a.reply_to); - if (str(a.session_alias)) payload.session_alias = str(a.session_alias); - if (Number.isFinite(a.max_rounds)) payload.max_rounds = a.max_rounds; - const ids = normalizeAttachmentIDs(a.attachment_ids); - if (ids.length) payload.attachment_ids = ids; - - const result = await client.post('/mail/send', payload); - - // 记下「模型自己发了信」—— 驱动据此决定要不要再自动转发本轮收尾话。 - // 两边是两个进程(工具跑在 ZCode 起的 MCP 服务器里),只能经文件对齐; - // 不记的后果是收件箱里出现两封说同一件事的邮件(线上实测过)。 - noteExplicitSendFile(explicitSendsFile(process.env), { - sessionId: process.env.AGENTMAIL_SESSION_ID || result?.session_id || '', - to, - replyTo: str(a.reply_to), - ts: Date.now() - }); - - const parts = [`邮件已发送(ID: ${result?.mail_id ?? '?'}`]; - if (result?.session_id) parts.push(`,会话: ${result.session_id}`); - if (result?.session_alias) parts.push(`,别名: ${result.session_alias}`); - parts.push(')。'); - if (result?.budget_remaining !== undefined) { - parts.push(`本任务剩余往返:${result.budget_remaining}。`); - } - return parts.join(''); - } - }); - - tools.push({ - name: 'forward_mail', - annotations: WRITE_SAFE, - description: - '转发一封邮件给新的收件人(自动引用原文与附件)。与回复不同:回复落回原会话,转发按目标地址另行定位会话。', - inputSchema: { - type: 'object', - properties: { - mail_id: { type: 'string', description: '要转发的邮件 ID' }, - to: { type: 'string', description: '新收件人的三维地址' }, - comment: { type: 'string', description: '转发说明,置于引用原文之前' } - }, - required: ['mail_id', 'to'] - }, - async run(args) { - guard(); - const a = obj(args); - if (!str(a.mail_id) || !str(a.to)) throw new Error('缺少 mail_id 或 to'); - const result = await client.post( - withScope(`/mail/${encodeURIComponent(str(a.mail_id))}/forward`), - { to: str(a.to), comment: str(a.comment) } - ); - return `已转发(新邮件 ID: ${result?.mail_id ?? '?'},会话: ${result?.session_id ?? '?'})。`; - } - }); - - // ─── 附件 ────────────────────────────────────────────────────── - tools.push({ - name: 'upload_attachment', - annotations: WRITE_SAFE, - description: - '上传本地文件作为邮件附件,返回 attachment_id。' + - '拿到 id 后必须在 send_mail 的 attachment_ids 里带上,附件才会随邮件发出。', - inputSchema: { - type: 'object', - properties: { file_path: { type: 'string', description: '本地文件绝对路径' } }, - required: ['file_path'] - }, - async run(args) { - guard(); - const p = str(obj(args).file_path); - if (!p) throw new Error('缺少 file_path'); - const a = await uploadLocalFile(client, p); - return ( - `已上传 ${a.filename}(${formatSize(a.size_bytes)})。attachment_id: ${a.attachment_id}\n` + - `在 send_mail 的 attachment_ids 里带上这个 id 才会随邮件发出。` - ); - } - }); - - tools.push({ - name: 'download_attachment', - annotations: WRITE_SAFE, - description: '下载邮件附件到本地文件。attachment_id 从 read_inbox 的附件清单里取。', - inputSchema: { - type: 'object', - properties: { - attachment_id: { type: 'string', description: '附件 ID' }, - save_path: { type: 'string', description: '保存到的本地绝对路径' } - }, - required: ['attachment_id', 'save_path'] - }, - async run(args) { - guard(); - const a = obj(args); - const id = str(a.attachment_id); - const save = str(a.save_path); - if (!id || !save) throw new Error('缺少 attachment_id 或 save_path'); - const size = await downloadToFile(client, id, save); - return `已保存到 ${save}(${formatSize(size)})`; - } - }); - - // ─── 寻址发现 ────────────────────────────────────────────────── - tools.push({ - name: 'suggest_address', - annotations: READ_ONLY, - description: - '查询可用的收件人地址,用于精准发信。不带参数给候选收件人名;带 name 给它可用的' + - '工作目录;name+path 都带则给该目录下可续谈的会话与现成地址。', - inputSchema: { - type: 'object', - properties: { - name: { type: 'string', description: '收件人名,如 pi / admin' }, - path: { type: 'string', description: '工作目录绝对路径' } - } - }, - async run(args) { - guard(); - const a = obj(args); - const name = str(a.name); - const path = str(a.path); - const qs = new URLSearchParams(); - if (name) qs.set('name', name); - if (path) qs.set('path', path); - const data = await client.get(withScope(`/agent/contacts/suggest?${qs.toString()}`)); - if (!name) return renderNameSuggestions(data?.names || data?.suggestions || []); - if (!path) return renderPathSuggestions(data?.paths || [], name); - return renderSessionSuggestions(data, name, path); - } - }); - - tools.push({ - name: 'list_contacts', - annotations: READ_ONLY, - description: '列出自己参与过的全部会话及各自的可投递地址、未读数、剩余往返预算。', - inputSchema: { - type: 'object', - properties: { limit: { type: 'number', description: '最多列出多少条,默认 20' } } - }, - async run(args) { - guard(); - const limit = Number.isFinite(obj(args).limit) ? obj(args).limit : 20; - const data = await client.get(withScope(`/agent/contacts?limit=${limit}`)); - return renderContacts(data, limit); - } - }); - - tools.push({ - name: 'session_participants', - annotations: READ_ONLY, - description: - '列出某条会话的全部参与方(发件人/收件人/抄送方)及各自的可投递地址,并标出谁还没回应。' + - '要回给抄收方或向第三方转达时先用它拿地址。', - inputSchema: { - type: 'object', - properties: { session_id: { type: 'string', description: '会话 ID' } }, - required: ['session_id'] - }, - async run(args) { - guard(); - const sid = str(obj(args).session_id); - if (!sid) throw new Error('缺少 session_id'); - const data = await client.get(withScope(`/agent/sessions/${encodeURIComponent(sid)}/participants`)); - return renderParticipants(data); - } - }); - - // ─── 连接与登记 ──────────────────────────────────────────────── - tools.push({ - name: 'connect_to_server', - annotations: WRITE_SAFE, - description: - '连接到 AgentMail Gateway:用当前配置的身份完成登记,并报告连通性。' + - '首次安装或换了 Gateway 地址时调用。', - inputSchema: { - type: 'object', - properties: { - gateway_url: { type: 'string', description: 'Gateway 地址;省略则用当前配置' }, - key_token: { type: 'string', description: '管理员签发的 Agent 密钥;省略则用当前配置' } - } - }, - async run(args) { - // 刻意**不走 guard**:密钥没配好时,这个工具正是用来把问题说清楚的那个。 - // 若也直接抛「未配置完成」,模型只能转述一句抱怨,人不知道该去哪里填。 - const a = obj(args); - const url = (str(a.gateway_url) || client.baseURL).replace(/\/+$/, ''); - const key = str(a.key_token) || client.agentKey; - const missing = client.checkConfig(); - if (!agentName || (!key && !client.agentSecret)) { - return ( - `AgentMail 尚未配置完成:缺少 ${missing.join('、')}。\n` + - `请设置 AGENTMAIL_AGENT_NAME / AGENTMAIL_AGENT_KEY(或 AGENTMAIL_AGENT_SECRET)环境变量后重启 MCP 服务器。\n` + - `(当前解析到的 Gateway 地址:${url})` - ); - } - const res = await fetch(`${url}/api/v1/agent/register`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - ...(key - ? { Authorization: `Bearer ${key}` } - : { 'X-Agent-Secret': client.agentSecret }) - }, - // ★ 2026-10-02 修:secret-only 的 Agent(dsh/pi 就是这么配的)之前注册 - // **一直 400** —— 该端点只认 `Authorization: Bearer` 或 body 里的 - // `secret`,不认 `X-Agent-Secret` 头(其它接口才认)。这里漏了 body.secret, - // 于是模型调「连一下服务器」就被 400 卡住,而它看不出该改什么。 - // lib/gateway.mjs 的 register() 本来就做对了(没密钥时把 secret 放进 - // body),之前只是没用上。 - body: JSON.stringify({ - name: agentName, - platform: client.platform || 'mcp', - ...(key ? {} : { secret: client.agentSecret }) - }) - }); - const text = await res.text(); - let data = {}; - try { - data = text ? JSON.parse(text) : {}; - } catch { - data = {}; - } - if (!res.ok) { - return `登记失败(HTTP ${res.status}):${data.error || data.message || text.slice(0, 200)}`; - } - return `已连接 ${url},身份 ${agentName}(状态:${data.status || 'ok'})。`; - } - }); - - return tools; -} - -/** 便捷:把工具集变成 `name -> tool` 的映射,供分发层使用。 */ -export function indexTools(tools) { - return new Map(tools.map(t => [t.name, t])); -} diff --git a/plugins/zcode-mail-bridge/mcp/server.mjs b/plugins/zcode-mail-bridge/mcp/server.mjs deleted file mode 100644 index befb42b..0000000 --- a/plugins/zcode-mail-bridge/mcp/server.mjs +++ /dev/null @@ -1,119 +0,0 @@ -#!/usr/bin/env node -/** - * AgentMail 的通用 MCP 服务器入口(stdio 传输,JSON-RPC)。 - * - * 任何支持 MCP 的宿主都能直接挂载,只需注入 AGENTMAIL_* 环境变量: - * - * node /mcp/server.mjs - * - * # 不属于任何单一宿主 - * - * 本文件只做三件事:读 stdin 分帧 → lib/mcp-rpc.mjs 分发 → 写回 stdout。 - * 工具集在 lib/tools.mjs,网关客户端在 lib/gateway.mjs,二者都不认识宿主。 - * 此前入口注释写「ZCode」并额外挂载了 run_command / write_file 两个 - * “会动机器”的工具 —— 那套门禁是为 ZCode 的 headless(`--mode yolo` + - * `--disallowed-tools`)双进程审批设计的,脱离该宿主后语义不成立, - * 且它把“工具面 = 邮件工具”这一件事与某个宿主绑死。故已移除。 - * - * # 工具面只保留邮件协作工具 - * - * 12 个纯邮件/寻址/附件工具,全部走网关的鉴权与会话收窄(与 pi / dsh / - * opencode 三桥同源)。需要动机器的能力由宿主自己的工具提供,不在本服务内。 - * - * # stdout 是协议通道 - * - * stdout 上**只能**出现协议消息。任何一行 `console.log` 都会被客户端当成 - * JSON 解析失败 —— 于是服务器看起来「起来了但一个工具都没有」。 - * 本文件里所有诊断一律 `console.error`(stderr 被客户端当日志转发,不影响协议)。 - * - * # 起不来要说清楚 - * - * 这是邮件驱动会话的一部分:没有本地 UI 让人看见崩溃。所以缺配置时 - * 不是静默退出,而是把原因写到 stderr(进宿主日志),并在**每次工具调用**时 - * 再报一次(模型能读,于是它会告诉人)。 - */ - -import { createInterface } from 'node:readline'; -import { GatewayClient } from '../lib/gateway.mjs'; -import { buildTools, indexTools } from '../lib/tools.mjs'; -import { handleLine, SERVER_NAME, SERVER_VERSION } from '../lib/mcp-rpc.mjs'; -import { isMainModule } from '../lib/is-main.mjs'; - -const log = (...parts) => console.error('[agentmail-mcp]', ...parts); - -export async function main() { - const client = new GatewayClient(process.env); - // 常见情况是没配 agent_name(宿主没注入 userConfig / 环境变量没继承)—— - // 用网关的默认值兜底会让它以别人的身份发信,所以宁可留空并在调用时报错。 - const agentName = client.agentName; - - const tools = buildTools({ client, agentName }); - - const byName = indexTools(tools); - - const ctx = { - tools, - call: async (name, args) => { - const tool = byName.get(name); - if (!tool) throw new Error(`没有名为 ${name} 的工具`); - return tool.run(args); - } - }; - - log(`启动 v${SERVER_VERSION},网关 ${client.baseURL},身份 ${agentName || '(未配置)'},` + - `平台 ${client.platform},工具 ${tools.length} 个`); - - const rl = createInterface({ input: process.stdin, crlfDelay: Infinity }); - - // 关闭 stdin 不等于「可以立刻退出」:此刻可能还有在途的工具调用。 - // 直接 `process.exit(0)` 会把它们的响应丢掉 —— 实测表现是 - // 「协议消息全对,但访问网关的那两个调用完全没有响应」, - // 而客户端只能等到超时(看起来像服务器挂了)。 - // 所以:计数在途工作,关闭后等它归零再退,且把 stdout 写入也计入, - // 否则最后一条响应可能在缓冲区里被丢掉。 - let pending = 0; - let stdinClosed = false; - const exitIfDrained = () => { - if (stdinClosed && pending === 0) process.exit(0); - }; - const writeOut = text => - new Promise(resolve => { - process.stdout.write(text + '\n', resolve); - }); - - rl.on('line', line => { - pending++; - // 不串行化:每条消息各自发起,谁先完成谁先写回(MCP 靠 id 配对, - // 乱序是合法的)。实测确实会乱序 —— 两条 suggest_address 的耗时不同, - // 后发的先回。不要在这里排 Promise 链:那会让一个慢调用 - // (例如 upload_attachment 传大文件)把后面的 read_inbox 堵住。 - Promise.resolve() - .then(() => handleLine(line, ctx)) - .then(out => (out === null || out === undefined ? undefined : writeOut(out))) - .catch(error => { - log('处理消息失败:', error?.message || error); - }) - .finally(() => { - pending--; - exitIfDrained(); - }); - }); - - rl.on('close', () => { - stdinClosed = true; - log('stdin 关闭,等 ' + pending + ' 件在途工作结束后退出'); - exitIfDrained(); - }); -} - -// 直接执行时启动;被 import 时只导出。 -// -// 判断**必须解析软链**(见 lib/is-main.mjs):生产布局是 `current` 软链, -// 直接比 `import.meta.url === 'file://'+argv[1]` 会判假 —— 服务器什么都不做、 -// 无输出、退出码 0(实测)。 -if (isMainModule(import.meta.url)) { - main().catch(error => { - log('致命错误:', error?.stack || error); - process.exit(1); - }); -} diff --git a/plugins/zcode-mail-bridge/package.json b/plugins/zcode-mail-bridge/package.json index 95025e8..6d047bd 100644 --- a/plugins/zcode-mail-bridge/package.json +++ b/plugins/zcode-mail-bridge/package.json @@ -3,11 +3,9 @@ "version": "0.1.0", "private": true, "type": "module", - "description": "AgentMail 的 ZCode 适配:让 ZCode 以 MCP 工具收发邮件、以 hook 承接授权与回信", + "description": "AgentMail 的 ZCode 适配:邮件驱动 + 授权钩子。MCP 工具面已内置于网关(POST /api/v1/mcp),本包不再自带 MCP 服务器。", "license": "AGPL-3.0-only", - "main": "mcp/server.mjs", "scripts": { - "test": "node --test 'test/*.test.mjs'", - "verify": "node -e \"import('./mcp/server.mjs')\" 2>/dev/null || true" + "test": "node --test 'test/*.test.mjs'" } } diff --git a/plugins/zcode-mail-bridge/src/index.mjs b/plugins/zcode-mail-bridge/src/index.mjs index b445b34..e74ed7e 100644 --- a/plugins/zcode-mail-bridge/src/index.mjs +++ b/plugins/zcode-mail-bridge/src/index.mjs @@ -31,8 +31,6 @@ import { basename, join } from 'node:path'; import { homedir } from 'node:os'; -import { readFileSync } from 'node:fs'; -import { fileURLToPath } from 'node:url'; import { GatewayClient, GatewayError } from '../lib/gateway.mjs'; import { createSSEClient } from '../lib/sse-client.js'; import { BoundedSet, BoundedMap, MAX_TRACKED_MAILS, MAX_TRACKED_SESSIONS } from '../lib/bounded.js'; @@ -77,65 +75,50 @@ export function zcodeSessionFallback(sessionKey, rootOverride) { /** * 自报给网关的强制力。 * - * **只声明得出来的事**。而且要分开两件事,它们以前被当成了同一件: + * **只声明得出来的事。** * - * 1. **平台自己的权限引擎**(PermissionRequest 钩子)—— 只在 `--mode build/edit` - * 下才会问。现在档位映射把 workspace 映到 `yolo`(平台恒 allow、钩子根本不会触发), - * 所以**拿「钩子已注册」当强制力证据是错的**。桌面(有界面)时它仍然有用, - * 但 headless 驱动这条路上不是它。 - * 2. **我们自己的门禁**(`lib/action-tools.mjs`)—— `yolo` + `--disallowed-tools` - * 之后,能动机器的只剩我们两个工具,而它们每次都要过 `requestApproval`。 - * 这个才是这条路上真正拦得住东西的那一层。 + * ★ 2026-10-02 重写:这套自报曾经依赖两样都已不存在的东西。 * - * ★ 2026-10-02:`run_command` / `write_file` 已从 **MCP 工具面**移除 - * (`mcp/server.mjs` 改为通用邮件服务,不再挂载它们)。原因是那套门禁 - * 为 ZCode headless 的双进程审批(`--mode yolo` + 禁用自带工具 + 钩子转达) - * 而设计,脱离该宿主后语义不成立。此处 `detectModeEnforcement` 的自检 - * 与下面的 `disallowedTools` 仍按原逻辑报告,但**实际后果变了**: - * 禁用自带工具后模型不再有执行类动作可做(只剩收发邮件),这是**安全侧 - * 的失败关闭**,不是开放。若将来要在本宿主恢复执行能力,需重新设计门禁。 + * 旧版有三条依据,现在只剩一条还成立: * - * 于是判据改成:先看我们自己那条链能不能真的拦住(模块在不在 + 禁用清单够不够), - * 再说平台那一层是否还参与。报 native 的含义是「该档位真的被强制,而非口头约定」。 + * 1. ~~平台 PermissionRequest 钩子~~ —— 连同 `hooks/` 整个目录一起删了。 + * 它存在的唯一理由是审批 `run_command` / `write_file`;工具没了, + * 问谁都是多余的。**留着一条「钩子是否注册」的判据只会说谎。** + * 2. ~~我们自己的门禁(`lib/action-tools.mjs`)~~ —— 删了。那套双进程审批 + * (MCP 进程问、钩子进程答、落盘表对齐)是随本地 MCP 进程一起消失的; + * 本地 MCP 也没了(MCP 工具面已内置于网关 `POST /api/v1/mcp`)。 + * 3. **`--disallowed-tools` 禁用清单** —— ✅ 仍在,且现在是**唯一**那道。 * - * 查的是驱动自己的文件(它住在插件里),不需要额外配置项。 + * 剩下的判据其实很简单,也更诚实:平台自带的 32 项「能动机器」工具全部 + * 禁用,加上 AgentMail 侧不提供任何执行类工具 ⇒ 模型**没有执行面**。 + * 这是**失败关闭**(安全侧),不是开放。 + * + * 报 `native` 的含义随之收窄为「该档位真的没有执行面,而非口头约定」—— + * 以前它含���「有人会来问」,现在没���人会来问,因为没人需要执行。 + * + * `hooksFile` 参数保留是为了不破坏调用方与判据签名;它现在**不再被读取** + * (读了就会去打开一个已删的文件)。 */ -export function detectModeEnforcement({ hooksFile, env = process.env } = {}) { - const file = hooksFile || fileURLToPath(new URL('../hooks/hooks.json', import.meta.url)); - - // 我们自己那条链:必须是 yolo(平台不问)+ 一张够长的禁用清单。 +export function detectModeEnforcement({ env = process.env } = {}) { const mode = zcodeModeForTier('workspace', env); const denied = denylistForTier('workspace', env); const gateReady = mode === 'yolo' && denied.includes('Bash') && denied.includes('js'); - // 平台那条链:钩子清单里真的注册了 PermissionRequest(桌面模式下才用得上)。 - let hookRegistered = false; - let hookNote; - try { - const cfg = JSON.parse(readFileSync(file, 'utf8')); - const entries = cfg?.hooks?.PermissionRequest; - hookRegistered = Array.isArray(entries) && entries.some(e => Array.isArray(e?.hooks) && e.hooks.length > 0); - hookNote = hookRegistered ? `钩子已注册(${file})` : `钩子清单里没有 PermissionRequest(${file})`; - } catch (e) { - hookNote = `读不到钩子清单(${file}):${e?.message || e}`; - } - if (gateReady) { return { enforcement: 'native', reason: `平台自带危险工具已禁用 ${denied.length} 项(--mode ${mode}),` + - `且 AgentMail 侧不再提供执行类工具(run_command / write_file 已移除)` + - `⇒ 模型无任何执行面` + - (hookRegistered ? ';桌面模式另有平台钩子' : '') + `且 AgentMail 侧不提供执行类工具(run_command / write_file 已随 MCP 内置网关而移除)` + + `⇒ 模型无任何执行面` }; } - if (hookRegistered && modeReachesPermissionHook(mode)) { - return { enforcement: 'native', reason: hookNote }; - } return { enforcement: 'advisory', - reason: hookRegistered ? `钩子已注册但不参与(--mode ${mode})` : `${hookNote},且门禁自检未通过` + reason: + `禁用清单自检未通过(--mode ${mode},已禁用 ${denied.length} 项,` + + `Bash${denied.includes('Bash') ? '✓' : '✗'} / js${denied.includes('js') ? '✓' : '✗'})` + + `—— 本平台无执行门禁兜底,禁用清单就是唯一那道` }; } @@ -247,8 +230,12 @@ export function createDriver({ client, runTurnFn = runTurn, logFn = log, env = p logFn(`已禁用 ${disallowedTools.length} 个自带工具(Bash/Write/Edit/js/…);` + `本轮模型可做的只有收发邮件(执行类工具已不在 MCP 面内)`); if (modeReachesPermissionHook(mode)) { - // 平台会问、我们的钩子会转达 —— 说明档位映射被配置改回了 build/edit。 - logFn(`注意:--mode ${mode} 下平台自带工具会产生权限询问(映射被覆盖过?)`); + // 平台会问人 —— 但**没有人会来回答**(PermissionRequest 钩子已随 + // 执行工具一起移除)。所以这条只是提醒:档位映射被改回了 build/edit, + // 而那种档位下的权限询问会悬着,模型会等到超时。 + // 不是错误(本平台本来就没有执行面),但值得说一声。 + logFn(`注意:--mode ${mode} 下平台会产生权限询问,而审批钩子已移除(` + + `没有执行类工具需要审批)—— 映射被覆盖过?`); } const prompt = buildMailPrompt({ agentName: CFG.agentName, data }); diff --git a/plugins/zcode-mail-bridge/test/action-tools.test.mjs b/plugins/zcode-mail-bridge/test/action-tools.test.mjs deleted file mode 100644 index e476516..0000000 --- a/plugins/zcode-mail-bridge/test/action-tools.test.mjs +++ /dev/null @@ -1,329 +0,0 @@ -/** - * 执行工具(lib/action-tools.mjs)的测试。 - * - * 这些工具是**唯一**能动机器的路径(平台自带的 Bash/Write/Edit/js 已被 - * `--disallowed-tools` 禁掉),所以每条测试都必须同时验两件事: - * - * 1. 结果对不对(执行了 / 返回了什么) - * 2. **在没获批准时,副作用真的没有发生** - * - * 第 2 条不能只看「抛错了」—— 抛错之后照样写文件是最糟的实现方式, - * 而只验抛错完全发现不了。所以拒绝场景一律配一个文件系统断言。 - */ - -import { test } from 'node:test'; -import assert from 'node:assert/strict'; -import { mkdtemp, readFile, rm, stat, mkdir } from 'node:fs/promises'; -import { existsSync } from 'node:fs'; -import { tmpdir } from 'node:os'; -import { join } from 'node:path'; -import { buildActionTools } from '../lib/action-tools.mjs'; -import { createGrantStore } from '../lib/permission-grants.js'; - -/** 假 SSE:立刻发 connected,测试自己投喂决策。 */ -function makeSSE() { - const s = { onEvent: null, stopped: false }; - return { - state: s, - factory: ({ onEvent }) => { - s.onEvent = onEvent; - queueMicrotask(() => onEvent('connected', {})); - return { stop: () => { s.stopped = true; } }; - } - }; -} - -function makeClient({ decision, fail } = {}) { - const state = { requests: [] }; - return { - state, - client: { - baseURL: 'http://gw.test', - authHeaders: () => ({}), - async post(path, body) { - state.requests.push({ path, body }); - if (fail) throw fail; - return {}; - } - } - }; -} - -/** 人都同意场景:请求受理后立刻投喂「同意」。 */ -function approving({ decision = '同意' } = {}) { - const sse = makeSSE(); - const c = makeClient(); - const orig = c.client.post; - c.client.post = async (p, b) => { - await orig(p, b); - queueMicrotask(() => sse.state.onEvent('permission_decision', { relay_key: b.relay_key, decision })); - return {}; - }; - return { ...c, factory: sse.factory }; -} - -async function withTools(env, fn, opts = {}) { - const dir = await mkdtemp(join(tmpdir(), 'zc-act-')); - const c = opts.client || makeClient(); - const tools = buildActionTools({ - client: c.client, - env: { AGENTMAIL_SESSION_ID: 'sess-1', AGENTMAIL_WORKSPACE_ROOT: dir, ...env }, - grants: opts.grants || null, - createSSE: opts.createSSE, - log: () => {} - }); - const byName = new Map(tools.map(t => [t.name, t])); - try { - return await fn({ byName, dir, client: c }); - } finally { - await rm(dir, { recursive: true, force: true }); - } -} - -// ─── 工具面本身 ───────────────────────────────────────────────────────── - -test('★ 工具面只暴露两个执行工具,且都声明为 destructive', async () => { - await withTools({}, async ({ byName }) => { - assert.deepEqual([...byName.keys()].sort(), ['run_command', 'write_file']); - for (const [name, t] of byName) { - assert.equal(t.annotations.readOnlyHint, false, `${name} 不该声称只读`); - // destructiveHint 必须为真:plan 档下平台的判定是 - // 「permissionName==="mcp" && !destructive → allow」,声明成非破坏性会让 - // 这两个工具在只读档被平台放行 —— 那时我们的门禁也会拒,但平台那层 - // 已经先把话说错了。 - assert.equal(t.annotations.destructiveHint, true, `${name} 必须声明为破坏性`); - } - }); -}); - -// ─── run_command ──────────────────────────────────────────────────────── - -test('★ 获批准后真的执行,并返回退出码与输出', async () => { - const c = approving(); - await withTools({ AGENTMAIL_PERMISSION_MODE: 'workspace' }, async ({ byName }) => { - const out = await byName.get('run_command').run({ command: 'echo hello; echo err >&2' }); - assert.match(out, /退出码:0/); - assert.match(out, /hello/); - assert.match(out, /err/); - }, { client: c, createSSE: c.factory }); -}); - -test('★ 拒绝时抛错、且命令真的没执行', async () => { - await withTools({ AGENTMAIL_PERMISSION_MODE: 'plan' }, async ({ byName, dir }) => { - const marker = join(dir, 'should-not-exist.txt'); - await assert.rejects( - () => byName.get('run_command').run({ command: `touch ${marker}` }), - /未获批准/ - ); - assert.equal(existsSync(marker), false, '被拒的命令仍然产生了副作用'); - }); -}); - -test('★ 命令非零退出不是工具失败:原样把退出码与 stderr 交给模型', async () => { - // 抛错会让模型以为工具坏了并重试;而 `grep` 没匹配到、测试失败、 - // 编译报错都是**正常的命令结果**,模型靠 stderr 判断下一步。 - const c = approving(); - await withTools({ AGENTMAIL_PERMISSION_MODE: 'full' }, async ({ byName }) => { - const out = await byName.get('run_command').run({ command: 'echo boom >&2; exit 7' }); - assert.match(out, /退出码:7/); - assert.match(out, /boom/); - }, { client: c, createSSE: c.factory }); -}); - -test('★ 超时被当作命令结果报告(不能挂死整轮)', async () => { - const c = approving(); - await withTools({ AGENTMAIL_PERMISSION_MODE: 'full' }, async ({ byName }) => { - const out = await byName.get('run_command').run({ command: 'sleep 5', timeout_ms: 300 }); - assert.match(out, /退出码:(SIGTERM|null)/); - assert.match(out, /超时被终止/); - assert.match(out, /上限 300ms/); - }, { client: c, createSSE: c.factory }); -}); - -test('★ 输出过长时截断并明确说明截断了多少', async () => { - const c = approving(); - await withTools({ AGENTMAIL_PERMISSION_MODE: 'full' }, async ({ byName }) => { - const out = await byName.get('run_command').run({ command: `seq 1 20000` }); - assert.match(out, /被截断,省略 \d+ 字符/); - assert.ok(out.length < 20000, '截断没生效'); - }, { client: c, createSSE: c.factory }); -}); - -test('★ 工作目录默认是本会话工作区,可用 cwd 覆盖', async () => { - const c = approving(); - await withTools({ AGENTMAIL_PERMISSION_MODE: 'full' }, async ({ byName, dir }) => { - const out = await byName.get('run_command').run({ command: 'pwd' }); - assert.match(out, new RegExp(dir.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))); - }, { client: c, createSSE: c.factory }); -}); - -test('空命令被拒(不浪费一次人工审批)', async () => { - await withTools({ AGENTMAIL_PERMISSION_MODE: 'full' }, async ({ byName }) => { - await assert.rejects(() => byName.get('run_command').run({ command: ' ' }), /command 不能为空/); - }); -}); - -// ─── write_file ───────────────────────────────────────────────────────── - -test('★ 获批准后真的写入文件(含自动建父目录)', async () => { - const c = approving(); - await withTools({ AGENTMAIL_PERMISSION_MODE: 'workspace' }, async ({ byName, dir }) => { - const target = join(dir, 'deep', 'nested', 'a.txt'); - const out = await byName.get('write_file').run({ path: target, content: '内容' }); - assert.match(out, /已写入/); - assert.equal(await readFile(target, 'utf8'), '内容'); - }, { client: c, createSSE: c.factory }); -}); - -test('★ 拒绝时抛错、且不创建文件也不创建目录', async () => { - await withTools({ AGENTMAIL_PERMISSION_MODE: 'plan' }, async ({ byName, dir }) => { - const target = join(dir, 'deep', 'x.txt'); - await assert.rejects(() => byName.get('write_file').run({ path: target, content: 'x' }), /未获批准/); - assert.equal(existsSync(target), false, '被拒的写入仍然产生了文件'); - assert.equal(existsSync(join(dir, 'deep')), false, '被拒的写入仍然创建了目录'); - }); -}); - -test('★ 保护目录:即使有人批准也拒,而且**根本不发审批请求**', async () => { - // 这不是不信任人,而是防自我强化:邮件驱动的 Agent 可能被来信诱导去改 - // 网关数据库/服务单元/自己的插件代码,改完下一轮就换了一套规则。 - // 所以这道判定必须在门禁**之前**,且不能消耗人的注意力。 - const c = approving(); - for (const target of [ - '/opt/agentmail/data/agentmail.db', - '/opt/agentmail/plugins/zcode-mail-bridge/x.mjs', - '/etc/systemd/system/homeagent.service', - '/etc/agentmail/pi.env', - '/root/.agentmail-zcode/secret' - ]) { - await withTools({ AGENTMAIL_PERMISSION_MODE: 'full' }, async ({ byName }) => { - await assert.rejects( - () => byName.get('write_file').run({ path: target, content: 'x' }), - /平台保护目录/, - `${target} 应该被保护` - ); - }, { client: c, createSSE: c.factory }); - } - // 反向对照:保护目录外真的写了(否则上面全绿可能只是因为全都写不进去)。 - await withTools({ AGENTMAIL_PERMISSION_MODE: 'full' }, async ({ byName, dir }) => { - const p = join(dir, 'ok.txt'); - await byName.get('write_file').run({ path: p, content: 'ok' }); - assert.equal(await readFile(p, 'utf8'), 'ok'); - }, { client: c, createSSE: c.factory }); -}); - -test('★ 保护判定不能被路径花招绕过(大小写/相对路径/..)', async () => { - for (const target of [ - '/opt/agentmail/data/../data/agentmail.db', - '/opt/agentmail/./data/x', - '/etc/systemd/system/../system/x.service' - ]) { - await withTools({ AGENTMAIL_PERMISSION_MODE: 'full' }, async ({ byName }) => { - await assert.rejects(() => byName.get('write_file').run({ path: target, content: 'x' }), /平台保护目录/); - }); - } -}); - -test('★ 相对路径按工作区解析(不能靠相对路径逃出工作区之外)', async () => { - await withTools({ AGENTMAIL_PERMISSION_MODE: 'full' }, async ({ byName, dir }) => { - const out = await byName.get('write_file').run({ path: 'sub/rel.txt', content: 'r' }); - assert.match(out, new RegExp('sub/rel.txt')); - assert.equal(await readFile(join(dir, 'sub', 'rel.txt'), 'utf8'), 'r'); - const st = await stat(join(dir, 'sub', 'rel.txt')); - assert.ok(st.isFile()); - }); -}); - -test('content 必须是字符串(否则会写出 "[object Object]")', async () => { - await withTools({ AGENTMAIL_PERMISSION_MODE: 'full' }, async ({ byName }) => { - await assert.rejects(() => byName.get('write_file').run({ path: 'x.txt', content: { a: 1 } }), /必须是字符串/); - await assert.rejects(() => byName.get('write_file').run({ content: 'x' }), /path 不能为空/); - }); -}); - -// ─── 门禁接线 ─────────────────────────────────────────────────────────── - -test('★ 授权请求里带上了人真正需要看的信息(命令原文 / 用途 / 目标路径)', async () => { - const c = approving(); - await withTools({ AGENTMAIL_PERMISSION_MODE: 'workspace' }, async ({ byName, client }) => { - await byName.get('run_command').run({ command: 'rm -rf /tmp/x', purpose: '清理临时文件' }); - const body = client.state.requests.at(-1).body; - assert.equal(body.session_id, 'sess-1'); - assert.match(body.question, /rm -rf \/tmp\/x/, '批准人必须看到命令原文'); - assert.match(body.context, /清理临时文件/, '用途要带给批准人'); - assert.match(body.relay_key, /sess-1/); - }, { client: c, createSSE: c.factory }); -}); - -test('★ 「一直同意」命中时不再打扰人(同一会话同一工具)', async () => { - const grants = createGrantStore(); - grants.grant('sess-1', 'run_command', '一直同意'); - const c = makeClient(); - await withTools({ AGENTMAIL_PERMISSION_MODE: 'workspace' }, async ({ byName }) => { - const out = await byName.get('run_command').run({ command: 'echo granted' }); - assert.match(out, /granted/); - }, { client: c, grants }); - assert.equal(c.state.requests.length, 0, '已有授权却仍然发了审批请求'); -}); - -test('★ full 档不打扰人(发件人已声明全权)', async () => { - const c = makeClient(); - await withTools({ AGENTMAIL_PERMISSION_MODE: 'full' }, async ({ byName }) => { - const out = await byName.get('run_command').run({ command: 'echo full' }); - assert.match(out, /full/); - }, { client: c }); - assert.equal(c.state.requests.length, 0); -}); - -test('★ 网关不可达时 fail closed(不执行、不写文件)', async () => { - const fail = Object.assign(new Error('ECONNREFUSED'), { status: 502 }); - const c = makeClient({ fail }); - const sse = makeSSE(); - await withTools({ AGENTMAIL_PERMISSION_MODE: 'workspace' }, async ({ byName, dir }) => { - const marker = join(dir, 'nope.txt'); - await assert.rejects(() => byName.get('run_command').run({ command: `touch ${marker}` }), /未获批准/); - assert.equal(existsSync(marker), false); - }, { client: c, createSSE: sse.factory }); -}); - -// ─── 等待窗口必须容得下「人真的来点一下」──────────────────────────────── -// 这一组来自一个实测缺陷:工具在等授权,客户端(ZCode)默认 30 秒就把这次 -// MCP 调用掐了,模型于是回报「30 秒内未获批准」——看起来像人没理它, -// 实际是门禁的等待窗口被截断,而且**表现得完全正常**。 - -test('★ 授权等待被夹到 MCP 调用超时之下(并留下可发现的痕迹)', async () => { - const { resolveWaitMs, resolveMcpTimeoutMs } = await import('../lib/action-tools.mjs'); - - // 清单里声明的时间(本插件自己的清单,实测生效:40 秒的命令没被砍) - const declared = resolveMcpTimeoutMs(); - assert.ok(declared && declared >= 60000, `清单应声明一个够长的 timeoutMs,实际 ${declared}`); - - // 配置想等 90 分钟,但 MCP 只给 10 分钟 → 应夹到 10 分钟减余量 - const capped = resolveWaitMs({ AGENTMAIL_PERMISSION_WAIT_MS: '5400000' }, 600000); - assert.ok(capped.waitMs < 600000, '必须小于 MCP 超时,否则调用会先被杀掉'); - assert.ok(capped.waitMs >= 600000 - 120000, '也不该夹得过小(人需要时间点同意)'); - assert.equal(capped.capped, true, '被夹小这件事必须能被发现(要写日志)'); - - // 边界:配置正好等于上限 → 不算被夹(它本来就 settle 得掉) - const onEdge = resolveWaitMs({ AGENTMAIL_PERMISSION_WAIT_MS: String(600000 - 30000) }, 600000); - assert.equal(onEdge.capped, false); - assert.equal(onEdge.waitMs, 570000); - - // 反向对照:配置本来就比 MCP 超时小 → 原样使用,不报「被夹」 - const fine = resolveWaitMs({ AGENTMAIL_PERMISSION_WAIT_MS: '120000' }, 600000); - assert.equal(fine.waitMs, 120000); - assert.equal(fine.capped, false); - - // 反向对照:读不到清单时不猜,沿用配置(并在日志里说没校到) - const unknown = resolveWaitMs({ AGENTMAIL_PERMISSION_WAIT_MS: '540000' }, null); - assert.equal(unknown.waitMs, 540000); - assert.equal(unknown.capped, false); -}); - -test('★ 清单里的 timeoutMs 必须真的存在且够长(否则门禁没有可行窗口)', async () => { - const { resolveMcpTimeoutMs } = await import('../lib/action-tools.mjs'); - const t = resolveMcpTimeoutMs(); - assert.ok(t, '插件清单的 mcpServers.agentmail 必须有 timeoutMs'); - // 默认 30 秒的 MCP 超时下,人根本来不及看到请求 —— 所以必须显式声明一个大的。 - assert.ok(t > 300000, `timeoutMs=${t} 太短,人工审批窗口不够`); -}); diff --git a/plugins/zcode-mail-bridge/test/approval.test.mjs b/plugins/zcode-mail-bridge/test/approval.test.mjs deleted file mode 100644 index 2da2086..0000000 --- a/plugins/zcode-mail-bridge/test/approval.test.mjs +++ /dev/null @@ -1,354 +0,0 @@ -/** - * 授权往返(lib/approval.mjs)的测试。 - * - * 这是全项目最该被测死的一块:它决定「什么算同意」。所以每一组都配了 - * **反向对照** —— 不是只验「同意时放行了」,而要同时验「别的任何东西都不放行」。 - * - * 时序靠注入的假 SSE 控制:真网关的 SSE 是扇出的,假实现只需要保留 - * `onEvent` 回调并在合适的时候投喂事件,就能精确复现「先建连、再发请求、 - * 决策在请求之后到达」以及几个边界(决策在请求之前就到了 / 一直没到 / - * 来的是别人的决策)。 - */ - -import { test } from 'node:test'; -import assert from 'node:assert/strict'; -import { requestApproval, tierOf, hasLocalUi } from '../lib/approval.mjs'; -import { createGrantStore } from '../lib/permission-grants.js'; - -/** 可控的假 SSE:把 onEvent 抓住,测试自己决定何时投喂什么。 */ -function makeSSE() { - const s = { onEvent: null, connected: false, stopped: false }; - const factory = ({ onEvent }) => { - s.onEvent = onEvent; - // 真实现在建连后立刻下发 connected;这里用 microtask 复现「不等它也能跑」。 - queueMicrotask(() => { - s.connected = true; - onEvent('connected', {}); - }); - return { stop: () => { s.stopped = true; } }; - }; - return { factory, s }; -} - -/** 记录请求体;可选在请求成功后投喂一条决定。 - * `onRequest` 的**返回值会被当作 HTTP 响应体**返回给被测代码 —— - * 这一点至关重要:网关的幂等命中是一个 200 + `{status:"duplicate_relay"}`, - * 判据就看它。之前这里硬编码 `return {}`,把响应体丢了,于是「重复请求」 - * 那条测试变成干等到超时,而失败信息看起来像被测代码的 bug。 - */ -function makeClient({ onRequest } = {}) { - const state = { requests: [] }; - const client = { - baseURL: 'http://gw.test', - authHeaders: () => ({ 'X-Agent-Secret': 's' }), - async post(path, body) { - state.requests.push({ path, body }); - if (onRequest) { - const res = await onRequest(state, body); - return res === undefined ? {} : res; - } - return {}; - } - }; - return { client, state }; -} - -const base = env => ({ - toolName: 'run_command', - question: '要执行一条命令', - context: 'echo hi', - sessionId: 'sess-1', - log: () => {}, - env, - ...env -}); - -test('★ plan 档直接拒绝执行类工具,且根本不发请求', async () => { - const { factory } = makeSSE(); - const { client, state } = makeClient(); - const r = await requestApproval({ - ...base({ tier: 'plan', createSSE: factory }) - }); - assert.equal(r.allowed, false); - assert.equal(r.via, 'tier'); - assert.match(r.reason, /plan 档/); - // 反向对照:不该在「注定拒绝」的档位上去打扰人。 - assert.equal(state.requests.length, 0, 'plan 档不该发出授权请求'); -}); - -test('★ full 档直接放行', async () => { - const { client } = makeClient(); - const r = await requestApproval({ toolName: 'run_command', tier: 'full', sessionId: 's1' }); - assert.equal(r.allowed, true); - assert.equal(r.via, 'tier'); -}); - -test('★ 「一直同意」命中时不发请求(钩子与工具共用同一张表)', async () => { - const grants = createGrantStore(); - grants.grant('sess-1', 'run_command', '一直同意'); - const { client, state } = makeClient(); - const r = await requestApproval({ ...base({}), client, tier: 'workspace', grants }); - assert.equal(r.allowed, true); - assert.equal(r.via, 'grant'); - assert.equal(state.requests.length, 0); -}); - -test('★ 人同意 → 放行,且请求里带上了 relay_key 与选项', async () => { - const { factory, s } = makeSSE(); - const { client, state } = makeClient({ - onRequest: async st => { - // 真网关是「先受理、后有人决策」,所以决策必须晚于请求。 - queueMicrotask(() => s.onEvent('permission_decision', { relay_key: st.requests[0].body.relay_key, decision: '同意', decided_by: 'gui-lab' })); - } - }); - const r = await requestApproval({ ...base({}), client, tier: 'workspace', createSSE: factory, waitMs: 1000 }); - assert.equal(r.allowed, true); - assert.equal(r.via, 'human'); - assert.equal(r.decidedBy, 'gui-lab'); - const sent = state.requests[0].body; - assert.equal(sent.session_id, 'sess-1'); - assert.ok(sent.relay_key, '请求必须带 relay_key(决定回执怎么配对)'); - assert.deepEqual(sent.options, ['同意', '一直同意', '拒绝']); - assert.equal(s.stopped, true, 'SSE 必须被关掉(否则短命进程不退出)'); -}); - -test('★ 「一直同意」放行并落进授权表;「同意」不落', async () => { - for (const [decision, shouldPersist] of [ - ['一直同意', true], - ['同意', false] - ]) { - const { factory, s } = makeSSE(); - const grants = createGrantStore(); - const { client } = makeClient({ - onRequest: async st => { - queueMicrotask(() => s.onEvent('permission_decision', { relay_key: st.requests[0].body.relay_key, decision })); - } - }); - const r = await requestApproval({ ...base({}), client, tier: 'workspace', grants, createSSE: factory, waitMs: 1000 }); - assert.equal(r.allowed, true, decision); - assert.equal( - grants.isGranted('sess-1', 'run_command'), - shouldPersist, - `${decision} 的落表行为不对` - ); - } -}); - -test('★ 人拒绝 → 不放行,且原因里带上决策人', async () => { - const { factory, s } = makeSSE(); - const { client } = makeClient({ - onRequest: async st => { - queueMicrotask(() => - s.onEvent('permission_decision', { - relay_key: st.requests[0].body.relay_key, - decision: '拒绝', - decided_by: 'gui-lab', - note: '这条命令会删数据' - }) - ); - } - }); - const r = await requestApproval({ ...base({}), client, tier: 'workspace', createSSE: factory, waitMs: 1000 }); - assert.equal(r.allowed, false); - assert.equal(r.via, 'human'); - assert.match(r.reason, /拒绝/); - assert.match(r.reason, /gui-lab/); - assert.match(r.reason, /会删数据/); -}); - -test('★ 反向对照:一切「不是明确同意」的文本都不放行', async () => { - // 判据是「在放行白名单里」,不是「不等于拒绝」。所以拒绝、看不懂的东西、 - // 平台自己的 shutdown 哨兵、空串都不能放行。 - // - // 注意白名单本身是共用库的前缀匹配(`^同意|一直同意|allow|approve|always|yes`, - // 四个桥共用同一份)。所以「不同意」不放行(前缀不是同意),而「同意吧」放行 —— - // 后者是刻意接受的:决策文本来自界面按钮,前缀匹配是为了容错,不是为了放宽。 - // 这里把两类都钉住,避免哪天有人把前缀匹配改成 includes 而无人发现 - // (那会让「我不同意」变成同意)。 - for (const decision of ['', 'maybe', 'ok?', 'shutdown', 'deny', '拒绝', '不同意', '否', 'no', undefined, null]) { - const { factory, s } = makeSSE(); - const { client } = makeClient({ - onRequest: async st => { - queueMicrotask(() => s.onEvent('permission_decision', { relay_key: st.requests[0].body.relay_key, decision })); - } - }); - const r = await requestApproval({ ...base({}), client, tier: 'workspace', createSSE: factory, waitMs: 300 }); - assert.equal(r.allowed, false, `decision=${JSON.stringify(decision)} 不该放行`); - } - - // 反向对照的对照:确实在白名单里的必须放行,否则上面全绿可能只是因为门槛坏死了。 - for (const decision of ['同意', '一直同意', 'allow', 'yes']) { - const { factory, s } = makeSSE(); - const { client } = makeClient({ - onRequest: async st => { - queueMicrotask(() => s.onEvent('permission_decision', { relay_key: st.requests[0].body.relay_key, decision })); - } - }); - const r = await requestApproval({ ...base({}), client, tier: 'workspace', createSSE: factory, waitMs: 300 }); - assert.equal(r.allowed, true, `decision=${JSON.stringify(decision)} 应当放行`); - } -}); - -test('★ 超时 → 拒绝(不能靠「没消息就是好消息」)', async () => { - const { factory } = makeSSE(); - const { client } = makeClient(); // 从不投喂决策 - const t0 = Date.now(); - const r = await requestApproval({ ...base({}), client, tier: 'workspace', createSSE: factory, waitMs: 120 }); - assert.equal(r.allowed, false); - assert.equal(r.via, 'timeout'); - assert.match(r.reason, /超时/); - assert.ok(Date.now() - t0 >= 100, '必须真的等过,而不是立刻返回'); -}); - -test('★ 别人的决策不能拿来用(relay_key 配对)', async () => { - // 同一个 Agent 可能同时有多个调用在等(模型并行发起两个动作)。 - // 若不按 relay_key 过滤,B 的同意会放行 A。 - const { factory, s } = makeSSE(); - const { client } = makeClient({ - onRequest: async st => { - const mine = st.requests[0].body.relay_key; - queueMicrotask(() => { - s.onEvent('permission_decision', { relay_key: `${mine}-other`, decision: '同意' }); - setTimeout(() => s.onEvent('permission_decision', { relay_key: mine, decision: '拒绝' }), 30); - }); - } - }); - const r = await requestApproval({ ...base({}), client, tier: 'workspace', createSSE: factory, waitMs: 1000 }); - assert.equal(r.allowed, false, '拿到别人的「同意」就是越权放行'); - assert.match(r.reason, /拒绝/); -}); - -test('★ 永久失败(409 无人可问)当场拒绝,并把服务端建议带给模型', async () => { - const { factory } = makeSSE(); - const err = Object.assign(new Error('409'), { - status: 409, - body: { error: '本线索内找不到可决策的人类', suggestion: '请让发件人把档位改成 full' } - }); - const { client } = makeClient({ - onRequest: async () => { - throw err; - } - }); - const r = await requestApproval({ ...base({}), client, tier: 'workspace', createSSE: factory, waitMs: 1000 }); - assert.equal(r.allowed, false); - assert.equal(r.via, 'permanent-failure'); - assert.match(r.reason, /找不到可决策的人类/); - assert.match(r.reason, /改成 full/, '服务端的建议必须原样带给模型,否则它只能盲试'); -}); - -test('★ 暂时失败:没有本地界面时必须拒绝(fail closed)', async () => { - const { factory } = makeSSE(); - const { client } = makeClient({ - onRequest: async () => { - throw new Error('ECONNREFUSED'); - } - }); - const r = await requestApproval({ ...base({}), client, tier: 'workspace', createSSE: factory, waitMs: 1000 }); - assert.equal(r.allowed, false); - assert.equal(r.via, 'transport'); - assert.match(r.reason, /没有本地界面/); -}); - -test('★ 暂时失败:有本地界面时明确说明没有放行', async () => { - // 桌面模式下平台自己还有流程,所以这里不放行是安全的 —— 但**不能说**放行了。 - const { factory } = makeSSE(); - const { client } = makeClient({ - onRequest: async () => { - throw new Error('ECONNREFUSED'); - } - }); - const r = await requestApproval({ toolName: 'Bash', tier: 'workspace', client, createSSE: factory, waitMs: 1000 }); - assert.equal(r.allowed, false); - assert.match(r.reason, /授权询问失败/); -}); - -test('★ 「有没有本地界面」由调用方传的 sessionId 判定(单一事实来源)', async () => { - // 反向对照:同一个暂时失败,在「有会话」与「没会话」下必须给出不同的拒绝理由。 - // 这里刻意把 process.env.AGENTMAIL_SESSION_ID 设成反的,验证模块**不看它** —— - // 两个事实来源不一致时,谁也说不清到底算有界面还是没界面。 - const prev = process.env.AGENTMAIL_SESSION_ID; - process.env.AGENTMAIL_SESSION_ID = '来自进程环境的干扰值'; - try { - const mk = () => { - const { factory } = makeSSE(); - const { client } = makeClient({ onRequest: async () => { throw new Error('boom'); } }); - return { factory, client }; - }; - const a = mk(); - const withSession = await requestApproval({ - ...base({}), client: a.client, tier: 'workspace', createSSE: a.factory, waitMs: 500 - }); - assert.match(withSession.reason, /没有本地界面/, '带会话 = 邮件驱动,必须 fail closed'); - - const b = mk(); - const noSession = await requestApproval({ - toolName: 'Bash', tier: 'workspace', client: b.client, createSSE: b.factory, waitMs: 500 - }); - assert.doesNotMatch(noSession.reason, /没有本地界面/, '不带会话 = 有界面,不该说成没界面'); - } finally { - if (prev === undefined) delete process.env.AGENTMAIL_SESSION_ID; - else process.env.AGENTMAIL_SESSION_ID = prev; - } -}); - -test('tierOf / hasLocalUi 的判据', () => { - assert.equal(tierOf({}), 'workspace'); - assert.equal(tierOf({ AGENTMAIL_PERMISSION_MODE: 'full' }), 'full'); - // 认不出来的值 → workspace(共用库的约定),不是「免问」 - assert.equal(tierOf({ AGENTMAIL_PERMISSION_MODE: 'FULL' }), 'workspace'); - // 有会话 id = 邮件驱动 = 没有本地界面 - assert.equal(hasLocalUi({}), true); - assert.equal(hasLocalUi({ AGENTMAIL_SESSION_ID: 'sess-1' }), false); - assert.equal(hasLocalUi({ AGENTMAIL_SESSION_ID: ' ' }), true, '空白串不算会话'); -}); - -// ─── 幂等键必须按「这一次调用」唯一 ───────────────────────────────────── -// 一个实测缺陷,失败方式极隐蔽:键取成「会话+工具」之后,同一会话里**第二次** -// run_command 被网关判成重复请求 → HTTP 200 duplicate_relay → 请求**没发出去**、 -// 永远没人来决策 → 工具干等到被 MCP 调用超时砍掉 → 模型回报「30 秒内未获批准」。 -// 从状态码到措辞全都看不出问题,归因还完全错了(像是人没理它)。 - -test('★ 同一会话同一工具的两次调用必须用不同的幂等键', async () => { - const keys = []; - for (let i = 0; i < 2; i++) { - const { factory, s } = makeSSE(); - const { client } = makeClient({ - onRequest: async st => { - keys.push(st.requests[0].body.relay_key); - queueMicrotask(() => s.onEvent('permission_decision', { relay_key: st.requests[0].body.relay_key, decision: '同意' })); - } - }); - const r = await requestApproval({ ...base({}), client, tier: 'workspace', createSSE: factory, waitMs: 500 }); - assert.equal(r.allowed, true); - } - assert.equal(keys.length, 2); - assert.notEqual(keys[0], keys[1], '两次调用的键相同 ⇒ 第二次会被网关当重复丢弃'); - // 键里仍保留会话与工具,便于事后从邮件反查(但唯一性来自随机尾) - assert.match(keys[0], /sess-1/); - assert.match(keys[0], /run_command/); -}); - -test('★ 网关判为重复请求时当场拒绝(不能干等到被超时砍掉)', async () => { - const { factory } = makeSSE(); - const { client } = makeClient({ - onRequest: async () => ({ status: 'duplicate_relay', detail: '该权限询问已转发过,本次调用未产生新邮件' }) - }); - const t0 = Date.now(); - const r = await requestApproval({ ...base({}), client, tier: 'workspace', createSSE: factory, waitMs: 60000 }); - const dt = Date.now() - t0; - assert.equal(r.allowed, false); - assert.equal(r.via, 'duplicate-relay'); - assert.match(r.reason, /重复/); - assert.match(r.reason, /没有人会看到这次询问/); - assert.ok(dt < 5000, `必须立刻返回,实际等了 ${dt}ms(说明它在干等一个永远不会来的决策)`); -}); - -test('★ 反向对照:正常的 200(非 duplicate_relay)仍要等决策', async () => { - // 否则上面那条可能只是因为「任何 200 都被当成重复」。 - const { factory } = makeSSE(); - const { client } = makeClient({ - onRequest: async () => ({ status: 'pending' }) - }); - const r = await requestApproval({ ...base({}), client, tier: 'workspace', createSSE: factory, waitMs: 150 }); - assert.equal(r.via, 'timeout', '非重复的正常请求应该等,然后超时'); -}); diff --git a/plugins/zcode-mail-bridge/test/generic-mcp.test.mjs b/plugins/zcode-mail-bridge/test/generic-mcp.test.mjs deleted file mode 100644 index b147166..0000000 --- a/plugins/zcode-mail-bridge/test/generic-mcp.test.mjs +++ /dev/null @@ -1,169 +0,0 @@ -/** - * 通用化(去 ZCode 影子)的看守判据。 - * - * ★ 2026-10-02 新增。这三条改动的判别力此前**无人看守**: - * 变异验证时「把 action-tools 挂回 server.mjs」与「platform 硬编码回 zcode」 - * 都能通过全套测试 —— 因为没有一条判据看 server.mjs 实际挂了什么、 - * 也没有一条看注册时上报的 platform。改动本身是对的,但没有判据就等于 - * 下次谁都能悄悄改回去。 - * - * 这里验的都是**结构**(源码 + 模块行为),不联网。 - */ - -import { test } from 'node:test'; -import assert from 'node:assert/strict'; -import { readFile } from 'node:fs/promises'; -import { fileURLToPath } from 'node:url'; -import { dirname, join } from 'node:path'; -import { GatewayClient } from '../lib/gateway.mjs'; -import { buildTools, indexTools } from '../lib/tools.mjs'; - -const HERE = dirname(fileURLToPath(import.meta.url)); -const ROOT = join(HERE, '..'); - -/** - * server.mjs 真实挂载的工具清单。 - * - * 走真实模块(main 会挂 stdio,但工具集合只依赖 client/agentName), - * 复刻它那段装配逻辑 —— 直接 import 会启动服务器。 - */ -function mountedToolNames() { - const client = new GatewayClient({ - AGENTMAIL_AGENT_NAME: 'probe', - AGENTMAIL_AGENT_KEY: 'k' - }); - return [...indexTools(buildTools({ client, agentName: client.agentName })).keys()]; -} - -test('★ MCP 面**不含**会动机器的工具(run_command / write_file 已移除)', () => { - const names = mountedToolNames(); - // 这两个是「会动机器」的。它们的门禁为 ZCode headless 双进程审批设计, - // 脱离该宿主后语义不成立 —— 挂在通用 MCP 服务上等于给人一个没门禁的旁路。 - assert.ok(!names.includes('run_command'), 'run_command 不得回到通用 MCP 面'); - assert.ok(!names.includes('write_file'), 'write_file 不得回到通用 MCP 面'); - // 工具清单就这些 —— 挂进来的每个工具都是产品决策,不是实现细节 - assert.deepEqual(names.sort(), [ - 'connect_to_server', - 'download_attachment', - 'forward_mail', - 'list_contacts', - 'read_inbox', - 'read_mail', - 'read_thread', - 'send_mail', - 'session_participants', - 'suggest_address', - 'upload_attachment' - ]); -}); - -test('★ server.mjs 源码不 import action-tools / grants-file(结构层钉死,不只验运行结果)', async () => { - const src = await readFile(join(ROOT, 'mcp', 'server.mjs'), 'utf8'); - assert.doesNotMatch(src, /action-tools/, 'server.mjs 不该再 import 执行类工具'); - assert.doesNotMatch(src, /grants-file/, '授权表是 ZCode 钩子的东西,不属通用 MCP 面'); - // 反向对照:它必须真的挂上了邮件工具,否则「没挂 action-tools」可能只是空文件 - assert.match(src, /buildTools/, 'server.mjs 必须装配邮件工具'); -}); - -test('★ platform 可配置且默认 mcp(通用服务不冒充任何宿主)', () => { - // 默认 - const dflt = new GatewayClient({ AGENTMAIL_AGENT_NAME: 'a', AGENTMAIL_AGENT_KEY: 'k' }); - assert.equal(dflt.platform, 'mcp'); - - // 可配置:宿主可自报平台名(如 codex / claude-code),便于服务端统计 - const custom = new GatewayClient({ - AGENTMAIL_AGENT_NAME: 'a', - AGENTMAIL_AGENT_KEY: 'k', - AGENTMAIL_MCP_PLATFORM: 'my-host' - }); - assert.equal(custom.platform, 'my-host'); - - // 空白值不得变成空字符串(会让服务端统计出一个空平台) - const blank = new GatewayClient({ - AGENTMAIL_AGENT_NAME: 'a', - AGENTMAIL_AGENT_KEY: 'k', - AGENTMAIL_MCP_PLATFORM: ' ' - }); - assert.equal(blank.platform, 'mcp', '空白 platform 必须回落默认值,不能是空串'); -}); - -test('★ 注册载荷带可配置 platform,且源码里不残留 zcode 字面量', async () => { - const gw = await readFile(join(ROOT, 'lib', 'gateway.mjs'), 'utf8'); - // 注册时上报的是 client.platform,不是硬编码 - assert.match(gw, /platform: this\.platform/, 'register 必须用可配置的 platform'); - assert.doesNotMatch(gw, /platform: 'zcode'/, 'register 不得硬编码 zcode'); - - // 面向用户的文案不得把宿主名写死 —— 通用服务提着 ZCode 说「请在 ZCode 的 - // 插件设置里填写」,会让人去一个不存在的界面找配置。 - const tools = await readFile(join(ROOT, 'lib', 'tools.mjs'), 'utf8'); - assert.doesNotMatch(tools, /请在 ZCode 的插件设置里/, '错误文案不得指向 ZCode 插件设置'); -}); - -test('★ connect_to_server 对 secret-only 的 Agent 也能注册(实测 400 过)', async () => { - // 根因:`/agent/register` 只认 `Authorization: Bearer` 或 **body 里的 secret**, - // 不认 `X-Agent-Secret` 头。而 connect_to_server 早前只发了那个头 ⇒ - // dsh / pi 这类 secret-only 的 Agent 调它必得 400,且模型看不出该改什么。 - // - // 这条是**行为**判据:真起一个 fetch 替身,按 secret-only 装配调用该工具, - // 检查请求体里确实带了 secret。 - const src = await readFile(join(ROOT, 'lib', 'tools.mjs'), 'utf8'); - assert.match( - src, - /secret:\s*client\.agentSecret/, - 'connect_to_server 在没有 Bearer 时必须把 secret 放进请求体' - ); - - // 端到端:真调一次工具,拦截 fetch 看它发出去什么。 - let captured = null; - const fakeFetch = async (url, init) => { - captured = { url, init }; - return { - ok: true, - status: 200, - text: async () => JSON.stringify({ status: 'registered' }) - }; - }; - const realFetch = globalThis.fetch; - globalThis.fetch = fakeFetch; - try { - const tools = buildTools({ - client: { - baseURL: 'http://fake', - agentName: 'dsh', - agentKey: '', - agentSecret: 'sekrit', - platform: 'mcp', - checkConfig: () => [], - get: async () => ({}), - post: async () => ({}), - uploadFile: async () => ({}), - downloadFile: async () => Buffer.alloc(0) - }, - agentName: 'dsh' - }); - const byName = indexTools(tools); - await byName.get('connect_to_server').run({}); - assert.ok(captured, 'connect_to_server 应当真的发出请求'); - const body = JSON.parse(captured.init.body); - assert.equal(body.secret, 'sekrit', 'secret-only 装配时 body 必须带 secret'); - assert.equal(body.platform, 'mcp', 'platform 仍应是可配置值'); - // 反向对照:没有 secret 时不能硬塞空串(那是另一种错) - const tools2 = buildTools({ - client: { - baseURL: 'http://fake', agentName: 'a', agentKey: 'key', agentSecret: '', - platform: 'mcp', checkConfig: () => [], get: async () => ({}), post: async () => ({}), - uploadFile: async () => ({}), downloadFile: async () => Buffer.alloc(0) - }, - agentName: 'a' - }); - captured = null; - await indexTools(tools2).get('connect_to_server').run({}); - assert.equal( - JSON.parse(captured.init.body).secret, - undefined, - '有 Bearer 时 body 不该塞 secret' - ); - } finally { - globalThis.fetch = realFetch; - } -}); \ No newline at end of file diff --git a/plugins/zcode-mail-bridge/test/grants-file.test.mjs b/plugins/zcode-mail-bridge/test/grants-file.test.mjs deleted file mode 100644 index 3ed5ce9..0000000 --- a/plugins/zcode-mail-bridge/test/grants-file.test.mjs +++ /dev/null @@ -1,98 +0,0 @@ -/** - * 「一直同意」的跨进程持久化测试。 - * - * 这个功能的判据只有一条最要紧:**下一个进程还认不认**。 - * 钩子一封(一次工具调用)一个进程,所以「记在内存里」等于没记。 - */ - -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 deleted file mode 100644 index a6258e4..0000000 --- a/plugins/zcode-mail-bridge/test/hook-policy.test.mjs +++ /dev/null @@ -1,98 +0,0 @@ -/** - * 授权钩子策略层的测试。 - * - * 档位判定是**给产品定的、不是给平台定的**:同一条「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/inbox-session-scope.test.mjs b/plugins/zcode-mail-bridge/test/inbox-session-scope.test.mjs deleted file mode 100644 index 8a373b8..0000000 --- a/plugins/zcode-mail-bridge/test/inbox-session-scope.test.mjs +++ /dev/null @@ -1,32 +0,0 @@ -/** - * read_inbox 必须收窄到**自己那条会话**(用户:「你还是没修好不同 session agent - * 收件箱隔离的问题」)。 - * - * zcode 的特殊之处:一轮一个进程,驱动已经把本轮邮件会话注入 AGENTMAIL_SESSION_ID - * (授权钩子本来就用它)⇒ 直接读 env 即可,天然并发安全。 - */ -import { test } from 'node:test'; -import assert from 'node:assert/strict'; -import { readFileSync } from 'node:fs'; -import { dirname, join } from 'node:path'; -import { fileURLToPath } from 'node:url'; - -const HERE = dirname(fileURLToPath(import.meta.url)); -const tools = readFileSync(join(HERE, '..', 'lib', 'tools.mjs'), 'utf8'); -const index = readFileSync(join(HERE, '..', 'src', 'index.mjs'), 'utf8'); - -test('驱动确实注入了本轮邮件会话', () => { - assert.match(index, /AGENTMAIL_SESSION_ID: sessionId/, '驱动要把邮件会话传下去'); -}); - -test('read_inbox 会拼上会话收窄,且在调用时读 env', () => { - assert.match(tools, /const mailSessionID = process\.env\.AGENTMAIL_SESSION_ID \|\| ''/, '调用时读(不是 import 时读死)'); - assert.match(tools, /mail\/inbox\?status=\$\{encodeURIComponent\(status\)\}&limit=\$\{limit\}\$\{scope\}/); - assert.match(tools, /session_id=\$\{encodeURIComponent\(mailSessionID\)\}/); -}); - -test('★ 判据自检:旧写法(不带 env、不带 scope)必须判红', () => { - const old = '`/mail/inbox?status=${encodeURIComponent(status)}&limit=${limit}`'; - assert.ok(!/\$\{scope\}/.test(old)); - assert.ok(!/AGENTMAIL_SESSION_ID/.test(old)); -}); diff --git a/plugins/zcode-mail-bridge/test/is-main.test.mjs b/plugins/zcode-mail-bridge/test/is-main.test.mjs index 7ea117a..cb449c9 100644 --- a/plugins/zcode-mail-bridge/test/is-main.test.mjs +++ b/plugins/zcode-mail-bridge/test/is-main.test.mjs @@ -41,15 +41,15 @@ test('★ 通过软链指向自己 → 仍是入口(生产布局就是软链 }); test('★ 目录软链(current → <时间戳>)下的完整路径同样成立', async () => { - // 生产的软链在**目录**这一层:/opt/...//current/mcp/server.mjs + // 生产的软链在**目录**这一层:/opt/...//current/src/index.mjs const dir = await mkdtemp(join(tmpdir(), 'zc-is-main-d-')); try { - await mkdir(join(dir, '20260101-000000', 'mcp'), { recursive: true }); - const real = join(dir, '20260101-000000', 'mcp', 'server.mjs'); + await mkdir(join(dir, '20260101-000000', 'src'), { recursive: true }); + const real = join(dir, "20260101-000000", "src", "index.mjs"); await writeFile(real, '// x\n', 'utf8'); await symlink('20260101-000000', join(dir, 'current')); assert.equal( - isMainModule(pathToFileURL(real).href, join(dir, 'current', 'mcp', 'server.mjs')), + isMainModule(pathToFileURL(real).href, join(dir, "current", "src", "index.mjs")), true ); } finally { diff --git a/plugins/zcode-mail-bridge/test/mcp-rpc.test.mjs b/plugins/zcode-mail-bridge/test/mcp-rpc.test.mjs deleted file mode 100644 index 252a223..0000000 --- a/plugins/zcode-mail-bridge/test/mcp-rpc.test.mjs +++ /dev/null @@ -1,212 +0,0 @@ -/** - * MCP 协议层的测试。 - * - * 这一层是手写的,所以它必须被穷举 —— 否则「工具没出现」「模型收不到错误」 - * 这类问题只能连上 ZCode 才能发现,而那时线索要少得多。 - * - * 每条断言都对应一个**真实的失败模式**,不是为覆盖率写的。 - */ - -import { test } from 'node:test'; -import assert from 'node:assert/strict'; -import { handleMessage, handleLine, RPC_ERROR } from '../lib/mcp-rpc.mjs'; - -const TOOLS = [ - { name: 'read_inbox', description: '读收件箱', inputSchema: { type: 'object' } }, - { name: 'send_mail', description: '发信', inputSchema: { type: 'object' } } -]; - -/** 造一个 ctx;`call` 默认成功,可换成抛错来验失败路径。 */ -const makeCtx = (impl = async () => '结果文本') => ({ - tools: TOOLS, - call: impl -}); - -test('initialize 回显客户端给的协议版本', async () => { - const out = await handleMessage( - { jsonrpc: '2.0', id: 1, method: 'initialize', params: { protocolVersion: '2025-03-26' } }, - makeCtx() - ); - assert.equal(out.result.protocolVersion, '2025-03-26'); - assert.deepEqual(out.result.capabilities, { tools: { listChanged: false } }); - assert.equal(out.result.serverInfo.name, 'agentmail'); -}); - -test('initialize 缺参数时用默认版本兜底,而不是崩', async () => { - const out = await handleMessage({ jsonrpc: '2.0', id: 1, method: 'initialize' }, makeCtx()); - assert.ok(out.result.protocolVersion); -}); - -test('notifications/initialized 不回响应(回了会让后续调用错配)', async () => { - const out = await handleMessage( - { jsonrpc: '2.0', method: 'notifications/initialized' }, - makeCtx() - ); - assert.equal(out, null); -}); - -test('任何无 id 的消息都不回响应', async () => { - const out = await handleMessage({ jsonrpc: '2.0', method: 'tools/list' }, makeCtx()); - assert.equal(out, null); -}); - -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) { - assert.deepEqual(Object.keys(t).sort(), ['description', 'inputSchema', 'name']); - } -}); - -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: {} } }, - makeCtx() - ); - assert.deepEqual(out.result, { content: [{ type: 'text', text: '结果文本' }] }); - assert.equal(out.result.isError, undefined); -}); - -test('tools/call 把 arguments 原样交给工具', async () => { - let seen = null; - const ctx = makeCtx(async (name, args) => { - seen = { name, args }; - return 'ok'; - }); - await handleMessage( - { - jsonrpc: '2.0', - id: 4, - method: 'tools/call', - params: { name: 'send_mail', arguments: { to: 'admin@/tmp', subject: 's' } } - }, - ctx - ); - assert.deepEqual(seen, { name: 'send_mail', args: { to: 'admin@/tmp', subject: 's' } }); -}); - -test('tools/call 缺 arguments 时当空对象,不抛错', async () => { - let seen = null; - const ctx = makeCtx(async (name, args) => { - seen = args; - return 'ok'; - }); - const out = await handleMessage( - { jsonrpc: '2.0', id: 5, method: 'tools/call', params: { name: 'read_inbox' } }, - ctx - ); - assert.deepEqual(seen, {}); - assert.equal(out.result.isError, undefined); -}); - -test('★ 工具执行失败回 result+isError,不回 JSON-RPC error', async () => { - // 判据的关键:模型必须能看到失败原因。若回 JSON-RPC error, - // 客户端只会显示一次协议错误,模型拿不到「为什么失败」, - // 也就无法改正(opencode 上连试 6 次发不出附件就是这个后果)。 - const ctx = makeCtx(async () => { - throw new Error('HTTP 409:附件已随其他邮件发出'); - }); - const out = await handleMessage( - { jsonrpc: '2.0', id: 6, method: 'tools/call', params: { name: 'send_mail', arguments: {} } }, - ctx - ); - assert.equal(out.error, undefined, '不该是 JSON-RPC error'); - assert.equal(out.result.isError, true); - assert.match(out.result.content[0].text, /附件已随其他邮件发出/); -}); - -test('★ 反向对照:成功时绝不带 isError', async () => { - // 与上一条构成对照:同样的入参、同样的方法,只翻转工具行为, - // isError 必须跟着翻转。否则「总是 isError」也会让上一条通过。 - const ok = await handleMessage( - { jsonrpc: '2.0', id: 7, method: 'tools/call', params: { name: 'send_mail', arguments: {} } }, - makeCtx() - ); - const bad = await handleMessage( - { jsonrpc: '2.0', id: 8, method: 'tools/call', params: { name: 'send_mail', arguments: {} } }, - makeCtx(async () => { - throw new Error('x'); - }) - ); - assert.equal(ok.result.isError, undefined); - assert.equal(bad.result.isError, true); -}); - -test('tools/call 未知工具名回 INVALID_PARAMS', async () => { - const out = await handleMessage( - { jsonrpc: '2.0', id: 9, method: 'tools/call', params: { name: 'not_a_tool' } }, - makeCtx() - ); - assert.equal(out.error.code, RPC_ERROR.INVALID_PARAMS); - assert.equal(out.result, undefined); -}); - -test('tools/call 缺 name 回 INVALID_PARAMS', async () => { - const out = await handleMessage( - { jsonrpc: '2.0', id: 10, method: 'tools/call', params: {} }, - makeCtx() - ); - assert.equal(out.error.code, RPC_ERROR.INVALID_PARAMS); -}); - -test('未知方法回 METHOD_NOT_FOUND', async () => { - const out = await handleMessage({ jsonrpc: '2.0', id: 11, method: 'x/y' }, makeCtx()); - assert.equal(out.error.code, RPC_ERROR.METHOD_NOT_FOUND); -}); - -test('ping 有响应', async () => { - const out = await handleMessage({ jsonrpc: '2.0', id: 12, method: 'ping' }, makeCtx()); - assert.deepEqual(out.result, {}); -}); - -test('缺 method 回 INVALID_REQUEST', async () => { - const out = await handleMessage({ jsonrpc: '2.0', id: 13 }, makeCtx()); - assert.equal(out.error.code, RPC_ERROR.INVALID_REQUEST); -}); - -test('id 原样回显(含 0 与字符串 id)', async () => { - for (const id of [0, 'abc', 42]) { - const out = await handleMessage({ jsonrpc: '2.0', id, method: 'ping' }, makeCtx()); - assert.equal(out.id, id); - } -}); - -// ─── handleLine:分帧与解析 ─────────────────────────────────────── -test('handleLine 空行不产生响应', async () => { - assert.equal(await handleLine('', makeCtx()), null); - assert.equal(await handleLine(' ', makeCtx()), null); -}); - -test('handleLine 非法 JSON 回带 id=null 的解析错误', async () => { - // 必须回:不回的话客户端会一直等这一条的响应。 - const out = await handleLine('{not json', makeCtx()); - const parsed = JSON.parse(out); - assert.equal(parsed.error.code, RPC_ERROR.PARSE); - assert.equal(parsed.id, null); -}); - -test('handleLine 输出是单行(换行会破坏分帧)', async () => { - const out = await handleLine( - JSON.stringify({ jsonrpc: '2.0', id: 14, method: 'tools/call', params: { name: 'read_inbox' } }), - makeCtx(async () => '多行\n文本\n在此') - ); - assert.equal(out.includes('\n'), false, '响应里不能有裸换行(应被转义进 JSON 字符串)'); - assert.match(JSON.parse(out).result.content[0].text, /多行\n文本/); -}); diff --git a/plugins/zcode-mail-bridge/test/read-scope-wiring.test.mjs b/plugins/zcode-mail-bridge/test/read-scope-wiring.test.mjs deleted file mode 100644 index ea2ae39..0000000 --- a/plugins/zcode-mail-bridge/test/read-scope-wiring.test.mjs +++ /dev/null @@ -1,61 +0,0 @@ -/** - * 五个读类端点的请求都要带上**自己那条邮件会话**(zcode)。 - * - * read_inbox 早就有这一维(缺陷:列表按 Agent 列且按契约标已读 ⇒ A 会话标掉 - * B 会话的未读 ⇒ 静默丢信)。服务端现在拿它多干一件事:**由这条会话反查工作区**, - * 只有同工作区的会话才放行 —— 一个 Agent 同时服务所有工作区,不收窄时在 TrueAgent - * 里干活的 worker 能读到 agentmail 的整条线索(用户 2026-09-14 报的越界)。 - * - * 服务端语义由 server/internal/repo/workspace_scope_test.go 负责;这里只验接线。 - * - * 判据两侧都钉:**包住了**(withScope(...) 的整句存在)与**没包住**(去掉包装的 - * 那句不存在)。只验前者的话,把 withScope 写成恒等函数也能过。 - */ -import { test } from 'node:test'; -import assert from 'node:assert/strict'; -import { readFileSync } from 'node:fs'; -import { dirname, join } from 'node:path'; -import { fileURLToPath } from 'node:url'; - -const HERE = dirname(fileURLToPath(import.meta.url)); -const tools = readFileSync(join(HERE, '..', 'lib', 'tools.mjs'), 'utf8'); - -// 每个端点两句话:包住的 / 没包住的。 -const ENDPOINTS = [ - ['read_mail', - 'withScope(`/agent/mail/${encodeURIComponent(id)}?body_limit=0`)', - 'client.get(`/agent/mail/${encodeURIComponent(id)}?body_limit=0`)'], - ['read_thread', - 'withScope(`/agent/mail/${encodeURIComponent(id)}/thread${qs}`)', - 'client.get(`/agent/mail/${encodeURIComponent(id)}/thread${qs}`)'], - ['suggest_address', - 'withScope(`/agent/contacts/suggest?${qs.toString()}`)', - 'client.get(`/agent/contacts/suggest?${qs.toString()}`)'], - ['list_contacts', - 'withScope(`/agent/contacts?limit=${limit}`)', - 'client.get(`/agent/contacts?limit=${limit}`)'], - ['session_participants', - 'withScope(`/agent/sessions/${encodeURIComponent(sid)}/participants`)', - 'client.get(`/agent/sessions/${encodeURIComponent(sid)}/participants`)'], -]; - -for (const [name, scoped, bare] of ENDPOINTS) { - test(`★ ${name} 的请求走 withScope(...)`, () => { - assert.ok(bare.includes('client.get('), '夹具形状不对:对照组必须是没包住的那句'); - assert.ok(tools.includes(scoped), `${name} 的 URL 没有包在 withScope 里:${scoped}`); - assert.ok(!tools.includes(bare), `${name} 还有一处没包住的写法:${bare}`); - }); -} - -test('★ forward_mail 也带上收窄(它读的是原文)', () => { - const scoped = 'withScope(`/mail/${encodeURIComponent(str(a.mail_id))}/forward`)'; - const bare = 'client.post(\n `/mail/${encodeURIComponent(str(a.mail_id))}/forward`,'; - assert.ok(tools.includes(scoped), '转发的 URL 没有包在 withScope 里'); - assert.ok(!tools.includes(bare), '转发还有一处没包住的写法'); -}); - -test('withScope 在调用时读 env,且自己判断分隔符', () => { - assert.match(tools, /const sid = process\.env\.AGENTMAIL_SESSION_ID \|\| ''/, '调用时读,不是 import 时读死'); - assert.ok(tools.includes("path.includes('?') ? '&' : '?'"), '已有查询串要用 & 分隔'); - assert.match(tools, /if \(!sid\) return path/, '拿不到会话就原样返回,不拼半截 URL'); -}); diff --git a/plugins/zcode-mail-bridge/test/tools.test.mjs b/plugins/zcode-mail-bridge/test/tools.test.mjs deleted file mode 100644 index d6b2e6e..0000000 --- a/plugins/zcode-mail-bridge/test/tools.test.mjs +++ /dev/null @@ -1,244 +0,0 @@ -/** - * 工具层的测试。 - * - * 用假客户端,不发真请求 —— 这里要验的是**参数处理与渲染**, - * 那才是各平台容易走样的地方(真请求由端到端演练覆盖)。 - */ - -import { test } from 'node:test'; -import assert from 'node:assert/strict'; -import { readFile, writeFile, mkdtemp } from 'node:fs/promises'; -import { tmpdir } from 'node:os'; -import { fileURLToPath } from 'node:url'; -import { dirname, join } from 'node:path'; -import { buildTools, indexTools } from '../lib/tools.mjs'; - -const HERE = dirname(fileURLToPath(import.meta.url)); - -/** 造一个假客户端:记录调用,按需返回。 */ -function fakeClient({ config = [], responses = {} } = {}) { - const calls = []; - return { - calls, - baseURL: 'http://fake', - agentName: 'zcode', - checkConfig: () => config, - async get(path) { - calls.push({ method: 'GET', path }); - for (const key of Object.keys(responses)) { - if (path.startsWith(key)) return responses[key]; - } - return {}; - }, - async post(path, body) { - calls.push({ method: 'POST', path, body }); - return { mail_id: 'sent-1', session_id: 'sess-1' }; - }, - async uploadFile() { - return { attachment_id: 'att-1', filename: 'a.txt', size_bytes: 12 }; - }, - async downloadFile() { - return Buffer.from('hello'); - } - }; -} - -const toolsOf = client => indexTools(buildTools({ client, agentName: 'zcode' })); - -test('工具数量与名称稳定(改名会破坏跨平台一致性)', () => { - const t = toolsOf(fakeClient()); - assert.deepEqual([...t.keys()].sort(), [ - 'connect_to_server', - 'download_attachment', - 'forward_mail', - 'list_contacts', - 'read_inbox', - 'read_mail', - 'read_thread', - 'send_mail', - 'session_participants', - 'suggest_address', - 'upload_attachment' - ]); -}); - -test('★ 与 pi 桥的工具名逐一对齐(少一个就会让某平台「不会回信」)', async () => { - // 跨平台一致性是被真实问题逼出来的约定:模型在某个平台上找不到 - // 熟悉的工具名,行为就与其它平台不同。这条断言让「改名」在 CI 里红, - // 而不是等到某个平台的演练才发现。 - const piSrc = await readFile( - join(HERE, '../../pi-mail-bridge/src/tools.mjs'), - 'utf8' - ).catch(() => null); - if (piSrc === null) { - // pi 桥不在旁边(例如插件被单独拷走)时无法对照 —— - // 明确说明跳过,而不是假装通过。 - assert.ok(true, '跳过:找不到 pi 桥源码用于对照'); - return; - } - const piNames = new Set([...piSrc.matchAll(/name:\s*'([a-z_]+)'/g)].map(m => m[1])); - const mine = new Set(toolsOf(fakeClient()).keys()); - const missing = [...piNames].filter(n => !mine.has(n)); - assert.deepEqual(missing, [], `本插件缺少 pi 桥有的工具:${missing.join(', ')}`); -}); - -// ─── send_mail 的参数处理 ───────────────────────────────────────── -test('★ send_mail 接受 JSON 字符串形式的 attachment_ids(opencode 上连试 6 次失败的形状)', async () => { - const c = fakeClient(); - await toolsOf(c).get('send_mail').run({ - to: 'admin@/tmp', - subject: 's', - body: 'b', - attachment_ids: '["10e73e9f-1"]' // ← 模型实际会这么写 - }); - const sent = c.calls.find(x => x.path === '/mail/send'); - assert.deepEqual(sent.body.attachment_ids, ['10e73e9f-1']); -}); - -test('send_mail 也接受数组、单 id、逗号分隔', async () => { - for (const [input, want] of [ - [['a', 'b'], ['a', 'b']], - ['a', ['a']], - ['a, b', ['a', 'b']], - ['a b', ['a', 'b']] - ]) { - const c = fakeClient(); - await toolsOf(c).get('send_mail').run({ - to: 'x@/p', - subject: 's', - body: 'b', - attachment_ids: input - }); - assert.deepEqual(c.calls.find(x => x.path === '/mail/send').body.attachment_ids, want); - } -}); - -test('★ 没有附件时不带 attachment_ids 字段(带空数组会被服务端当「要挂附件」)', async () => { - for (const input of [undefined, null, '', [], ['', null]]) { - const c = fakeClient(); - await toolsOf(c).get('send_mail').run({ - to: 'x@/p', - subject: 's', - body: 'b', - attachment_ids: input - }); - const body = c.calls.find(x => x.path === '/mail/send').body; - assert.equal('attachment_ids' in body, false, `输入 ${JSON.stringify(input)} 时不该带`); - } -}); - -test('send_mail 缺必填字段时明确报错(且不发请求)', async () => { - const t = toolsOf(fakeClient()); - for (const args of [{}, { to: 'x@/p' }, { to: 'x@/p', subject: 's' }]) { - await assert.rejects(() => t.get('send_mail').run(args), /缺少必填字段/); - } -}); - -test('send_mail 只透传有值的可选字段', async () => { - const c = fakeClient(); - await toolsOf(c).get('send_mail').run({ - to: 'x@/p', - subject: 's', - body: 'b', - cc: '', - reply_to: 'm1', - session_alias: '', - max_rounds: 5 - }); - const body = c.calls.find(x => x.path === '/mail/send').body; - assert.deepEqual(Object.keys(body).sort(), ['body', 'max_rounds', 'reply_to', 'subject', 'to']); -}); - -// ─── read_inbox ─────────────────────────────────────────────────── -test('read_inbox 只把本次列出来的未读标为已读', async () => { - const c = fakeClient({ - responses: { - '/mail/inbox': { - mails: [ - { mail_id: 'm1', subject: '一', body: 'x', from: 'pi' }, - { mail_id: 'm2', subject: '二', body: 'y', from: 'dsh' } - ] - } - } - }); - const out = await toolsOf(c).get('read_inbox').run({}); - assert.match(out, /一/); - const mark = c.calls.find(x => x.path === '/mail/read'); - assert.ok(mark, '应该标记已读'); - assert.deepEqual(mark.body.mail_ids, ['m1', 'm2']); -}); - -test('read_inbox 空收件箱给出可读文本', async () => { - const c = fakeClient({ responses: { '/mail/inbox': { mails: [] } } }); - assert.match(await toolsOf(c).get('read_inbox').run({}), /收件箱为空/); -}); - -test('★ 标记已读失败不影响读取结果', async () => { - // 正文已经拿到了,代价只是下次重复看到 —— 比丢掉这次读取轻得多。 - const c = fakeClient({ responses: { '/mail/inbox': { mails: [{ mail_id: 'm1', subject: '一', body: 'x' }] } } }); - c.post = async () => { - throw new Error('500'); - }; - const out = await toolsOf(c).get('read_inbox').run({}); - assert.match(out, /一/); -}); - -// ─── 配置缺失 ───────────────────────────────────────────────────── -test('★ 未配置密钥时每次调用都明确报错(而不是收到 401 再猜)', async () => { - const c = fakeClient({ config: ['AGENTMAIL_AGENT_KEY'] }); - const t = toolsOf(c); - await assert.rejects( - () => t.get('read_inbox').run({}), - /未配置完成.*AGENTMAIL_AGENT_KEY/ - ); - // 关键:真的一次请求都没发出去 - assert.equal(c.calls.length, 0); -}); - -test('每个工具都受配置校验保护(漏一个就会发出匿名请求)', async () => { - const c = fakeClient({ config: ['AGENTMAIL_AGENT_NAME'] }); - const t = toolsOf(c); - const argsByName = { - read_inbox: {}, - read_mail: { mail_id: 'm' }, - read_thread: { mail_id: 'm' }, - send_mail: { to: 'x@/p', subject: 's', body: 'b' }, - forward_mail: { mail_id: 'm', to: 'x@/p' }, - upload_attachment: { file_path: '/tmp/x' }, - download_attachment: { attachment_id: 'a', save_path: '/tmp/y' }, - suggest_address: {}, - list_contacts: {}, - connect_to_server: {}, - session_participants: { session_id: 's' } - }; - for (const [name, tool] of t) { - if (name === 'connect_to_server') { - // 它是唯一**刻意**绕过 guard 的工具:配置缺失时它负责说清楚缺什么 - // (见 lib/tools.mjs 里的注释),所以要断言另一种行为。 - const out = await tool.run({}); - assert.match(out, /未配置完成|已连接/, name); - continue; - } - await assert.rejects(() => tool.run(argsByName[name]), /未配置完成/, name); - } - assert.equal(c.calls.length, 0, '任何工具都不该在缺配置时发出请求'); -}); - -// ─── 附件 ───────────────────────────────────────────────────────── -test('upload_attachment 提示必须把 id 带进 send_mail 才发得出去', async () => { - // 用真文件:这里要连真实路径一起验(读文件 → multipart 上传), - // 把 uploadLocalFile 抹掉就测不到「路径写错」这种最常见的失败。 - const dir = await mkdtemp(join(tmpdir(), 'zc-upload-')); - const filePath = join(dir, 'a.txt'); - await writeFile(filePath, 'hello'); - const out = await toolsOf(fakeClient()).get('upload_attachment').run({ file_path: filePath }); - assert.match(out, /att-1/); - assert.match(out, /attachment_ids/); -}); - -test('download_attachment 报告落盘路径与大小', async () => { - const out = await toolsOf(fakeClient()) - .get('download_attachment') - .run({ attachment_id: 'a1', save_path: '/tmp/out.bin' }); - assert.match(out, /\/tmp\/out\.bin/); -});