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