Files
MailUI4Agents/plugins/zcode-mail-bridge/README.md
JianFeeeee c5e1d562eb feat(zcode): 邮件驱动 —— 收到来信就自动开工,并把结论回信
第三步(补齐一等 Agent 的另一半):驱动进程订阅 SSE,按邮件起一轮 headless
ZCode,取最终文本回信。

## --mode 是必传的(不传等于关掉授权系统)

ZCode 的权限判定里 `mode === "yolo"` 一律 allow
("Yolo mode bypasses permission prompts"),而 `--prompt` 的默认 mode **就是 yolo**。
所以驱动不传 --mode 时:授权钩子根本不会触发,整个授权系统**静默消失** ——
不报错,只是没有任何询问,看起来一切正常。

档位映射(依据是 CLI 产物里的规则表,不是猜):
  plan → --mode plan    (mode.plan.nonReadOnly:非只读一律拒)
  workspace → --mode build(mode.build.highRisk / sideEffect:Bash/Write/Edit → ask)
  full → --mode yolo    (刻意绕过)
buildRunArgs 收不到 mode 直接抛错;测试里有一条反向对照钉住「只有 full 能得到 yolo」,
含大写 FULL(共用库 normalizeMode 严格匹配,落回 default 而不是 yolo —— 好性质,也钉住)。

## 一轮怎么跑

  node <zcode.cjs> --prompt <提示词> --output-format stream-json \
       --cwd <工作目录> --mode <m> [--resume sess_xxx] --max-turns N

用 stream-json 而不是 --json:`--json` 全程无输出,一个卡住的回合与一个正在
干活的回合在外部完全一样,而邮件驱动的会话没有界面,日志是唯一能看见它的地方。
输出契约(逐条事件 + 末尾 {type:"result",sessionId,response})同样逆自 CLI 产物。
会话延续靠 --resume + 存回的 sess_…:丢了它模型每封信都从零开始。

## 回信策略(与另三桥同源)

- 人来信 → 自动把本轮最终文本回过去(relay:'summary' + relay_key 走免配额通道)
- Agent 来信 → **不**自动回(Agent 间必须自己 send_mail,否则两边把对方的
  「已收到」当待办,无限客套)
- 一轮跑不起来 → **必回**失败信,且给出 ZCode 自己的成因(没登录/缺模型配置/
  CLI 路径不对)。没有本地界面时,什么都不发等于「信发出去了,然后再无音讯」。
  刻意不复用共用库那份 renderFailureReport:它的建议是「调整可用模型范围」,
  对 ZCode 什么也解决不了。
- 模型这一轮自己发过信 → 让位。工具跑在 ZCode 派生的 MCP 服务器**进程**里,
  与驱动内存不通,所以经 lib/explicit-sends.mjs 落盘对齐(不记的后果线上实测过:
  收件箱里两封说同一件事的邮件,311 与 342 字节)。

## 两处健壮性(都是实现时自己发现的真问题)

- 超时必须**必然** settle:既不退也不报错的孩子会让 Promise 永不 settle,
  而队列是串行的 → 那封信永远挂住、后面的信全都不再被处理。
  现在 SIGTERM → SIGKILL → 无论如何收尾;定时器刻意不 unref
  (unref 过的定时器让「没有其它句柄」的进程直接退出,收尾根本没机会跑)。
- 关停时终止在途回合:否则 systemd 杀掉驱动后那个 ZCode 还在跑工具,
  而既没有驱动看着它、也没有本地界面看着它。

## 自报强制力只声明得出来的事

驱动启动时读自己的 hooks/hooks.json,确认 PermissionRequest 已注册才报 native,
否则报 advisory 并在日志里写明原因 —— 不替一个不存在的能力背书。

## 验证

- 单元 320/320(新增 90 项:turn-mode 8、zcode-run 17、driver 19、prompt 14 +
  继承的共用测试;含反向对照)
- 邮件驱动端到端 7/7 × 3 次连跑稳定:桩 CLI 替掉 ZCode,真网关真邮件 ——
  SSE 订阅、去重、工作目录、档位映射、参数拼装(--mode 必须对)、
  stream-json 解析、回信、Agent 来信不回、CLI 失败必回失败信
- 授权桥端到端 5/5 × 3 次连跑稳定
- 共用模块四方同源(新纳入 catchup/relay-dedup/relay-policy/workspace,
  反向验证:让 workspace.js 分叉会被抓住)

## 我自己写错并被测试抓出来的三处(值得记)

1. 验证脚本把人类发信写成了 /api/v1/mail/send(**Agent** 路由)→ 401。
   报错「Missing Authorization: Bearer …」其实已经指明走错了路由表。
2. findReply 按「驱动验证(人)」这种片段找,第二次跑时命中了**上一轮遗留的回信**
   → 正文比对失败、后续参数核对变成「无法判定」。收件箱是跨轮次共享的持久状态,
   必须按唯一 marker 定位(与之前「待决权限列表」那次是同一类错误)。
