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:
@ -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);
|
||||
}
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
};
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
@ -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]));
|
||||
}
|
||||
Reference in New Issue
Block a user