Files
MailUI4Agents/plugins/zcode-mail-bridge/test/action-tools.test.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

330 lines
15 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.

/**
* 执行工具lib/action-tools.mjs的测试。
*
* 这些工具是**唯一**能动机器的路径(平台自带的 Bash/Write/Edit/js 已被
* `--disallowed-tools` 禁掉),所以每条测试都必须同时验两件事:
*
* 1. 结果对不对(执行了 / 返回了什么)
* 2. **在没获批准时,副作用真的没有发生**
*
* 第 2 条不能只看「抛错了」—— 抛错之后照样写文件是最糟的实现方式,
* 而只验抛错完全发现不了。所以拒绝场景一律配一个文件系统断言。
*/
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { mkdtemp, readFile, rm, stat, mkdir } from 'node:fs/promises';
import { existsSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { buildActionTools } from '../lib/action-tools.mjs';
import { createGrantStore } from '../lib/permission-grants.js';
/** 假 SSE立刻发 connected测试自己投喂决策。 */
function makeSSE() {
const s = { onEvent: null, stopped: false };
return {
state: s,
factory: ({ onEvent }) => {
s.onEvent = onEvent;
queueMicrotask(() => onEvent('connected', {}));
return { stop: () => { s.stopped = true; } };
}
};
}
function makeClient({ decision, fail } = {}) {
const state = { requests: [] };
return {
state,
client: {
baseURL: 'http://gw.test',
authHeaders: () => ({}),
async post(path, body) {
state.requests.push({ path, body });
if (fail) throw fail;
return {};
}
}
};
}
/** 人都同意场景:请求受理后立刻投喂「同意」。 */
function approving({ decision = '同意' } = {}) {
const sse = makeSSE();
const c = makeClient();
const orig = c.client.post;
c.client.post = async (p, b) => {
await orig(p, b);
queueMicrotask(() => sse.state.onEvent('permission_decision', { relay_key: b.relay_key, decision }));
return {};
};
return { ...c, factory: sse.factory };
}
async function withTools(env, fn, opts = {}) {
const dir = await mkdtemp(join(tmpdir(), 'zc-act-'));
const c = opts.client || makeClient();
const tools = buildActionTools({
client: c.client,
env: { AGENTMAIL_SESSION_ID: 'sess-1', AGENTMAIL_WORKSPACE_ROOT: dir, ...env },
grants: opts.grants || null,
createSSE: opts.createSSE,
log: () => {}
});
const byName = new Map(tools.map(t => [t.name, t]));
try {
return await fn({ byName, dir, client: c });
} finally {
await rm(dir, { recursive: true, force: true });
}
}
// ─── 工具面本身 ─────────────────────────────────────────────────────────
test('★ 工具面只暴露两个执行工具,且都声明为 destructive', async () => {
await withTools({}, async ({ byName }) => {
assert.deepEqual([...byName.keys()].sort(), ['run_command', 'write_file']);
for (const [name, t] of byName) {
assert.equal(t.annotations.readOnlyHint, false, `${name} 不该声称只读`);
// destructiveHint 必须为真plan 档下平台的判定是
// 「permissionName==="mcp" && !destructive → allow」声明成非破坏性会让
// 这两个工具在只读档被平台放行 —— 那时我们的门禁也会拒,但平台那层
// 已经先把话说错了。
assert.equal(t.annotations.destructiveHint, true, `${name} 必须声明为破坏性`);
}
});
});
// ─── run_command ────────────────────────────────────────────────────────
test('★ 获批准后真的执行,并返回退出码与输出', async () => {
const c = approving();
await withTools({ AGENTMAIL_PERMISSION_MODE: 'workspace' }, async ({ byName }) => {
const out = await byName.get('run_command').run({ command: 'echo hello; echo err >&2' });
assert.match(out, /退出码0/);
assert.match(out, /hello/);
assert.match(out, /err/);
}, { client: c, createSSE: c.factory });
});
test('★ 拒绝时抛错、且命令真的没执行', async () => {
await withTools({ AGENTMAIL_PERMISSION_MODE: 'plan' }, async ({ byName, dir }) => {
const marker = join(dir, 'should-not-exist.txt');
await assert.rejects(
() => byName.get('run_command').run({ command: `touch ${marker}` }),
/未获批准/
);
assert.equal(existsSync(marker), false, '被拒的命令仍然产生了副作用');
});
});
test('★ 命令非零退出不是工具失败:原样把退出码与 stderr 交给模型', async () => {
// 抛错会让模型以为工具坏了并重试;而 `grep` 没匹配到、测试失败、
// 编译报错都是**正常的命令结果**,模型靠 stderr 判断下一步。
const c = approving();
await withTools({ AGENTMAIL_PERMISSION_MODE: 'full' }, async ({ byName }) => {
const out = await byName.get('run_command').run({ command: 'echo boom >&2; exit 7' });
assert.match(out, /退出码7/);
assert.match(out, /boom/);
}, { client: c, createSSE: c.factory });
});
test('★ 超时被当作命令结果报告(不能挂死整轮)', async () => {
const c = approving();
await withTools({ AGENTMAIL_PERMISSION_MODE: 'full' }, async ({ byName }) => {
const out = await byName.get('run_command').run({ command: 'sleep 5', timeout_ms: 300 });
assert.match(out, /退出码:(SIGTERM|null)/);
assert.match(out, /超时被终止/);
assert.match(out, /上限 300ms/);
}, { client: c, createSSE: c.factory });
});
test('★ 输出过长时截断并明确说明截断了多少', async () => {
const c = approving();
await withTools({ AGENTMAIL_PERMISSION_MODE: 'full' }, async ({ byName }) => {
const out = await byName.get('run_command').run({ command: `seq 1 20000` });
assert.match(out, /被截断,省略 \d+ 字符/);
assert.ok(out.length < 20000, '截断没生效');
}, { client: c, createSSE: c.factory });
});
test('★ 工作目录默认是本会话工作区,可用 cwd 覆盖', async () => {
const c = approving();
await withTools({ AGENTMAIL_PERMISSION_MODE: 'full' }, async ({ byName, dir }) => {
const out = await byName.get('run_command').run({ command: 'pwd' });
assert.match(out, new RegExp(dir.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')));
}, { client: c, createSSE: c.factory });
});
test('空命令被拒(不浪费一次人工审批)', async () => {
await withTools({ AGENTMAIL_PERMISSION_MODE: 'full' }, async ({ byName }) => {
await assert.rejects(() => byName.get('run_command').run({ command: ' ' }), /command 不能为空/);
});
});
// ─── write_file ─────────────────────────────────────────────────────────
test('★ 获批准后真的写入文件(含自动建父目录)', async () => {
const c = approving();
await withTools({ AGENTMAIL_PERMISSION_MODE: 'workspace' }, async ({ byName, dir }) => {
const target = join(dir, 'deep', 'nested', 'a.txt');
const out = await byName.get('write_file').run({ path: target, content: '内容' });
assert.match(out, /已写入/);
assert.equal(await readFile(target, 'utf8'), '内容');
}, { client: c, createSSE: c.factory });
});
test('★ 拒绝时抛错、且不创建文件也不创建目录', async () => {
await withTools({ AGENTMAIL_PERMISSION_MODE: 'plan' }, async ({ byName, dir }) => {
const target = join(dir, 'deep', 'x.txt');
await assert.rejects(() => byName.get('write_file').run({ path: target, content: 'x' }), /未获批准/);
assert.equal(existsSync(target), false, '被拒的写入仍然产生了文件');
assert.equal(existsSync(join(dir, 'deep')), false, '被拒的写入仍然创建了目录');
});
});
test('★ 保护目录:即使有人批准也拒,而且**根本不发审批请求**', async () => {
// 这不是不信任人,而是防自我强化:邮件驱动的 Agent 可能被来信诱导去改
// 网关数据库/服务单元/自己的插件代码,改完下一轮就换了一套规则。
// 所以这道判定必须在门禁**之前**,且不能消耗人的注意力。
const c = approving();
for (const target of [
'/opt/agentmail/data/agentmail.db',
'/opt/agentmail/plugins/zcode-mail-bridge/x.mjs',
'/etc/systemd/system/homeagent.service',
'/etc/agentmail/pi.env',
'/root/.agentmail-zcode/secret'
]) {
await withTools({ AGENTMAIL_PERMISSION_MODE: 'full' }, async ({ byName }) => {
await assert.rejects(
() => byName.get('write_file').run({ path: target, content: 'x' }),
/平台保护目录/,
`${target} 应该被保护`
);
}, { client: c, createSSE: c.factory });
}
// 反向对照:保护目录外真的写了(否则上面全绿可能只是因为全都写不进去)。
await withTools({ AGENTMAIL_PERMISSION_MODE: 'full' }, async ({ byName, dir }) => {
const p = join(dir, 'ok.txt');
await byName.get('write_file').run({ path: p, content: 'ok' });
assert.equal(await readFile(p, 'utf8'), 'ok');
}, { client: c, createSSE: c.factory });
});
test('★ 保护判定不能被路径花招绕过(大小写/相对路径/..', async () => {
for (const target of [
'/opt/agentmail/data/../data/agentmail.db',
'/opt/agentmail/./data/x',
'/etc/systemd/system/../system/x.service'
]) {
await withTools({ AGENTMAIL_PERMISSION_MODE: 'full' }, async ({ byName }) => {
await assert.rejects(() => byName.get('write_file').run({ path: target, content: 'x' }), /平台保护目录/);
});
}
});
test('★ 相对路径按工作区解析(不能靠相对路径逃出工作区之外)', async () => {
await withTools({ AGENTMAIL_PERMISSION_MODE: 'full' }, async ({ byName, dir }) => {
const out = await byName.get('write_file').run({ path: 'sub/rel.txt', content: 'r' });
assert.match(out, new RegExp('sub/rel.txt'));
assert.equal(await readFile(join(dir, 'sub', 'rel.txt'), 'utf8'), 'r');
const st = await stat(join(dir, 'sub', 'rel.txt'));
assert.ok(st.isFile());
});
});
test('content 必须是字符串(否则会写出 "[object Object]"', async () => {
await withTools({ AGENTMAIL_PERMISSION_MODE: 'full' }, async ({ byName }) => {
await assert.rejects(() => byName.get('write_file').run({ path: 'x.txt', content: { a: 1 } }), /必须是字符串/);
await assert.rejects(() => byName.get('write_file').run({ content: 'x' }), /path 不能为空/);
});
});
// ─── 门禁接线 ───────────────────────────────────────────────────────────
test('★ 授权请求里带上了人真正需要看的信息(命令原文 / 用途 / 目标路径)', async () => {
const c = approving();
await withTools({ AGENTMAIL_PERMISSION_MODE: 'workspace' }, async ({ byName, client }) => {
await byName.get('run_command').run({ command: 'rm -rf /tmp/x', purpose: '清理临时文件' });
const body = client.state.requests.at(-1).body;
assert.equal(body.session_id, 'sess-1');
assert.match(body.question, /rm -rf \/tmp\/x/, '批准人必须看到命令原文');
assert.match(body.context, /清理临时文件/, '用途要带给批准人');
assert.match(body.relay_key, /sess-1/);
}, { client: c, createSSE: c.factory });
});
test('★ 「一直同意」命中时不再打扰人(同一会话同一工具)', async () => {
const grants = createGrantStore();
grants.grant('sess-1', 'run_command', '一直同意');
const c = makeClient();
await withTools({ AGENTMAIL_PERMISSION_MODE: 'workspace' }, async ({ byName }) => {
const out = await byName.get('run_command').run({ command: 'echo granted' });
assert.match(out, /granted/);
}, { client: c, grants });
assert.equal(c.state.requests.length, 0, '已有授权却仍然发了审批请求');
});
test('★ full 档不打扰人(发件人已声明全权)', async () => {
const c = makeClient();
await withTools({ AGENTMAIL_PERMISSION_MODE: 'full' }, async ({ byName }) => {
const out = await byName.get('run_command').run({ command: 'echo full' });
assert.match(out, /full/);
}, { client: c });
assert.equal(c.state.requests.length, 0);
});
test('★ 网关不可达时 fail closed不执行、不写文件', async () => {
const fail = Object.assign(new Error('ECONNREFUSED'), { status: 502 });
const c = makeClient({ fail });
const sse = makeSSE();
await withTools({ AGENTMAIL_PERMISSION_MODE: 'workspace' }, async ({ byName, dir }) => {
const marker = join(dir, 'nope.txt');
await assert.rejects(() => byName.get('run_command').run({ command: `touch ${marker}` }), /未获批准/);
assert.equal(existsSync(marker), false);
}, { client: c, createSSE: sse.factory });
});
// ─── 等待窗口必须容得下「人真的来点一下」────────────────────────────────
// 这一组来自一个实测缺陷工具在等授权客户端ZCode默认 30 秒就把这次
// MCP 调用掐了模型于是回报「30 秒内未获批准」——看起来像人没理它,
// 实际是门禁的等待窗口被截断,而且**表现得完全正常**。
test('★ 授权等待被夹到 MCP 调用超时之下(并留下可发现的痕迹)', async () => {
const { resolveWaitMs, resolveMcpTimeoutMs } = await import('../lib/action-tools.mjs');
// 清单里声明的时间本插件自己的清单实测生效40 秒的命令没被砍)
const declared = resolveMcpTimeoutMs();
assert.ok(declared && declared >= 60000, `清单应声明一个够长的 timeoutMs实际 ${declared}`);
// 配置想等 90 分钟,但 MCP 只给 10 分钟 → 应夹到 10 分钟减余量
const capped = resolveWaitMs({ AGENTMAIL_PERMISSION_WAIT_MS: '5400000' }, 600000);
assert.ok(capped.waitMs < 600000, '必须小于 MCP 超时,否则调用会先被杀掉');
assert.ok(capped.waitMs >= 600000 - 120000, '也不该夹得过小(人需要时间点同意)');
assert.equal(capped.capped, true, '被夹小这件事必须能被发现(要写日志)');
// 边界:配置正好等于上限 → 不算被夹(它本来就 settle 得掉)
const onEdge = resolveWaitMs({ AGENTMAIL_PERMISSION_WAIT_MS: String(600000 - 30000) }, 600000);
assert.equal(onEdge.capped, false);
assert.equal(onEdge.waitMs, 570000);
// 反向对照:配置本来就比 MCP 超时小 → 原样使用,不报「被夹」
const fine = resolveWaitMs({ AGENTMAIL_PERMISSION_WAIT_MS: '120000' }, 600000);
assert.equal(fine.waitMs, 120000);
assert.equal(fine.capped, false);
// 反向对照:读不到清单时不猜,沿用配置(并在日志里说没校到)
const unknown = resolveWaitMs({ AGENTMAIL_PERMISSION_WAIT_MS: '540000' }, null);
assert.equal(unknown.waitMs, 540000);
assert.equal(unknown.capped, false);
});
test('★ 清单里的 timeoutMs 必须真的存在且够长(否则门禁没有可行窗口)', async () => {
const { resolveMcpTimeoutMs } = await import('../lib/action-tools.mjs');
const t = resolveMcpTimeoutMs();
assert.ok(t, '插件清单的 mcpServers.agentmail 必须有 timeoutMs');
// 默认 30 秒的 MCP 超时下,人根本来不及看到请求 —— 所以必须显式声明一个大的。
assert.ok(t > 300000, `timeoutMs=${t} 太短,人工审批窗口不够`);
});