Files
MailUI4Agents/plugins/zcode-mail-bridge/README.md
JianFeeeee d015d3694c test(zcode): 把门禁判决实验收进仓库(test/manual/gate-e2e.py)
它是「yolo + 自有工具面 + 我们自己的门禁」这个姿态**唯一**的决定性验证:
批了→命令真执行(比对文件内容,不只看回信);拒了→命令真没执行(文件不存在)
**且回信把成因说成「人拒绝」而不是「超时」**;同会话第三次调用仍产生新请求
并在获批后执行(幂等键按调用唯一)。之前只放在 /root 下,会随环境丢弃。

顺手修两处会骗人的东西:

1. **不要用 `python3 run.py | tee log` 再取 `$?`** —— 那是 tee 的退出码(恒 0)。
   实测踩过:脚本自己打印 5/6(有失败),后台任务通知却报 exit-code 0,
   日志里那句 `EXIT=0` 完全是噪声。现在脚本把**自己的** exit_code 写进证据文件,
   README 也改成 `set -o pipefail` 的调用方式。
   (同源问题第三次:PIPESTATUS 在 dash 下报错、`go build | head` 假绿、这次是 tee。)
2. **备注文字不再显示在通过项旁边** —— 通过的判据曾挂着「很可能又被当成重复请求
   丢弃了」这种失败提示,会把「全绿」读成「有问题」。

已从仓库路径连跑三次 13/13(不同标记),确认收进仓库后仍可用。
2026-09-12 19:11:58 +08:00

