feat(mcp): 去 ZCode 影子 —— mcp/server.mjs 改为通用 MCP 服务
## 目的
`mcp/server.mjs` 此前注释与行为都绑定 ZCode,接入端必须为 AgentMail 写
专用插件。去掉这层绑定后,任何支持 MCP 的宿主挂一行配置即可用:
{"command":"node","args":["…/mcp/server.mjs"],"env":{
"AGENTMAIL_GATEWAY_URL":…,"AGENTMAIL_AGENT_NAME":…,
"AGENTMAIL_AGENT_SECRET":…,"AGENTMAIL_MCP_PLATFORM":"my-host"}}
协议层(零依赖手写 stdio JSON-RPC)与 11 个邮件工具本就与宿主无关,
真正要动的只有 4 处耦合 + 工具面。
## 改动
**1. 移除执行类工具(`run_command` / `write_file`)**
它们的门禁(lib/action-tools.mjs + lib/approval.mjs + 落盘授权表)是为
ZCode headless 的**双进程审批**设计的:MCP 进程问人、ZCode 钩子进程等回答、
中间靠文件对齐。脱离该宿主后这套门禁的前提不成立,挂在通用服务上等于
提供一条**没有审批的旁路**。
`lib/` 里三个模块与 `hooks/` 源码保留(桌面模式的 ZCode 仍走它们),
只是 server.mjs 不再装载。
**2. platform 可配置**:`AGENTMAIL_MCP_PLATFORM`,默认 `mcp`,
空白值回落默认值。原先硬编码 `'zcode'`(两处)。
**3. 错误文案去宿主名**:不再让模型/人「去 ZCode 的插件设置里填写」,
改为说明设置 `AGENTMAIL_*` 环境变量。
**4. 提示词如实说能力**(src/prompt.mjs):原文案向模型承诺
`run_command`/`write_file` 可用并分档描述「会被请示 / 直接生效」。
工具移除后那变成**指向不存在工具的承诺** —— 模型会去找、把整轮浪费在
换名字重试上。改为明说「本平台没有执行面,需要动手就写进回信请人做」。
三档措辞仍互不相同(`plan`/`workspace`/`full`),因为「档位仍存在但都无
执行面」这件事模型需要知道。
## ★★ 顺带修掉一个真实缺陷(端到端撞出来的)
`connect_to_server` 对 secret-only 的 Agent **一直 400**:
`/agent/register` 只认 `Authorization: Bearer` 或 body 里的 `secret`,
不认 `X-Agent-Secret` 头(其它接口才认),而它漏了 `body.secret`。
dsh / pi 正是 secret-only 配置 ⇒ 它们调「连一下服务器」必然失败,
且模型看不出该改什么。
lib/gateway.mjs 的 `register()` 本来就做对了,tools.mjs 里是手抄的劣化副本。
修后实测 `HTTP 400` → `已连接 …(状态:registered)`。
## 判据
新增 `test/generic-mcp.test.mjs`(5 格)。**这三件事此前无人看守**:
变异验证时「把 action-tools 挂回 server.mjs」与「platform 硬编码回 zcode」
都能全套通过 —— 因为没有判据看 server.mjs 实际挂了什么、也没人看 platform。
改写的 4 格(prompt 3 格 + driver 1 格)保留原意图(不向模型撒谎、
native 自报要有真凭据、工具不存在时不要重试),改为断言新事实。
**变异验证**(每条都确认已应用后才数红格):
挂回 action-tools → 红 3
platform 硬编码 zcode → 红 3
platform 空白不回落 → 红 3
文案指回 ZCode 插件设置 → 红 3
删掉 body.secret(400 复现) → 红 3
全套 **402/402**。
## 端到端验收
写了一个**非 ZCode 宿主**探针(纯 stdio JSON-RPC,不加载任何插件),
对着真实网关跑通:initialize → tools/list(11 个,无执行类)→
connect_to_server(registered)→ suggest_address。
## 未做
- 未发布到 npm registry(`npx` 即用需要发布或指向仓库路径)。
- 未改 `check-deploy-drift.mjs` 的 zcode 豁免(本机仍不退场该宿主)。
This commit is contained in:
@ -1,20 +1,35 @@
|
||||
# zcode-mail-bridge —— AgentMail 的 ZCode 适配
|
||||
# zcode-mail-bridge —— AgentMail 的通用 MCP 服务(+ ZCode 适配)
|
||||
|
||||
让 [ZCode](https://zcode.z.ai)(z.ai 的 Electron 客户端)成为 AgentMail 里
|
||||
一个能收发邮件、传附件、**把危险工具授权交给人**的 Agent。
|
||||
**`mcp/server.mjs` 是一个通用 MCP 服务器**:任何支持 MCP 的宿主挂上它就能收发邮件,
|
||||
不需要为 AgentMail 写专用插件。
|
||||
|
||||
```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)。
|
||||
|
||||
## 组成
|
||||
|
||||
```
|
||||
.zcode-plugin/plugin.json 插件清单(MCP 服务器 + hooks 目录)
|
||||
mcp/server.mjs MCP 服务器入口(stdio,换行分隔 JSON-RPC)
|
||||
.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 → 回信
|
||||
src/index.mjs ★ 邮件驱动(仅 ZCode):收到来信 → 起一轮 ZCode → 回信
|
||||
src/zcode-run.mjs 跑一轮(headless CLI + stream-json 解析)
|
||||
src/prompt.mjs 由邮件构造提示词与回信文案
|
||||
src/turn-mode.mjs 档位 → `--mode` 映射(授权系统在不在的关键)
|
||||
@ -22,12 +37,32 @@ lib/mcp-rpc.mjs 协议层(纯函数,可穷举测试)
|
||||
lib/tools.mjs 11 个 AgentMail 工具(与另三个桥同名同参)
|
||||
lib/gateway.mjs 网关 HTTP 客户端
|
||||
lib/hook-policy.mjs 档位判定(纯函数)
|
||||
lib/grants-file.mjs 「一直同意」的跨进程持久化
|
||||
lib/grants-file.mjs 「一直同意」的跨进程持久化(仅 ZCode 钩子用)
|
||||
lib/explicit-sends.mjs 模型自己发过信的记录(工具与驱动跨进程对齐)
|
||||
lib/action-tools.mjs ⚠ 已不在 MCP 面内(见下方「为什么没有 run_command」)
|
||||
lib/approval.mjs 授权判定共用库(ZCode 钩子路径)
|
||||
lib/{addressing,inbox-format,bounded,discovery,attachment-ids,
|
||||
permission-mode,relay-key,permission-grants,sse-client,
|
||||
catchup,relay-dedup,relay-policy,workspace}.js
|
||||
← 与 pi/dsh/opencode 三桥**逐字节同源**(见下)
|
||||
|
||||
## 为什么没有 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,真网关真邮件)
|
||||
@ -48,33 +83,24 @@ test/manual/driver-e2e.mjs 邮件驱动端到端(桩 CLI,真网关真
|
||||
`test/tools.test.mjs` 里有一条断言直接拿 pi 桥的工具名做对照:少一个就会让某个平台
|
||||
的行为与其它平台不同,而那种问题只在单一平台复现,排查代价最高。
|
||||
|
||||
### 2)执行门禁(headless 的主力路径)—— `lib/action-tools.mjs` + `lib/approval.mjs`
|
||||
### 2)执行门禁 —— 已从 MCP 面移除(2026-10-02)
|
||||
|
||||
**这是本平台现在真正的安全边界。** 平台自带的 `Bash`/`Write`/`Edit`/`js` 等
|
||||
32 项「能动机器」的工具全部被 `--disallowed-tools` 拿掉(清单见
|
||||
`src/turn-mode.mjs` 的 `REVIEWED_DENYLIST`,逐条的取舍理由写在那里),
|
||||
模型唯一能动手的路径是我们自己的两个工具:
|
||||
~~`lib/action-tools.mjs` + `lib/approval.mjs`~~ **不在通用 MCP 面内**。
|
||||
ZCode headless 下它曾是本平台真正的安全边界(禁用 32 项自带工具,
|
||||
模型只能经 `run_command` / `write_file` 动手,两者逐次请示发件人)。
|
||||
|
||||
| 工具 | 作用 | 批准后 |
|
||||
|---|---|---|
|
||||
| `run_command` | 执行一条 shell 命令(`bash -c`,默认 cwd = 会话工作区) | 真执行;退出码 / stdout / stderr 原样交回模型;输出超 16000 字符截断并标明截了多少 |
|
||||
| `write_file` | 写文件(覆盖写,父目录自动建) | 真写;平台保护目录之外才行 |
|
||||
通用化后这套门禁**失效**:它依赖 ZCode 特有的双进程审批(MCP 进程问、
|
||||
钩子进程答、落盘表对齐)。挂在通用服务上 = 一个没有审批的旁路。
|
||||
所以移除,理由与取舍见上文「为什么没有 run_command / write_file」。
|
||||
|
||||
两者的语义与档位对齐:
|
||||
源码保留(桌面模式的 ZCode 仍走它),但 `mcp/server.mjs` 不再装载。
|
||||
`src/prompt.mjs` 的能力说明也已改成如实告知「本平台没有执行面」——
|
||||
否则模型会去找不存在的工具,整轮浪费在换名字重试上。
|
||||
历史档位语义(`plan` 直接拒绝 / `workspace` 请示 / `full` 直接执行)
|
||||
见 `src/turn-mode.mjs` 与下文的「三道不可动摇的规矩」。
|
||||
|
||||
| 档位 | `--mode` | `run_command` / `write_file` |
|
||||
|---|---|---|
|
||||
| `plan` | `plan` | **直接拒绝**,且**根本不发授权请求**(注定拒绝的事不该打扰人) |
|
||||
| `workspace`(默认) | `yolo` | 每次调用**先向发件人请示**,拿到同意才执行 |
|
||||
| `full` | `yolo` | 直接执行(该档语义就是发件人已给全权) |
|
||||
|
||||
为什么 workspace 敢用 `yolo`:平台那条路在本环境下**不可用**——
|
||||
MCP 工具的 `needsApproval` 在产物里硬编码为 `true`,headless 没有审批客户端
|
||||
可问 ⇒ `build`/`edit` 档下**每个** MCP 工具都被拒(连 `read_inbox` 都调不动)。
|
||||
于是选择只有两个:「平台问、但问不到人 → 全拒」与「平台不问、我们自己问」。
|
||||
后者才是真的可用且仍然可审计。
|
||||
|
||||
**三道不可动摇的规矩**(`lib/approval.mjs` 的文件头有完整推导):
|
||||
<details>
|
||||
<summary>(历史,已不适用于 MCP 面)原执行门禁的三道规矩</summary>
|
||||
|
||||
1. **只有明确同意才放行** —— 判据是共用库的前缀白名单(`^同意|一直同意|allow|approve|always|yes`,
|
||||
四个桥共用同一份)。看不懂的文本、空串、`拒绝`、`deny`、平台自己的 `shutdown` 哨兵
|
||||
@ -96,6 +122,8 @@ MCP 工具的 `needsApproval` 在产物里硬编码为 `true`,headless 没有
|
||||
模型必须看见原因才有机会改道。opencode 上「工具失败但报成功」导致模型连试 6 次、
|
||||
最后放弃整个任务的教训。
|
||||
|
||||
</details>
|
||||
|
||||
### 2b)授权桥(`PermissionRequest` 钩子)—— 交互(桌面)模式用
|
||||
|
||||
**注意:headless 驱动这条路上这个钩子不参与**(`--mode yolo` 下平台不做任何权限判定,
|
||||
|
||||
@ -11,7 +11,7 @@
|
||||
import { readFile, writeFile, mkdir } from 'node:fs/promises';
|
||||
import { dirname, basename } from 'node:path';
|
||||
|
||||
/** 与网关一致的默认值;无头部署时由 ZCode 的 userConfig / 环境变量覆盖。 */
|
||||
/** 与网关一致的默认值;宿主配置优先,否则环境变量覆盖。 */
|
||||
const DEFAULT_BASE = 'http://127.0.0.1:8180';
|
||||
|
||||
export class GatewayError extends Error {
|
||||
@ -31,6 +31,9 @@ export class GatewayClient {
|
||||
this.agentKey = String(env.AGENTMAIL_AGENT_KEY || '').trim();
|
||||
this.agentSecret = String(env.AGENTMAIL_AGENT_SECRET || '').trim();
|
||||
this.agentName = String(env.AGENTMAIL_AGENT_NAME || '').trim();
|
||||
// 注册时上报的 platform(服务端按它统计在线桥,WebUI 会显示成平台名)。
|
||||
// 默认 'mcp' —— 本客户端是通用 MCP 服务器,不属于任何单一宿主。
|
||||
this.platform = String(env.AGENTMAIL_MCP_PLATFORM || 'mcp').trim() || 'mcp';
|
||||
}
|
||||
|
||||
/** 配置是否足以发请求 —— 缺密钥时要在第一次调用就明确报错,而不是收到 401 再猜。 */
|
||||
@ -67,7 +70,7 @@ export class GatewayClient {
|
||||
if (!this.agentKey && !this.agentSecret) {
|
||||
throw new Error('缺少 AGENTMAIL_AGENT_KEY 或 AGENTMAIL_AGENT_SECRET');
|
||||
}
|
||||
const body = { name: this.agentName, platform: 'zcode', ...extra };
|
||||
const body = { name: this.agentName, platform: this.platform, ...extra };
|
||||
if (!this.agentKey) body.secret = this.agentSecret;
|
||||
return this.post('/agent/register', body);
|
||||
}
|
||||
|
||||
@ -83,8 +83,9 @@ export async function handleMessage(msg, ctx) {
|
||||
name: t.name,
|
||||
description: t.description,
|
||||
inputSchema: t.inputSchema,
|
||||
// annotations 必须透传:ZCode 用它算风险等级(readOnlyHint→low /
|
||||
// destructiveHint→high),而 plan 档下「非破坏性的 MCP 工具直接放行」
|
||||
// annotations 必须透传:宿主据此算风险等级(readOnlyHint→low /
|
||||
// destructiveHint→high —— ZCode 的规则是逐字逆自其 CLI 产物),
|
||||
// 而 plan 档下「非破坏性的 MCP 工具直接放行」
|
||||
// 依赖它。漏传的后果不是「少个提示」,而是工具在该档下全被拒。
|
||||
...(t.annotations ? { annotations: t.annotations } : {})
|
||||
}))
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
/**
|
||||
* 暴露给 ZCode 模型的 AgentMail 工具。
|
||||
* 暴露给 MCP 宿主的 AgentMail 工具。
|
||||
*
|
||||
* # 为什么工具集与另三个桥完全相同
|
||||
*
|
||||
@ -11,11 +11,12 @@
|
||||
* 一旦这里少一个参数或换一种说法,就会出现「某个平台上模型不会回信」这类
|
||||
* 只在单一平台复现的问题 —— 而排查时最费时间的正是「它到底和别的平台哪里不一样」。
|
||||
*
|
||||
* # 与平台无关
|
||||
* # 与宿主无关
|
||||
*
|
||||
* 本模块不认识 MCP,也不认识 ZCode:它只是一组
|
||||
* `{name, description, inputSchema, run(args) -> string}`。
|
||||
* 本模块不认识任何特定宿主(ZCode / Claude Desktop / codex / 各类 agent harness…),
|
||||
* 它只是一组 `{name, description, inputSchema, run(args) -> string}`。
|
||||
* 协议那层在 lib/mcp-rpc.mjs,入口在 mcp/server.mjs。
|
||||
* 配置一律走环境变量 `AGENTMAIL_*`,由宿主注入。
|
||||
*/
|
||||
|
||||
import {
|
||||
@ -88,7 +89,7 @@ export function buildTools({ client, agentName }) {
|
||||
if (missing.length) {
|
||||
throw new Error(
|
||||
`AgentMail 未配置完成:缺少 ${missing.join('、')}。` +
|
||||
`请在 ZCode 的插件设置里填写,或为 ZCode 进程设置同名环境变量。`
|
||||
`请设置 AGENTMAIL_AGENT_NAME / AGENTMAIL_AGENT_KEY(或 AGENTMAIL_AGENT_SECRET)环境变量后重启 MCP 服务器。`
|
||||
);
|
||||
}
|
||||
};
|
||||
@ -439,7 +440,7 @@ export function buildTools({ client, agentName }) {
|
||||
if (!agentName || (!key && !client.agentSecret)) {
|
||||
return (
|
||||
`AgentMail 尚未配置完成:缺少 ${missing.join('、')}。\n` +
|
||||
`请在 ZCode 的插件设置里填写,或为 ZCode 进程设置同名环境变量后重启。\n` +
|
||||
`请设置 AGENTMAIL_AGENT_NAME / AGENTMAIL_AGENT_KEY(或 AGENTMAIL_AGENT_SECRET)环境变量后重启 MCP 服务器。\n` +
|
||||
`(当前解析到的 Gateway 地址:${url})`
|
||||
);
|
||||
}
|
||||
@ -451,7 +452,17 @@ export function buildTools({ client, agentName }) {
|
||||
? { Authorization: `Bearer ${key}` }
|
||||
: { 'X-Agent-Secret': client.agentSecret })
|
||||
},
|
||||
body: JSON.stringify({ name: agentName, platform: 'zcode' })
|
||||
// ★ 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 = {};
|
||||
|
||||
@ -1,12 +1,24 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* AgentMail 的 ZCode MCP 服务器入口。
|
||||
* AgentMail 的通用 MCP 服务器入口(stdio 传输,JSON-RPC)。
|
||||
*
|
||||
* ZCode 按插件清单里的 `mcpServers` 启动本文件:
|
||||
* 任何支持 MCP 的宿主都能直接挂载,只需注入 AGENTMAIL_* 环境变量:
|
||||
*
|
||||
* node <zcode.cjs> __zcode-plugin-host <plugin>/mcp/server.mjs
|
||||
* node <path>/mcp/server.mjs
|
||||
*
|
||||
* 启动后说 MCP(换行分隔 JSON-RPC,走 stdio),工具实现在 lib/tools.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 是协议通道
|
||||
*
|
||||
@ -17,15 +29,13 @@
|
||||
* # 起不来要说清楚
|
||||
*
|
||||
* 这是邮件驱动会话的一部分:没有本地 UI 让人看见崩溃。所以缺配置时
|
||||
* 不是静默退出,而是把原因写到 stderr(进 ZCode 日志),并在**每次工具调用**时
|
||||
* 不是静默退出,而是把原因写到 stderr(进宿主日志),并在**每次工具调用**时
|
||||
* 再报一次(模型能读,于是它会告诉人)。
|
||||
*/
|
||||
|
||||
import { createInterface } from 'node:readline';
|
||||
import { GatewayClient } from '../lib/gateway.mjs';
|
||||
import { buildTools, indexTools } from '../lib/tools.mjs';
|
||||
import { buildActionTools } from '../lib/action-tools.mjs';
|
||||
import { createFileGrantStore, grantsFilePath } from '../lib/grants-file.mjs';
|
||||
import { handleLine, SERVER_NAME, SERVER_VERSION } from '../lib/mcp-rpc.mjs';
|
||||
import { isMainModule } from '../lib/is-main.mjs';
|
||||
|
||||
@ -33,19 +43,12 @@ const log = (...parts) => console.error('[agentmail-mcp]', ...parts);
|
||||
|
||||
export async function main() {
|
||||
const client = new GatewayClient(process.env);
|
||||
// 常见情况是没配 agent_name(userConfig 没填、环境变量没继承)——
|
||||
// 常见情况是没配 agent_name(宿主没注入 userConfig / 环境变量没继承)——
|
||||
// 用网关的默认值兜底会让它以别人的身份发信,所以宁可留空并在调用时报错。
|
||||
const agentName = client.agentName;
|
||||
|
||||
const tools = buildTools({ client, agentName });
|
||||
|
||||
// 「会动机器」的工具(run_command / write_file)单独一组:它们的门禁在
|
||||
// lib/action-tools.mjs 里,且与钩子共用同一张落盘授权表(文件承载,
|
||||
// 因为 MCP 服务器与钩子是**两个进程**:桌面模式下人在 ZCode 里点「一直同意」,
|
||||
// 要能被我们的工具看见)。
|
||||
const grants = createFileGrantStore(grantsFilePath(process.env));
|
||||
tools.push(...buildActionTools({ client, grants, log }));
|
||||
|
||||
const byName = indexTools(tools);
|
||||
|
||||
const ctx = {
|
||||
@ -58,7 +61,7 @@ export async function main() {
|
||||
};
|
||||
|
||||
log(`启动 v${SERVER_VERSION},网关 ${client.baseURL},身份 ${agentName || '(未配置)'},` +
|
||||
`工具 ${tools.length} 个`);
|
||||
`平台 ${client.platform},工具 ${tools.length} 个`);
|
||||
|
||||
const rl = createInterface({ input: process.stdin, crlfDelay: Infinity });
|
||||
|
||||
|
||||
@ -87,6 +87,14 @@ export function zcodeSessionFallback(sessionKey, rootOverride) {
|
||||
* 之后,能动机器的只剩我们两个工具,而它们每次都要过 `requestApproval`。
|
||||
* 这个才是这条路上真正拦得住东西的那一层。
|
||||
*
|
||||
* ★ 2026-10-02:`run_command` / `write_file` 已从 **MCP 工具面**移除
|
||||
* (`mcp/server.mjs` 改为通用邮件服务,不再挂载它们)。原因是那套门禁
|
||||
* 为 ZCode headless 的双进程审批(`--mode yolo` + 禁用自带工具 + 钩子转达)
|
||||
* 而设计,脱离该宿主后语义不成立。此处 `detectModeEnforcement` 的自检
|
||||
* 与下面的 `disallowedTools` 仍按原逻辑报告,但**实际后果变了**:
|
||||
* 禁用自带工具后模型不再有执行类动作可做(只剩收发邮件),这是**安全侧
|
||||
* 的失败关闭**,不是开放。若将来要在本宿主恢复执行能力,需重新设计门禁。
|
||||
*
|
||||
* 于是判据改成:先看我们自己那条链能不能真的拦住(模块在不在 + 禁用清单够不够),
|
||||
* 再说平台那一层是否还参与。报 native 的含义是「该档位真的被强制,而非口头约定」。
|
||||
*
|
||||
@ -116,8 +124,9 @@ export function detectModeEnforcement({ hooksFile, env = process.env } = {}) {
|
||||
return {
|
||||
enforcement: 'native',
|
||||
reason:
|
||||
`执行类动作只能经我们自己的门禁(run_command / write_file 逐次请示),` +
|
||||
`平台自带危险工具已禁用 ${denied.length} 项(--mode ${mode})` +
|
||||
`平台自带危险工具已禁用 ${denied.length} 项(--mode ${mode}),` +
|
||||
`且 AgentMail 侧不再提供执行类工具(run_command / write_file 已移除)` +
|
||||
`⇒ 模型无任何执行面` +
|
||||
(hookRegistered ? ';桌面模式另有平台钩子' : '')
|
||||
};
|
||||
}
|
||||
@ -226,15 +235,17 @@ export function createDriver({ client, runTurnFn = runTurn, logFn = log, env = p
|
||||
const sessionId = data?.session_id || '';
|
||||
const tier = normalizeMode(data?.permission_mode);
|
||||
const mode = zcodeModeForTier(tier);
|
||||
// 危险的自带工具一律拿掉(三个档位同一张审过的清单);需要动手时,
|
||||
// 模型改用我们自己的 run_command / write_file,门禁就在那里面。
|
||||
// 危险的自带工具一律拿掉(三个档位同一张审过的清单)。
|
||||
// ★ 2026-10-02:`run_command` / `write_file` 已从 MCP 工具面移除,
|
||||
// 所以禁用后模型**没有**执行类动作可用(只剩收发邮件)—— 失败关闭。
|
||||
const disallowedTools = denylistForTier(tier);
|
||||
const cwd = resolveCwd(data);
|
||||
const prev = sessions.get(sessionId);
|
||||
const resume = prev?.zcodeSessionId || '';
|
||||
|
||||
logFn(`处理 ${data.mail_id}|${describeTier(tier, mode)}|cwd=${cwd}${resume ? `|续会话 ${resume}` : ''}`);
|
||||
logFn(`已禁用 ${disallowedTools.length} 个自带工具(Bash/Write/Edit/js/…),执行类动作走 AgentMail 门禁`);
|
||||
logFn(`已禁用 ${disallowedTools.length} 个自带工具(Bash/Write/Edit/js/…);` +
|
||||
`本轮模型可做的只有收发邮件(执行类工具已不在 MCP 面内)`);
|
||||
if (modeReachesPermissionHook(mode)) {
|
||||
// 平台会问、我们的钩子会转达 —— 说明档位映射被配置改回了 build/edit。
|
||||
logFn(`注意:--mode ${mode} 下平台自带工具会产生权限询问(映射被覆盖过?)`);
|
||||
|
||||
@ -25,43 +25,32 @@ import { normalizeMode, DEFAULT_MODE, MODE_PLAN, MODE_FULL } from '../lib/permis
|
||||
* 「工具不存在」(于是它反复换名字试,浪费整轮),或者它以为自己不能动手,
|
||||
* 把本来能做完的活写成「建议你自己执行」。
|
||||
*
|
||||
* 同时要把**授权这件事本身**讲明白:`run_command`/`write_file` 会先向发件人
|
||||
* 申请,被拒时工具会报错并给出原因。模型必须知道「被拒」是一个正常的、
|
||||
* 需要它改道的结果,而不是重试同一个动作的理由。
|
||||
* ★ 2026-10-02:`run_command`/`write_file` 已从 MCP 工具面移除(通用化)。
|
||||
* 那两个工具的门禁是为 ZCode headless 的双进程审批设计的,脱离该宿主后
|
||||
* 语义不成立。现在本平台**没有执行类工具**,文案必须如实说 —— 否则模型会
|
||||
* 去找一个不存在的工具,把整轮浪费在换名字重试上。
|
||||
*/
|
||||
export function capabilityNote(tier) {
|
||||
const t = normalizeMode(tier) || DEFAULT_MODE;
|
||||
const head =
|
||||
'## 你在这个平台上的执行能力\n\n' +
|
||||
'ZCode 自带的 Bash / Write / Edit / js 等工具**已被禁用**(不是故障,是本平台的策略:' +
|
||||
'邮件驱动的会话里没有本地界面可以给人审批,所以平台不做权限判定,改由我们自己的门禁负责)。';
|
||||
|
||||
if (t === MODE_PLAN) {
|
||||
return (
|
||||
head +
|
||||
'\n\n当前是 **plan 档**:只读。你**不能**执行命令或写文件(`run_command`/`write_file` 在' +
|
||||
'本档一律拒绝,不必尝试)。请把需要动手的步骤写进回信,说明「执行什么、为什么」。'
|
||||
);
|
||||
}
|
||||
|
||||
const gate =
|
||||
t === MODE_FULL
|
||||
? '当前是 **full 档**:发件人已声明全权,你的执行类调用**直接生效,不会打扰任何人**。'
|
||||
: '当前是 **workspace 档**:每类执行动作**第一次调用会先向发件人申请授权**' +
|
||||
'(他可以在界面上选「同意」「一直同意」或「拒绝」)。';
|
||||
'邮件驱动的会话里没有本地界面可以给人审批)。' +
|
||||
'AgentMail 侧**也没有提供执行类工具** —— 本平台只收发邮件与附件。';
|
||||
|
||||
return (
|
||||
head +
|
||||
'\n\n需要动手时请改用这两个工具:\n' +
|
||||
'- `run_command`:执行一条 shell 命令(工作目录默认是本会话工作区)\n' +
|
||||
'- `write_file`:写一个文件(父目录自动创建;平台自身的部署/数据库/服务配置目录' +
|
||||
'无论谁批准都拒绝写入)\n' +
|
||||
'\n' +
|
||||
gate +
|
||||
'\n\n被拒绝时工具会**报错并给出原因**(含决策人)。这是正常的业务结果,不是故障:' +
|
||||
'不要重试同一个动作、也不要绕道去找其它执行手段,而应当在回信里说明' +
|
||||
'「哪一步被拒了、为什么需要它、希望对方怎么做」。\n' +
|
||||
'读写与搜索用自带的 Read / Glob / Grep(这些只读工具始终可用)。'
|
||||
'\n\n所以你**不能**执行命令、不能读写工作区文件。' +
|
||||
(t === MODE_PLAN
|
||||
? '当前是 **plan 档**(只读)。'
|
||||
: t === MODE_FULL
|
||||
? '当前是 **full 档**,但平台仍然只提供邮件能力。'
|
||||
: '当前是 **workspace 档**,同样只提供邮件能力。') +
|
||||
'\n需要动手的步骤,请写进回信说明「执行什么、在哪个目录、为什么需要」,' +
|
||||
'由具备本地环境的一方去做。' +
|
||||
'\n读写与搜索用自带的 Read / Glob / Grep(这些只读工具始终可用)。' +
|
||||
'\n\n若你调用了某个不存在的工具而报错,那是**真实结果**而非故障:' +
|
||||
'不要重试、不要换名字再试,而应在回信里说清你需要什么。'
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -384,7 +384,12 @@ test('★ 自报 native 要有真凭据:门禁链就绪(yolo + 够长的禁
|
||||
assert.equal(r.enforcement, 'native');
|
||||
// 理由里必须点出**谁**在把关。以前这里写的是「钩子已注册」,而 yolo 下
|
||||
// 钩子根本不会触发 —— 那种理由会让人以为平台在管,实际平台什么都没管。
|
||||
assert.match(r.reason, /门禁|请示/);
|
||||
//
|
||||
// ★ 2026-10-02:执行类工具已从 MCP 面移除,所以 native 的凭据变成
|
||||
// 「平台自带危险工具已禁用 + AgentMail 侧无执行面 ⇒ 模型无执行路径」。
|
||||
// 凭据换了,**意图不变**:native 必须有真凭据,不能只报个标签。
|
||||
assert.match(r.reason, /门禁|执行面|无任何执行面|已禁用/);
|
||||
assert.match(r.reason, /不再提供执行类工具|无任何执行面/, '必须说清“没有执行面”才是当前凭据');
|
||||
assert.doesNotMatch(r.reason, /^钩子已注册/, '不能拿钩子当唯一凭据');
|
||||
});
|
||||
|
||||
|
||||
169
plugins/zcode-mail-bridge/test/generic-mcp.test.mjs
Normal file
169
plugins/zcode-mail-bridge/test/generic-mcp.test.mjs
Normal file
@ -0,0 +1,169 @@
|
||||
/**
|
||||
* 通用化(去 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;
|
||||
}
|
||||
});
|
||||
@ -128,17 +128,22 @@ test('失败回信在没有任何尝试记录时也不崩', () => {
|
||||
|
||||
// ─── 能力说明(平台把自带危险工具禁掉了,模型必须知道)─────────────────
|
||||
|
||||
test('★ workspace 档:说清自带工具被禁、动手要用我们的工具、会被请示', () => {
|
||||
test('★ workspace 档:说清没有执行面(不带 run_command/write_file 这类不存在的东西)', () => {
|
||||
const p = buildMailPrompt({ agentName: 'zcode', data: mail({ permission_mode: 'workspace' }) });
|
||||
assert.match(p, /Bash \/ Write \/ Edit \/ js/, '必须点名哪些自带工具不可用');
|
||||
assert.match(p, /禁用/);
|
||||
assert.match(p, /run_command/);
|
||||
assert.match(p, /write_file/);
|
||||
assert.match(p, /申请授权/, '模型必须知道动手会先请示');
|
||||
// 被拒是业务结果而非故障,且**不能靠重试或绕道** —— 这三件事必须都说
|
||||
assert.match(p, /报错并给出原因/);
|
||||
// ★ 2026-10-02:执行类工具已从 MCP 面移除,**不得**再向模型承诺它们。
|
||||
// 若这里再出现 run_command / write_file,模型会去找一个不存在的工具,
|
||||
// 把整轮浪费在换名字重试上 —— 这正是“如实说清能力”的反面。
|
||||
assert.doesNotMatch(p, /run_command/, '不得承诺不存在的执行工具');
|
||||
assert.doesNotMatch(p, /write_file/);
|
||||
assert.match(p, /不能\*\*执行命令/);
|
||||
// 工具不存在而报错是真实结果,不能靠重试或绕道
|
||||
assert.match(p, /真实结果/);
|
||||
assert.match(p, /不要重试/);
|
||||
assert.match(p, /绕道|其它执行手段/);
|
||||
assert.match(p, /换名字再试/);
|
||||
// 兜底路径要给出:需要动手就写进回信请人做,而不是自己硬试
|
||||
assert.match(p, /需要动手|写进回信/);
|
||||
// 只读工具要明确可用,否则模型会以为自己什么都干不了
|
||||
assert.match(p, /Read \/ Glob \/ Grep/);
|
||||
});
|
||||
@ -146,16 +151,20 @@ test('★ workspace 档:说清自带工具被禁、动手要用我们的工具
|
||||
test('★ plan 档:明说不能动手,别浪费一轮去试', () => {
|
||||
const p = buildMailPrompt({ agentName: 'zcode', data: mail({ permission_mode: 'plan' }) });
|
||||
assert.match(p, /plan 档/);
|
||||
assert.match(p, /不能\*\*执行命令或写文件|不能\*\*执行/);
|
||||
assert.match(p, /一律拒绝/);
|
||||
assert.doesNotMatch(p, /申请授权/, 'plan 档不该说会去申请授权(它根本不会发请求)');
|
||||
assert.match(p, /不能\*\*执行命令/);
|
||||
assert.doesNotMatch(p, /申请授权/, '根本不会发请求,不该说会去申请');
|
||||
assert.doesNotMatch(p, /run_command|write_file/, '不得承诺不存在的执行工具');
|
||||
});
|
||||
|
||||
test('★ full 档:明说免问(否则模型会以为每步都要等人,反而不敢动手)', () => {
|
||||
test('★ full 档:即使全权,也明说平台没有执行面', () => {
|
||||
const p = buildMailPrompt({ agentName: 'zcode', data: mail({ permission_mode: 'full' }) });
|
||||
assert.match(p, /full 档/);
|
||||
assert.match(p, /直接生效/);
|
||||
assert.match(p, /不会打扰|无需/);
|
||||
// ★ 2026-10-02:full 档只是“不问人”,不等于“工具存在”。
|
||||
// 旧文案说 full 档“直接生效、不会打扰”—— 那会让模型以为能动手,
|
||||
// 然后花一整轮去找一个已被移除的工具。
|
||||
assert.match(p, /不能\*\*执行命令/, 'full 档也必须明说没有执行面');
|
||||
assert.match(p, /只提供邮件能力/);
|
||||
assert.doesNotMatch(p, /run_command|write_file/);
|
||||
assert.doesNotMatch(p, /第一次调用会先向发件人申请授权/);
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user