按用户裁定「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、会抢邮件)。
## 判据纪律(本轮又踩到、已写进代码注释)
「文件没被创建」不能区分「被拦住了」与「进程根本没跑」;
「未获批准」不能区分「人拒绝」与「窗口被截断」;
「工具报错」不能区分「命令失败」与「工具坏了」。
每一处都改成了验到**具体成因**。
298 lines
11 KiB
JavaScript
298 lines
11 KiB
JavaScript
/**
|
||
* 跑一轮 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);
|
||
});
|
||
});
|
||
}
|