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

409 lines
16 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
*
* 判据集中在几件**错了就会静默出错**的事上:
*
* - 说错「会不会替你回信」→ 要么发件人等一封永远不来的信,要么收到两封重复邮件
* - 忘了带 `--mode` → 授权询问全部消失(见 turn-mode 的测试)
* - 一轮跑不起来却不回信 → 发件人只看到「信发出去了,然后再无音讯」
* - 去重失效 → 同一封信被处理两遍
*/
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { mkdtemp, rm, readFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { createDriver } from '../src/index.mjs';
import { explicitSendsFile, noteExplicitSendFile } from '../lib/explicit-sends.mjs';
/** 假网关客户端:记下发出去的每一封信。 */
function fakeClient() {
const sent = [];
return {
sent,
baseURL: 'http://fake',
agentName: 'zcode',
authHeaders: () => ({ 'X-Agent-Name': 'zcode' }),
checkConfig: () => [],
async post(path, body) {
if (path === '/mail/send') {
sent.push(body);
return { mail_id: `out-${sent.length}` };
}
return {};
},
async get() {
return {};
}
};
}
/** 一封来自人的来信。 */
const humanMail = (over = {}) => ({
mail_id: 'in-1',
session_id: 'sess-1',
role: 'to',
from_human: true,
from_name: 'gui-lab',
subject: '帮我看看日志',
permission_mode: 'workspace',
to_workspace: '',
reply_address: 'gui-lab@/work.sess-1',
...over
});
async function harness({ turn = { sessionId: 'sess_z1', response: '结论:是磁盘满了', exitCode: 0 }, env } = {}) {
const dir = await mkdtemp(join(tmpdir(), 'zc-driver-'));
const client = fakeClient();
const logs = [];
const calls = [];
const driver = createDriver({
client,
logFn: (...a) => logs.push(a.join(' ')),
env: env || {},
config: { workspaceRoot: dir, cliPath: '/fake/zcode.cjs', turnTimeoutMs: 60_000 },
runTurnFn: async (opts, deps) => {
calls.push({ opts, deps });
// 第三个参数是调用序号:测试里常要「第一次失败、第二次成功」
return typeof turn === 'function' ? turn(opts, deps, calls.length) : turn;
}
});
return { driver, client, logs, calls, dir, cleanup: () => rm(dir, { recursive: true, force: true }) };
}
test('★ 人来信 + 模型没自己发 → 自动回信', async () => {
const h = await harness();
try {
await h.driver.processMail(humanMail());
assert.equal(h.client.sent.length, 1, '必须回一封信');
const mail = h.client.sent[0];
assert.equal(mail.to, 'gui-lab');
assert.equal(mail.body, '结论:是磁盘满了');
assert.equal(mail.reply_to, 'in-1');
assert.equal(mail.subject, 'Re: 帮我看看日志');
// 走免配额通道:模型已经把话说完了,驱动只是搬运
assert.equal(mail.relay, 'summary');
assert.ok(mail.relay_key, '要有幂等键');
} finally {
await h.cleanup();
}
});
test('★ Agent 来信 → 不自动转发Agent 间必须自己 send_mail', async () => {
// 反向对照:同一封邮件只翻转 from_human回信行为必须跟着翻转。
// 不这么做的话,两个 Agent 会互相把对方的「已收到」当成待办,无限客套下去。
const h = await harness();
try {
await h.driver.processMail(humanMail({ from_human: false, from_name: 'pi' }));
assert.equal(h.client.sent.length, 0, 'Agent 来信不该被自动回信');
assert.ok(
h.logs.some(l => /不自动转发/.test(l)),
`日志里应说明原因:${h.logs.join(' | ')}`
);
} finally {
await h.cleanup();
}
});
test('★ 模型这一轮自己发过信 → 让位,不重复转发', async () => {
// 线上实测过后果收件箱里两封说同一件事的邮件311 与 342 字节)。
const env = { AGENTMAIL_ZCODE_SENDS_FILE: join(await mkdtemp(join(tmpdir(), 'zc-sends-')), 'sends.jsonl') };
noteExplicitSendFile(env.AGENTMAIL_ZCODE_SENDS_FILE, {
sessionId: 'sess-1',
to: 'gui-lab',
replyTo: 'in-1'
});
const h = await harness({ env });
try {
await h.driver.processMail(humanMail());
assert.equal(h.client.sent.length, 0, '模型已亲手回过,驱动不该再发一封');
assert.ok(h.logs.some(l => /跳过自动转发/.test(l)));
} finally {
await h.cleanup();
}
});
test('★ 反向对照:另一条会话的主动发信不该让本会话沉默', async () => {
// 去重不能按「有人发过信」一刀切,必须按会话配对。
const dir = await mkdtemp(join(tmpdir(), 'zc-sends-'));
const env = { AGENTMAIL_ZCODE_SENDS_FILE: join(dir, 'sends.jsonl') };
noteExplicitSendFile(env.AGENTMAIL_ZCODE_SENDS_FILE, {
sessionId: 'sess-OTHER',
to: 'gui-lab',
replyTo: 'in-1'
});
const h = await harness({ env });
try {
await h.driver.processMail(humanMail());
assert.equal(h.client.sent.length, 1, '别的会话发过信不该影响这一封');
} finally {
await h.cleanup();
await rm(dir, { recursive: true, force: true });
}
});
test('★★ 一轮跑不起来 → 必须回一封失败信', async () => {
// 邮件驱动的会话没有本地界面:什么都不发等于「信发出去了,然后再无音讯」。
const h = await harness({
turn: { sessionId: '', response: '', exitCode: 1, stderrTail: 'Model config is missing.' }
});
try {
await h.driver.processMail(humanMail());
assert.equal(h.client.sent.length, 1, '失败也必须回信');
const mail = h.client.sent[0];
assert.match(mail.subject, /处理失败/);
assert.match(mail.body, /Model config is missing/);
// 失败信的正文要给出**这个平台**的成因,而不是别处的建议
assert.match(mail.body, /没有登录/);
assert.match(mail.body, /AGENTMAIL_ZCODE_CLI/);
assert.equal(h.driver.stats.failures, 1);
} finally {
await h.cleanup();
}
});
test('超时也算失败,且原因写明超时', async () => {
const h = await harness({
turn: { sessionId: '', response: '', exitCode: -1, timedOut: true }
});
try {
await h.driver.processMail(humanMail());
assert.match(h.client.sent[0].body, /超时/);
assert.doesNotMatch(h.client.sent[0].body, /CLI 失败/);
} finally {
await h.cleanup();
}
});
test('退出码 0 但没有最终文本 → 不冒充回信', async () => {
const h = await harness({ turn: { sessionId: 's', response: ' ', exitCode: 0 } });
try {
const r = await h.driver.processMail(humanMail());
assert.equal(h.client.sent.length, 0);
assert.equal(r.relayed, false);
assert.ok(h.logs.some(l => /没有产出最终文本/.test(l)));
} finally {
await h.cleanup();
}
});
// ─── 提示词与档位怎么传下去 ─────────────────────────────────────────
test('提示词里带上回信地址与邮件 id模型自己发信时要拼对地址', async () => {
const h = await harness();
try {
await h.driver.processMail(humanMail());
const prompt = h.calls[0].opts.prompt;
assert.match(prompt, /gui-lab@\/work\.sess-1/);
assert.match(prompt, /in-1/);
// 人来信:告诉模型插件会替它回信
assert.match(prompt, /回信不用你自己发/);
} finally {
await h.cleanup();
}
});
test('Agent 来信的提示词必须说清「插件不会替你回信」', async () => {
const h = await harness();
try {
await h.driver.processMail(humanMail({ from_human: false, from_name: 'pi' }));
const prompt = h.calls[0].opts.prompt;
assert.match(prompt, /不会替你回信/);
assert.doesNotMatch(prompt, /回信不用你自己发/);
} finally {
await h.cleanup();
}
});
test('★ 档位随邮件传下去,并作为 --mode / 禁用清单 / 钩子环境变量注入', async () => {
for (const [tier, mode] of [
['plan', 'plan'],
// workspace 与 full 都映射到 yolo平台不做权限判定它自带危险工具已被
// --disallowed-tools 拿掉),执行类动作改由我们自己的门禁逐次请示。
['workspace', 'yolo'],
['full', 'yolo']
]) {
const h = await harness();
try {
await h.driver.processMail(humanMail({ permission_mode: tier }));
assert.equal(h.calls[0].opts.mode, mode, `档位 ${tier} 应映射到 ${mode}`);
const env = h.calls[0].opts.env;
// 钩子靠这两个变量决定档位与「有没有本地界面」
assert.equal(env.AGENTMAIL_PERMISSION_MODE, tier);
assert.equal(env.AGENTMAIL_SESSION_ID, 'sess-1');
// 禁用清单必须真的传下去:它是「平台不问」时唯一的替代防线。
const denied = h.calls[0].opts.disallowedTools;
assert.ok(Array.isArray(denied) && denied.length > 20, '禁用清单未传给 ZCode');
for (const must of ['Bash', 'Write', 'Edit', 'js', 'mcp__node_repl__js']) {
assert.ok(denied.includes(must), `禁用清单缺少 ${must}`);
}
} finally {
await h.cleanup();
}
}
});
test('工作目录取 to_workspace没有就用兜底目录', async () => {
const h = await harness();
try {
await h.driver.processMail(humanMail());
assert.ok(h.calls[0].opts.cwd.startsWith(h.dir), `兜底目录应在 ${h.dir} 下,实际 ${h.calls[0].opts.cwd}`);
const real = await mkdtemp(join(tmpdir(), 'zc-ws-'));
await h.driver.processMail(humanMail({ mail_id: 'in-2', to_workspace: real }));
assert.equal(h.calls[1].opts.cwd, real);
await rm(real, { recursive: true, force: true });
} finally {
await h.cleanup();
}
});
test('★ 同一会话的第二封信带上 --resume否则模型每封信都从零开始', async () => {
const h = await harness();
try {
await h.driver.processMail(humanMail());
assert.equal(h.calls[0].opts.resumeSessionId, undefined, '首轮不该带 resume');
await h.driver.processMail(humanMail({ mail_id: 'in-2' }));
assert.equal(h.calls[1].opts.resumeSessionId, 'sess_z1', '第二轮要续上同一个 ZCode 会话');
} finally {
await h.cleanup();
}
});
// ─── 事件入口 ───────────────────────────────────────────────────────
test('SSE 事件:重复的 mail_id 只处理一次', async () => {
const h = await harness();
try {
h.driver.handleEvent('new_mail', humanMail());
h.driver.handleEvent('new_mail', humanMail());
await new Promise(r => setTimeout(r, 20));
assert.equal(h.calls.length, 1, '同一封信被处理了两遍');
} finally {
await h.cleanup();
}
});
test('抄送给自己也处理role=cc其它角色忽略', async () => {
const h = await harness();
try {
h.driver.handleEvent('new_mail', humanMail({ mail_id: 'cc-1', role: 'cc' }));
h.driver.handleEvent('new_mail', humanMail({ mail_id: 'x-1', role: 'from' }));
await new Promise(r => setTimeout(r, 20));
assert.equal(h.calls.length, 1);
assert.equal(h.calls[0].opts.prompt.includes('cc-1'), true);
} finally {
await h.cleanup();
}
});
test('非 new_mail 事件被忽略permission_decision 由钩子自己处理)', async () => {
const h = await harness();
try {
h.driver.handleEvent('permission_decision', { relay_key: 'k' });
h.driver.handleEvent('session_archived', { session_id: 's' });
await new Promise(r => setTimeout(r, 20));
assert.equal(h.calls.length, 0);
} finally {
await h.cleanup();
}
});
test('一封邮件处理崩了不会带走驱动(后面的信照常处理)', async () => {
const h = await harness({
turn: (opts, deps, n) => {
if (n === 1) throw new Error('boom');
return { sessionId: 's', response: '第二封处理好了', exitCode: 0 };
}
});
try {
h.driver.handleEvent('new_mail', humanMail());
h.driver.handleEvent('new_mail', humanMail({ mail_id: 'in-2' }));
await new Promise(r => setTimeout(r, 50));
assert.equal(h.client.sent.length, 1);
assert.match(h.client.sent[0].body, /第二封处理好了/);
} finally {
await h.cleanup();
}
});
test('★ 关停时终止在途回合(不留下跑工具的孤儿)', async () => {
// systemd 杀掉驱动后,那个 ZCode 进程还在跑工具,而既没有驱动看着它、
// 也没有本地界面看着它 —— 宁可丢掉这一轮的工作。
const signals = [];
let release;
const h = await harness({
turn: async (opts, deps) => {
// 真实现会把「怎么杀」通过 deps.onChild 交出来(见 zcode-run.mjs
deps.onChild(sig => signals.push(sig));
await new Promise(r => (release = r));
return { sessionId: 's', response: 'x', exitCode: 0 };
}
});
try {
const p = h.driver.processMail(humanMail());
await new Promise(r => setTimeout(r, 20));
assert.equal(typeof release, 'function', '回合应已开始');
h.driver.abort();
assert.deepEqual(signals, ['SIGTERM'], 'abort 必须终止在途回合');
// 幂等:重复关停不该再杀一次
h.driver.abort();
assert.deepEqual(signals, ['SIGTERM']);
release();
await p;
} finally {
await h.cleanup();
}
});
// ─── 主动发信记录的落盘 ─────────────────────────────────────────────
test('★ explicit-sends 文件位置两端一致(驱动与 MCP 服务器必须解析出同一路径)', async () => {
const env = { AGENTMAIL_CONFIG_DIR: '/tmp/agentmail-cfg' };
assert.equal(explicitSendsFile(env), '/tmp/agentmail-cfg/explicit-sends.jsonl');
assert.equal(explicitSendsFile({ ZCODE_PLUGIN_DATA: '/d' }), '/d/explicit-sends.jsonl');
assert.match(explicitSendsFile({}), /explicit-sends\.jsonl$/);
});
test('读回的记录形状可直接交给共用去重判据', async () => {
const dir = await mkdtemp(join(tmpdir(), 'zc-sends-'));
const f = join(dir, 'sends.jsonl');
noteExplicitSendFile(f, { sessionId: 's1', to: 'gui-lab@/p', replyTo: 'm1' });
const { readExplicitSends } = await import('../lib/explicit-sends.mjs');
const rec = readExplicitSends(f, { sessionId: 's1' });
assert.ok(rec.names.has('gui-lab'));
assert.ok(rec.replyTos.has('m1'));
await rm(dir, { recursive: true, force: true });
});
// ─── 档位强制力自报(必须如实,否则是在替不存在的能力背书)────────────
test('★ 自报 native 要有真凭据门禁链就绪yolo + 够长的禁用清单)', async () => {
const { detectModeEnforcement } = await import('../src/index.mjs');
const r = detectModeEnforcement({ env: {} });
assert.equal(r.enforcement, 'native');
// 理由里必须点出**谁**在把关。以前这里写的是「钩子已注册」,而 yolo 下
// 钩子根本不会触发 —— 那种理由会让人以为平台在管,实际平台什么都没管。
assert.match(r.reason, /门禁|请示/);
assert.doesNotMatch(r.reason, /^钩子已注册/, '不能拿钩子当唯一凭据');
});
test('★ 反向对照:门禁链断了就必须降级成 advisory', async () => {
const { detectModeEnforcement } = await import('../src/index.mjs');
// 禁用清单被清空 = 平台自带 Bash/Write/js 全都还回去了 —— 此时即使钩子
// 清单正常,也不再是「该档位被强制」。
const r = detectModeEnforcement({ env: { AGENTMAIL_ZCODE_DISALLOWED_TOOLS: '' } });
assert.equal(r.enforcement, 'advisory');
});
test('★ 只拿得到钩子、拿不到门禁时不能硬报 native', async () => {
const { detectModeEnforcement } = await import('../src/index.mjs');
const r = detectModeEnforcement({
hooksFile: '/nonexistent/hooks.json',
env: {}
});
// 门禁就绪 → 仍然 native我们拦得住但理由里不能声称有钩子
assert.equal(r.enforcement, 'native');
assert.doesNotMatch(r.reason, /钩子已注册/);
});