3. 停旧驱动只发 SIGTERM 不等退出 → 新旧两个驱动同时订阅 SSE,
   同一封信被回两次,判据取到哪封取决于时序 → 时灵时不灵。改成等 exit 事件。
   另:桩脚本用 process.exit 截断管道写入,导致 stderr 时有时无 —— 改用 exitCode。
2026-09-12 14:58:36 +08:00

219 lines
12 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授权桥`PermissionRequest` 钩子)
ZCode 决定某个工具需要授权时触发钩子(事件 JSON 走 stdin我们回一个结论走 stdout
```jsonc
{"decision":"approve"} // 放行
{"decision":"block","reason":"…"} // 拒绝,且模型能看到原因
// 什么都不输出 // 不表态,退回 ZCode 自己的权限流程
```
档位语义与 pi 桥逐条对齐(同一条邮件派给不同 Agent行为必须一致
| 档位 | 守卫工具(`Bash`/`Write`/`Edit`/`ApplyPatch` | 其它工具 |
|---|---|---|
| `plan` | 直接拒绝,文案与 pi 桥同源 | 不表态(读与查本来就允许) |
| `workspace`(默认) | 发邮件问人,等决定 | 不表态 |
| `full` | 批准(该档语义就是免掉询问) | 不表态 |
**只有明确同意才放行**:看不懂的决策文本一律当拒绝(判定交给共用的
`permission-grants.js`)。「一直同意」落盘存到
`$AGENTMAIL_ZCODE_GRANTS_FILE`(或 `$AGENTMAIL_CONFIG_DIR` / `$ZCODE_PLUGIN_DATA` 下的
`permission-grants.json`)—— 钩子是**一个事件一个进程**,不落盘的话那个选项就是骗人的。
**失败一律 fail closed但区分模式**
- 永久失败409 无人可问 / 其它 4xx→ 拒绝并说明原因
- 暂时失败5xx / 网络)+ `AGENTMAIL_SESSION_ID` 非空(邮件驱动)→ 拒绝
(邮件驱动的会话没有本地界面兜底,退回本地决策等于守卫消失)
- 暂时失败 交互模式 → 不表态,让人就地决定
钩子**自己开 SSE** 等决定,不找桥进程要 —— 网关的 SSE 是扇出的
`clients` 按唯一 id 存,`SendToAgent` 推给该 Agent 的所有客户端),
所以一次性进程也能订阅到自己那条 `permission_decision`
这样**交互模式下这个功能同样可用**(人自己开着 ZCode 干活时并没有桥在跑)。
### 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`)——
授权钩子根本不会触发,整个授权系统**静默消失**(不报错,只是没有询问)。
档位映射表见 `src/turn-mode.mjs``buildRunArgs` 收不到 mode 会直接抛错。
回信策略(与另三桥同源,复用 `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` |
密钥怎么给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
```
`driver-e2e.mjs` 用桩 CLI 把「除了模型之外」的每一环都真跑一遍:参数拼装
(尤其是 `--mode`、stream-json 解析、回信策略、跨进程去重、失败必回信。
它需要 `gui-lab` 这个人类账号(去点界面/收信)与 zcode 的凭据。
`permission-e2e.mjs` 的判据设计正向同意→approve之外还有四条反向对照
拒绝→block 且原因必须来自人的拒绝、plan 档拒绝且**不产生**任何权限邮件、
无人可问→fail closed、非守卫工具→不表态。它必须按「启动前快照差集 +
`session_id` + `agent_name`」三重过滤待决请求 —— 待决列表里有历史积压
(实测 6 条,含其它 Agent 的),只取「第一条新的」会拿到一条无关请求,
于是人点了同意而钩子在等自己那条,最后超时。
## 已知缺口
- **真实一轮还没跑过**ZCode 的模型访问要 OAuth 登录,登录完成前 headless 会直接
报「Model config is missing」。桩 CLI 已经把除「模型干活」之外的每一环验过了,
但「模型能不能真的用这些工具把活干完」要等登录后实测。
- **驱动服务已写好但**未启用**`deploy/zcode-mail-bridge.service`
未登录就启用的话,每封来信都会收到一封「处理失败」,所以留给人决定。
启用:`install -m 0644 deploy/zcode-mail-bridge.service /etc/systemd/system/ && cp deploy/zcode.env.example /etc/agentmail/zcode.env && systemctl enable --now zcode-mail-bridge`
- **`mode_enforcement` 靠自检得出**:驱动启动时会读自己的 `hooks/hooks.json`
确认 `PermissionRequest` 已注册才报 `native`,否则报 `advisory` 并在日志里说明原因
(不替一个不存在的能力背书)。
- **生产路径**:当前 `plugins.dirs` 指向仓库工作副本,按项目纪律应改为
`/opt/agentmail/plugins/zcode-mail-bridge/current` 的快照 + 原子切换
(等 ZCode 重启不影响在跑的登录流程时再做)。
- **闭源**ZCode 是闭源客户端deb 里 `License: unknown`),本插件的协议层
MCP 分帧、钩子 schema、headless 输出格式、`--mode` 判定规则)全是从其产物里
实测逆出来的,版本升级可能破坏。