Files
MailUI4Agents/plugins/zcode-mail-bridge/src/zcode-run.mjs
JianFeeeee a00cbf36fc 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、会抢邮件)。

## 判据纪律(本轮又踩到、已写进代码注释)

「文件没被创建」不能区分「被拦住了」与「进程根本没跑」;
「未获批准」不能区分「人拒绝」与「窗口被截断」;
「工具报错」不能区分「命令失败」与「工具坏了」。
每一处都改成了验到**具体成因**。
2026-09-12 19:05:54 +08:00

298 lines
11 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 跑一轮 ZCode:起一个 headless 进程,把事件流读回来,取出最终回复。
*
* # 为什么用 `--output-format stream-json` 而不是 `--json`
*
* 两者都能在最后给出 `{sessionId, response}`(这是从 CLI 产物里读出的契约:
* 逐条事件写 `mapSessionEvent(event)`,最后补一行 `{type:"result", …}`)。
* 差别在**过程可见**:`--json` 全程没有输出,一个卡住的回合与一个正在干活的
* 回合在外部看起来完全一样 —— 而邮件驱动的会话没有界面,除了日志没人能看见它。
* stream-json 让「正在做什么」进得了日志,也让超时能被归因。
*
* # 会话延续
*
* 首轮没有 `--resume`,从 `result` 行里取回 `sess_...` 存下来;后续同一
* AgentMail 会话的信都带上 `--resume`,于是模型记得前几轮。丢了它就等于
* 「每封信都从零开始」,而模型会表现得像没见过之前的要求。
*
* # 超时
*
* 到期杀**进程树**:ZCode 会派生工具子进程(bash 等),只杀父进程会留下一堆
* 孤儿继续跑,而且它们还占着工作目录。
*/
import { spawn as nodeSpawn } from 'node:child_process';
/** 默认的 CLI 入口:ZCode 自带的纯 CLI(不是 Electron 那个 GUI 入口)。 */
export const DEFAULT_CLI = '/opt/ZCode/resources/glm/zcode.cjs';
const DEFAULT_TURN_TIMEOUT_MS = 20 * 60 * 1000;
/**
* 拼出一次 headless 调用的参数表。
*
* 单独抽出来是为了可测:`--mode` 漏掉会**静默绕过全部授权询问**
* (`--prompt` 的默认 mode 是 yolo),这种缺陷不会报错,只会让授权系统消失。
*/
export function buildRunArgs({
prompt,
cwd,
mode,
maxTurns,
resumeSessionId,
allowedTools,
disallowedTools,
cliPath = DEFAULT_CLI
}) {
const args = [
cliPath,
'--prompt',
prompt,
'--output-format',
'stream-json',
'--cwd',
cwd
];
// mode 必传:不传就是 yolo,等于关掉授权。
if (!mode) throw new Error('buildRunArgs 需要 mode(不传等于 yolo,会绕过授权询问)');
args.push('--mode', mode);
if (maxTurns) args.push('--max-turns', String(maxTurns));
if (resumeSessionId) args.push('--resume', resumeSessionId);
// `--allowed-tools` 在 help 里写着,但 CLI 的解析器**不认**它
// (`Unknown option '--allowed-tools'`),会直接退到 usage。
// 留着这个参数会拼出一条永远跑不起来的命令行,所以在拼参数这一步就报错,
// 而不是等到一两分钟后拿到一段 usage 文本才去查。
if (Array.isArray(allowedTools) && allowedTools.length) {
throw new Error(
'本版本 ZCode CLI 不支持 --allowed-tools(解析器报 Unknown option);' +
'只能用 --disallowed-tools 黑名单,见 src/turn-mode.mjs 的 REVIEWED_DENYLIST'
);
}
// 逗号分隔:help 说「Comma or space-separated」,实测两种都行,
// 但逗号不受调用方是否把参数拼成单个 argv 的影响。
if (Array.isArray(disallowedTools) && disallowedTools.length) {
args.push('--disallowed-tools', disallowedTools.join(','));
}
return args;
}
/**
* 解析一行 stream-json 输出的**纯函数**部分。
*
* 返回 `null` 表示这行不是我们要的(非 JSON、或没有意义的行)——
* 调用方据此计数,好让「输出格式变了」这件事能被发现,而不是静默当成没输出。
*
* @returns {{kind:'event'|'result', event?:any, sessionId?:string, response?:string}|null}
*/
export function parseStreamLine(line) {
const text = String(line ?? '').trim();
if (!text || text[0] !== '{') return null;
let obj;
try {
obj = JSON.parse(text);
} catch {
return null;
}
if (!obj || typeof obj !== 'object') return null;
if (obj.type === 'result') {
return {
kind: 'result',
sessionId: typeof obj.sessionId === 'string' ? obj.sessionId : '',
response: typeof obj.response === 'string' ? obj.response : '',
eventCount: typeof obj.eventCount === 'number' ? obj.eventCount : undefined,
projection: obj.projection
};
}
return { kind: 'event', event: obj };
}
/**
* 跑一轮。
*
* @param {{prompt:string, cwd:string, mode:string, maxTurns?:number,
* resumeSessionId?:string, turnTimeoutMs?:number,
* disallowedTools?:string[],
* env?:Record<string,string>, cliPath?:string, nodePath?:string,
* onChild?:(kill:(signal?:string)=>void)=>void,
* onEvent?:(event:any)=>void}} opts
* @param {{spawn?:Function, log?:Function}} [deps] spawn 可注入以便测试
* @returns {Promise<{sessionId:string, response:string, events:any[], exitCode:number,
* unparsable:number, timedOut:boolean, killed:boolean,
* stderrTail:string, argv:string[]}>}
*/
export function runTurn(opts, deps = {}) {
const spawn = deps.spawn || nodeSpawn;
const log = deps.log || (() => {});
const nodePath = opts.nodePath || process.execPath;
const args = buildRunArgs(opts);
const timeoutMs = opts.turnTimeoutMs ?? DEFAULT_TURN_TIMEOUT_MS;
// 宽限期:SIGTERM 之后等多久 SIGKILL;再等多久就无论如何收尾。
// 可配是为了测试 —— 生产用默认值。
const killGraceMs = opts.killGraceMs ?? 5000;
const settleGraceMs = opts.settleGraceMs ?? 2000;
const startedAt = Date.now();
return new Promise(resolve => {
let child;
try {
child = spawn(nodePath, args, {
cwd: opts.cwd,
env: { ...process.env, ...(opts.env || {}) },
stdio: ['ignore', 'pipe', 'pipe'],
// 自己建进程组,超时时能整组杀 —— 否则 ZCode 派生的工具子进程会活下来。
detached: process.platform !== 'win32'
});
} catch (e) {
resolve({
sessionId: '',
response: '',
events: [],
exitCode: -1,
unparsable: 0,
timedOut: false,
killed: false,
stderrTail: `spawn 失败:${e?.message || e}`,
argv: args
});
return;
}
const events = [];
let result = null;
let unparsable = 0;
let stdoutBuf = '';
const stderrLines = [];
let timedOut = false;
let killed = false;
let settled = false;
const killTree = signal => {
killed = true;
try {
if (process.platform !== 'win32' && child.pid) {
// 负号 = 整个进程组
process.kill(-child.pid, signal);
} else {
child.kill(signal);
}
} catch {
try {
child.kill(signal);
} catch {
/* 已经没了 */
}
}
};
const timer = setTimeout(() => {
timedOut = true;
log(`回合超时(${Math.round(timeoutMs / 1000)} 秒),终止进程树`);
killTree('SIGTERM');
// 给它一点时间优雅退出;不走就强杀(工具子进程不会自己收手)。
//
// 这几个定时器**刻意不 unref**:在途的回合应该让进程活着。
// unref 过的定时器会让「没有其它活跃句柄」的进程直接退出,
// 于是收尾这段代码根本没机会跑(测试里就是这样暴露的)。
setTimeout(() => killTree('SIGKILL'), killGraceMs);
// ★ 最后兵底:**必须**让这一轮结束。
//
// 没有这一步时,一个既不退也不报错的孩子会让 Promise 永不 settle ——
// 驱动会对那封信永远挂住,而且队列是串行的,后面的信全都不再被处理。
// 杀掉进程本来就应该算「这一轮结束了」,而不是「再等等看」。
setTimeout(() => {
if (settled) return;
log('强杀后仍未退出,按超时收尾(不再等)');
finish(-1);
}, killGraceMs + settleGraceMs);
}, timeoutMs);
const finish = exitCode => {
if (settled) return;
settled = true;
clearTimeout(timer);
resolve({
sessionId: result?.sessionId || '',
response: result?.response || '',
events,
exitCode,
unparsable,
timedOut,
killed,
stderrTail: stderrLines.slice(-8).join('\n'),
argv: args
});
};
// 把「怎么杀」交给调用方:驱动在收到 SIGTERM 时要能终止在途的回合。
// 不交出去的话,systemd 杀掉驱动之后那个 ZCode 进程还在跑工具,
// 而没有任何人(也没有任何界面)看着它。
if (typeof opts.onChild === 'function') {
try {
opts.onChild(killTree);
} catch {
/* 调用方自己的问题,不影响这一轮 */
}
}
child.stdout?.on('data', chunk => {
stdoutBuf += chunk.toString('utf8');
let nl;
while ((nl = stdoutBuf.indexOf('\n')) >= 0) {
const line = stdoutBuf.slice(0, nl);
stdoutBuf = stdoutBuf.slice(nl + 1);
const parsed = parseStreamLine(line);
if (!parsed) {
// 空行是正常的(CLI 用空格分隔事件),只统计真的不像 JSON 的行。
if (line.trim()) unparsable++;
continue;
}
if (parsed.kind === 'result') {
result = parsed;
} else {
events.push(parsed.event);
// 逐个事件交给调用方:邮件驱动的会话没有界面,
// 「模型正在干什么」只能靠日志,否则一个五分钟的回合在外部看起来
// 与一个卡死的回合完全一样。
if (typeof opts.onEvent === 'function') {
try {
opts.onEvent(parsed.event);
} catch {
/* 调用方的日志出错不该影响这一轮 */
}
}
}
}
});
child.stderr?.on('data', chunk => {
for (const line of chunk.toString('utf8').split('\n')) {
const t = line.trim();
if (!t) continue;
stderrLines.push(t);
// 实时转出去 —— 邮件驱动没有界面,日志是唯一能看见它在干什么的地方。
log(t);
}
});
child.on('error', e => {
stderrLines.push(`进程错误:${e?.message || e}`);
finish(-1);
});
child.on('close', code => {
// 收尾:最后一行可能没有换行
if (stdoutBuf.trim()) {
const parsed = parseStreamLine(stdoutBuf);
if (parsed?.kind === 'result') result = parsed;
else if (parsed?.kind === 'event') events.push(parsed.event);
else unparsable++;
}
log(
`回合结束:退出码 ${code},事件 ${events.length} 条,` +
`会话 ${result?.sessionId || '(无)'},耗时 ${Math.round((Date.now() - startedAt) / 1000)} 秒`
);
finish(code ?? -1);
});
});
}