/** * 执行工具(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} 太短,人工审批窗口不够`); });