按用户裁定「yolo_own_tools」实现:平台让开(--mode yolo),它自带的一切
「能动机器」的工具被 --disallowed-tools 拿掉,执行类动作改由我们自己的
run_command / write_file 承担,而门禁就在这两个工具里 —— 逐次向发件人请示。
## 为什么必须走这条路(实测,不是推断)
MCP 工具的 needsApproval 在产物里**硬编码为 true**(与 annotations 无关),
而 build/edit 档的判定最后一条是「需要审批 → ask」;headless 没有审批客户端
可问 ⇒ **每个 MCP 工具都被拒**(连 read_inbox 都调不动)。
我们本想让平台把询问转给钩子,但 PermissionRequest 在本版本(3.10.2 / CLI 0.16.5)
**不可靠**:有时压根不注册,触发时也无条件在 ~5ms 内失败、命令从未被 spawn
(用「钩子写 marker 文件」的副作用验证)。
于是选择只剩两个:「平台问、但问不到人 → 全拒」与「平台不问、我们自己问」。
后者才既可用又可审计。代价(平台不再提供第二道防线)写进了 README 的残余风险。
## 新增
- `lib/approval.mjs`:授权往返的唯一实现(钩子与工具共用,否则必然漂移)。
三条不可动摇的规矩:只有明确同意才放行(判据是共用库的前缀白名单,
不是「不等于拒绝」);永久失败(409/4xx)当场拒绝并把服务端建议带给模型;
暂时失败看有没有本地界面 —— 判据用**调用方传的 sessionId**(单一事实来源,
不再另读环境变量)。自己开 SSE 等决定,先建连再发请求。
- `lib/action-tools.mjs`:`run_command` / `write_file`。输出上限、超时上限、
默认 cwd=工作区;拒绝时**抛错**(MCP 层转 isError)而不是返回「已处理」——
opencode 上「工具失败但报成功」导致模型连试 6 次后放弃整个任务的教训。
平台保护目录(网关数据库/插件代码/服务单元/密钥目录)**无论谁批准都不写**,
且判定在门禁之前(不消耗人的注意力)——防的是自我强化:邮件驱动的 Agent
可能被来信诱导去改自己的插件代码,改完下一轮就换了一套规则。
- `REVIEWED_DENYLIST`(32 项):逐条按「不拿掉会怎样」分类。名单来自 CLI 产物里
模型可见工具名的**权威注册表**(aIn 那个 28 项数组)+ 另一份更宽的候选集并集,
**不采信模型自述**(基线里它用某个没点名的方式真的创建了文件)。
最容易被漏掉的是 `js` / `mcp__node_repl__js`:它挂在 MCP 上、
产物里自述「can run arbitrary JavaScript with full Node privileges, like Bash」。
- 提示词的能力说明(分档):告诉模型自带工具被禁、动手要用哪两个工具、
会被请示;并明确「被拒是业务结果,不要重试、不要绕道」。
## 修掉三个真缺陷(都是实测撞出来的)
1. **幂等键按「会话+工具」取 → 同会话第二次调用被静默吞掉**。
网关对重复 relay_key 返回 **HTTP 200** `{status:"duplicate_relay"}` 并提前返回:
不建请求、不发邮件、**永远不会有人来决策**。于是工具干等 → 被 MCP 调用超时
砍掉 → 模型回报「30 秒内未获批准」。从状态码到措辞全看不出问题,归因还完全
错了(像是人没理它)。改为**按调用唯一**(保留会话/工具前缀便于反查),
并把 duplicate_relay 当成可读的拒绝(fail fast,不再干等)。
2. **授权窗口被 MCP 调用超时截断**。ZCode 对 MCP 工具调用有超时(默认量级 30 秒),
而门禁要等人。已在插件清单声明 `mcpServers.agentmail.timeoutMs=600000`
(实测生效:40 秒的命令没被砍,墙钟 50 秒通过),并让门禁**自己**把等待夹到
timeoutMs - 余量之下(`resolveWaitMs`)——被客户端杀掉时连理由都发不出去,
所以必须由我们自己先 settle。
3. **`--allowed-tools` 在 help 里写着但解析器不认**(`Unknown option`)。
留着会拼出一条永远跑不起来的命令行,现在 `buildRunArgs` 直接抛错并指出
替代方案。我在这里误判过一次:先看到「文件没创建」就以为白名单生效,
其实进程只是没退到 usage。判据缺了「进程真的执行了」这一环。
## 自报改成如实
detectModeEnforcement 以前拿「钩子已注册」当 native 的凭据 —— yolo 下钩子
根本不会触发,那等于替一个不存在的能力背书。现在先看**我们那条链**是否就绪
(yolo + 禁用清单里真的有 Bash/js),就绪才报 native,并在理由里点明谁在把关
(实测输出:「执行类动作只能经我们自己的门禁…平台自带危险工具已禁用 32 项」)。
## 验证
- 单测 376/376(新增 47 条)。重点在反向对照:一句「拒绝/deny/空串/平台自己的
shutdown 哨兵都不放行」之外,还验了「别人的决策不能拿来用(relay_key 配对)」、
「超时必须真的拒绝」、「同一会话两次调用必须用不同的幂等键」、
「重复请求要当场拒绝而不是干等」;执行工具的每条拒绝场景都配一个**文件系统断言**
(「抛错了」不等于「副作用没发生」),保护目录还验了 `..`/`./` 绕不过去。
- 真模型端到端(`/root/e2e-zcode-gate/run.py`,13/13):
批 → 命令真执行(文件内容=标记);拒 → 命令真没执行(文件不存在)
且回信把成因说成「人拒绝」而**不是**「超时」;同会话第三次调用仍能产生新请求
并在获批后执行。判据本身也修了两处(授权请求邮件里带标记会被误当成回信;
备注在通过项旁边显示会误导)。
- 部署:`deploy/redeploy-plugin.sh zcode` 快照切换 + 握手自检;
驱动单元改为跑快照(生产不跑仓库工作区),env 与清单超时的关系写进注释。
- 顺手清掉一个遗留驱动进程(跑的是仓库路径的旧代码、连着网关 SSE、会抢邮件)。
## 判据纪律(本轮又踩到、已写进代码注释)
「文件没被创建」不能区分「被拦住了」与「进程根本没跑」;
「未获批准」不能区分「人拒绝」与「窗口被截断」;
「工具报错」不能区分「命令失败」与「工具坏了」。
每一处都改成了验到**具体成因**。
117 lines
4.7 KiB
JavaScript
117 lines
4.7 KiB
JavaScript
#!/usr/bin/env node
|
||
/**
|
||
* AgentMail 的 ZCode MCP 服务器入口。
|
||
*
|
||
* ZCode 按插件清单里的 `mcpServers` 启动本文件:
|
||
*
|
||
* node <zcode.cjs> __zcode-plugin-host <plugin>/mcp/server.mjs
|
||
*
|
||
* 启动后说 MCP(换行分隔 JSON-RPC,走 stdio),工具实现在 lib/tools.mjs。
|
||
*
|
||
* # stdout 是协议通道
|
||
*
|
||
* stdout 上**只能**出现协议消息。任何一行 `console.log` 都会被客户端当成
|
||
* JSON 解析失败 —— 于是服务器看起来「起来了但一个工具都没有」。
|
||
* 本文件里所有诊断一律 `console.error`(stderr 被客户端当日志转发,不影响协议)。
|
||
*
|
||
* # 起不来要说清楚
|
||
*
|
||
* 这是邮件驱动会话的一部分:没有本地 UI 让人看见崩溃。所以缺配置时
|
||
* 不是静默退出,而是把原因写到 stderr(进 ZCode 日志),并在**每次工具调用**时
|
||
* 再报一次(模型能读,于是它会告诉人)。
|
||
*/
|
||
|
||
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';
|
||
|
||
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 });
|
||
|
||
// 「会动机器」的工具(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 = {
|
||
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 || '(未配置)'},` +
|
||
`工具 ${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);
|
||
});
|
||
}
|