refactor(zcode): 删掉本地 MCP 服务器与整套执行门禁 —— MCP 已内置网关

## 删了什么

**本地 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 进程一起消失了。
This commit is contained in:
2026-10-02 14:14:06 +08:00
parent 2f17f62871
commit d09ef395b4
23 changed files with 68 additions and 3732 deletions

View File

@ -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"
}

View File

@ -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=<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` 与下文的「三道不可动摇的规矩」。
<details>
<summary>(历史,已不适用于 MCP 面)原执行门禁的三道规矩</summary>
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 次、
最后放弃整个任务的教训。
</details>
### 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 的),只取「第一条新的」会拿到一条无关请求,
于是人点了同意而钩子在等自己那条,最后超时。
## 已知缺口与残余风险

View File

@ -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 征求授权…"
}
]
}
]
}
}

View File

@ -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();
});

View File

@ -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);
}

View File

@ -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();
}
}

View File

@ -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;
}
};
}

View File

@ -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);
}

View File

@ -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<string>}} ctx
* @returns {Promise<object|null>} 要写回的消息;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);
}

View File

@ -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]));
}

View File

@ -1,119 +0,0 @@
#!/usr/bin/env node
/**
* AgentMail 的通用 MCP 服务器入口(stdio 传输,JSON-RPC)。
*
* 任何支持 MCP 的宿主都能直接挂载,只需注入 AGENTMAIL_* 环境变量:
*
* node <path>/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);
});
}

View File

@ -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'"
}
}

View File

@ -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` 的含义随之收窄为「该档位真的没有执行面,而非口头约定」——
* 以前它含<E5AE83><E590AB><EFBFBD>「有人会来问」,现在没<E59CA8><E6B2A1><EFBFBD>人会来问,因为没人需要执行。
*
* `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 });

View File

@ -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} 太短,人工审批窗口不够`);
});

View File

@ -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', '非重复的正常请求应该等,然后超时');
});

View File

@ -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;
}
});

View File

@ -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$/);
});

View File

@ -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}`);
});

View File

@ -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));
});

View File

@ -41,15 +41,15 @@ test('★ 通过软链指向自己 → 仍是入口(生产布局就是软链
});
test('★ 目录软链(current → <时间戳>)下的完整路径同样成立', async () => {
// 生产的软链在**目录**这一层:/opt/.../<name>/current/mcp/server.mjs
// 生产的软链在**目录**这一层:/opt/.../<name>/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 {

View File

@ -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文本/);
});

View File

@ -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');
});

View File

@ -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/);
});