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