313 lines
18 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# zcode-mail-bridge —— AgentMail 的 ZCode 适配
让 [ZCode](https://zcode.z.ai)z.ai 的 Electron 客户端)成为 AgentMail 里
一个能收发邮件、传附件、**把危险工具授权交给人**的 Agent。
ZCode 用**插件**扩展能力(`.zcode-plugin/plugin.json` 声明
`skills` / `commands` / `hooks` / `mcpServers`),所以适配它的正确形状是一个插件,
而不是又一个常驻桥进程。本目录就是那个插件。
## 组成
```
.zcode-plugin/plugin.json 插件清单MCP 服务器 + hooks 目录)
mcp/server.mjs MCP 服务器入口stdio换行分隔 JSON-RPC
hooks/permission.mjs PermissionRequest 钩子:把授权问给人、等决定、回结论
hooks/hooks.json 钩子注册matcher + 进程型钩子 + 超时)
src/index.mjs ★ 邮件驱动:收到来信 → 起一轮 ZCode → 回信
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 工具(与另三个桥同名同参)
lib/gateway.mjs 网关 HTTP 客户端
lib/hook-policy.mjs 档位判定(纯函数)
lib/grants-file.mjs 「一直同意」的跨进程持久化
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
← 与 pi/dsh/opencode 三桥**逐字节同源**(见下)
test/ 单元测试(含继承的共用测试)
test/manual/permission-e2e.mjs 授权桥端到端(真去点同意/拒绝)
test/manual/driver-e2e.mjs 邮件驱动端到端(桩 CLI真网关真邮件
```
## 三条能力线(现行姿态:一条主路 + 一条桌面通路)
### 1MCP 工具面
模型通过 `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执行门禁headless 的主力路径)—— `lib/action-tools.mjs` + `lib/approval.mjs`
**这是本平台现在真正的安全边界。** 平台自带的 `Bash`/`Write`/`Edit`/`js`
32 项「能动机器」的工具全部被 `--disallowed-tools` 拿掉(清单见
`src/turn-mode.mjs``REVIEWED_DENYLIST`,逐条的取舍理由写在那里),
模型唯一能动手的路径是我们自己的两个工具:
| 工具 | 作用 | 批准后 |
|---|---|---|
| `run_command` | 执行一条 shell 命令(`bash -c`,默认 cwd = 会话工作区) | 真执行;退出码 / stdout / stderr 原样交回模型;输出超 16000 字符截断并标明截了多少 |
| `write_file` | 写文件(覆盖写,父目录自动建) | 真写;平台保护目录之外才行 |
两者的语义与档位对齐:
| 档位 | `--mode` | `run_command` / `write_file` |
|---|---|---|
| `plan` | `plan` | **直接拒绝**,且**根本不发授权请求**(注定拒绝的事不该打扰人) |
| `workspace`(默认) | `yolo` | 每次调用**先向发件人请示**,拿到同意才执行 |
| `full` | `yolo` | 直接执行(该档语义就是发件人已给全权) |
为什么 workspace 敢用 `yolo`:平台那条路在本环境下**不可用**——
MCP 工具的 `needsApproval` 在产物里硬编码为 `true`headless 没有审批客户端
可问 ⇒ `build`/`edit` 档下**每个** MCP 工具都被拒(连 `read_inbox` 都调不动)。
于是选择只有两个:「平台问、但问不到人 → 全拒」与「平台不问、我们自己问」。
后者才是真的可用且仍然可审计。
**三道不可动摇的规矩**`lib/approval.mjs` 的文件头有完整推导):
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`
收到来信就自动开工,不需要人先打开 ZCode
```
SSE 收到 new_mail → 去重 → 解析工作目录与档位 → 跑一轮 headless ZCode
→ 取最终文本 → 决定要不要回信 → 投递
```
一轮长这样(`--mode` 是**必传**的,见下):
```bash
node /opt/ZCode/resources/glm/zcode.cjs \
--prompt "<邮件提示词>" --output-format stream-json \
--cwd <工作目录> --mode build [--resume sess_xxx] --max-turns N
```
**`--mode` 漏传的后果**`--prompt` 的默认 mode 是 **`yolo`**,而 ZCode 的判定里
`mode === "yolo"` 一律 allow`Yolo mode bypasses permission prompts`)。
现在 workspace 档**故意**用 `yolo`(见上文 2 节所以「mode 是不是 yolo」
已经不是安全性质 —— 真正的性质是「我们自己的门禁在不在管」,由
`denylistForTier``ourGateIsActive` 两个函数表达,且都有测试钉住。
`buildRunArgs` 收不到 mode 会直接抛错;`--allowed-tools` 会被它直接拒掉
(本版本 CLI 的 help 里写着这个选项,但解析器报 `Unknown option`)。
回信策略(与另三桥同源,复用 `lib/relay-policy.js`
| 来信方 | 自动回信? | 为什么 |
|---|---|---|
| 人 | 是(把本轮最终文本回过去) | 消息在提示词里就告诉他「回信不用你自己发」 |
| Agent | **否** | Agent 间必须自己 `send_mail`;否则两边会把对方的「已收到」当待办,无限客套 |
两种情况下都**不回信**也不行:
- 一轮跑不起来CLI 报错 / 超时)→ **必回一封失败信**,并写明 ZCode 自己的成因
(没登录 / 缺模型配置 / CLI 路径不对)。邮件驱动的会话没有本地界面,
什么都不发等于「信发出去了,然后再无音讯」。
- 模型这一轮自己发过信(工具跑在 ZCode 派生的 MCP 服务器进程里)→ 让位,
否则收件箱里会出现两封说同一件事的邮件(线上实测过 311 与 342 字节两封)。
跨进程对齐靠 `lib/explicit-sends.mjs` 落盘。
**串行**一轮一次。ZCode 的会话与工作目录是重资源,同目录并发跑两轮会互相踩。
代价是一封长信会挡住后面的信 —— 这是显式取舍。
**关停**:收到 SIGTERM 会终止在途回合(否则 systemd 杀掉驱动后,那个 ZCode
还在跑工具,而既没有驱动看着它、也没有本地界面看着它)。
插件读与其它三桥**同名**的环境变量:
| 变量 | 说明 |
|---|---|
| `AGENTMAIL_GATEWAY_URL` | 网关地址,默认 `http://127.0.0.1:8180` |
| `AGENTMAIL_AGENT_NAME` | 本 Agent 在 AgentMail 里的名字(如 `zcode` |
| `AGENTMAIL_AGENT_KEY` | 管理员签发的 Agent 密钥 |
| `AGENTMAIL_AGENT_SECRET` | 没有密钥时的兜底(`X-Agent-Secret`,服务端两条路都认) |
| `AGENTMAIL_SESSION_ID` | **仅邮件驱动时**由驱动进程注入:本会话的 AgentMail 会话 id兼作「有无本地界面」的判据 |
| `AGENTMAIL_PERMISSION_MODE` | 档位(`plan`/`workspace`/`full`),由驱动按邮件的 `permission_mode` 注入 |
| `AGENTMAIL_PERMISSION_WAIT_MS` | 等人工决策的上限,默认 5400009 分钟,须小于钩子的 `timeoutMs` |
| `AGENTMAIL_ZCODE_MODE_MAP` | 覆盖档位→mode 映射(`workspace:yolo,full:yolo`),平台修好钩子后只改配置即可恢复 |
| `AGENTMAIL_ZCODE_DISALLOWED_TOOLS` | **整表替换**禁用清单(空格/逗号分隔)。传空串 = 一张空清单,与「没设置」不同 |
密钥怎么给ZCode 的插件 `userConfig` **不支持** `sensitive` 值(官方文档明说
「sensitive 值当前无法在界面输入或持久化」),所以密钥走 **ZCode 进程的环境变量**
systemd `EnvironmentFile`),由 MCP 服务器与钩子继承。
`userConfig` 只适合放非机密项。
## 配置
ZCode 的发现源之一是 `plugins.dirs`配置里的「inline directories」
```jsonc
// ~/.zcode/cli/config.json
{ "plugins": { "enabled": true, "dirs": ["/opt/agentmail/plugins/zcode-mail-bridge/current"] } }
```
装完用 `node <zcode.cjs> plugins list` 自查,应当看到:
```
- agentmail@inline [enabled]
inline/inline: <插件目录>
skills: 0, commands: 0, hooks: 1, mcp: plugin:agentmail:agentmail
```
## 谁在维护"同源"
`deploy/check-shared-libs.sh` 会逐个字节比对上面那 9 个共用模块(及其测试)
与 opencode 基准。**该脚本曾有假绿**:本机 PATH 上的 `diff` 是鸿蒙 SDK 工具链里的
`diff`,不认 `-q` 且对内容不同的文件**仍返回 0**,于是检查器一直是永真输出。
现已改用 `cmp -s` 并在开头自检(判据本身必须先被证明能发现差异)。
改了共用模块的正规流程:改 `opencode-mail-bridge/lib/` 下的基准,
`deploy/check-shared-libs.sh` 看它报错,再逐字拷到其余三处。
## 验证
```bash
# 单元 + 继承的共用测试
node --test 'test/*.test.mjs'
# 共用模块四方同源(含判据自检)
bash ../../deploy/check-shared-libs.sh
# 授权桥端到端:真建会话 → 起钩子 → 以人类身份点同意/拒绝 → 验钩子结论
node test/manual/permission-e2e.mjs
# 邮件驱动端到端:桩 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 的),只取「第一条新的」会拿到一条无关请求,
于是人点了同意而钩子在等自己那条,最后超时。
## 已知缺口与残余风险
### 残余风险(新姿态必须说清楚的代价)
- **`yolo` 意味着平台不再有任何权限判定**。安全完全来自两处:那张
`REVIEWED_DENYLIST`(黑名单,漏一项就是开一个洞),以及我们自己的门禁。
黑名单的完整性靠**穷举工具名**保证——名单来自 CLI 产物里模型可见工具名的
权威注册表(`aIn` 那个 28 项数组)+ 另一份更宽的候选集取并集,测试里有一条
「一切能动机器的自带工具都在清单里」把审阅结论钉住。但**不能证明完备**
换版本、装新插件(浏览器类、其它 MCP 服务器)都可能新增执行能力,
而黑名单不会自动跟上。升级 ZCode 或安装新插件后应重做一次枚举。
- **网络出向未被门禁覆盖**`WebFetch` / `WebSearch` 保留着(它们读不到本机文件),
但能把上下文里的内容编码进 URL 发出去。要收紧就把它们加进
`AGENTMAIL_ZCODE_DISALLOWED_TOOLS`
- **文件系统读取范围未被限制**`Read`/`Grep`/`Glob` 能读本机任何可读文件
包括密钥文件。要收紧只能收紧整机权限systemd `ProtectHome` / 专用用户)。
- **保护目录是前缀匹配**:它挡住的是「按字面路径写入」,不是绕过符号链接等
间接路径;真正的隔离需要 mount namespace 或文件权限。
### 缺口
- **旧钩子在本版本3.10.2 / CLI 0.16.5)不可靠**:有时钩子压根不注册
(插件扫描与会话创建有竞态),触发时也无条件在 ~5ms 内失败且命令从未被 spawn
(用「钩子写 marker 文件」的副作用验证)。这是转向「我们自己的门禁」的直接原因;
桌面模式下钩子仍然可用已验5/5
- **`mode_enforcement` 靠自检得出**:驱动启动时先看我们那条门禁链是否就绪
`--mode yolo` + 禁用清单里真的有 `Bash`/`js`),就绪就报 `native` 并在日志里
点明「谁在把关」;否则降级成 `advisory`。**不再拿「钩子已注册」当凭据** ——
yolo 下钩子不会触发,那样等于替一个不存在的能力背书。
- **闭源**ZCode 是闭源客户端deb 里 `License: unknown`),本插件的协议层
MCP 分帧、钩子 schema、headless 输出格式、`--mode`/`--disallowed-tools` 的语义)
全是从其产物里实测逆出来的,版本升级可能破坏。
- **
`--allowed-tools` 在 help 里写着但不可用**:解析器报 `Unknown option`
退回 usage。所以「只放行只读自带工具 + 我们的工具」这种白名单姿态做不到,
只能用黑名单。`buildRunArgs` 现在会为此直接抛错,而不是拼出一条跑不起来的命令。
- **驱动与 MCP 服务器是两个进程**:模型自己发过信的记录、以及「一直同意」表,
都靠落盘对齐(`lib/explicit-sends.mjs` / `lib/grants-file.mjs`)。
两边解析出不同路径就会出现「刚点过一直同意又问你一遍」与「同一件事发两封信」。