Files
MailUI4Agents/plugins/zcode-mail-bridge/test/driver.test.mjs
JianFeeeee 3a6d572020 feat(zcode): 工具加 MCP 注解 + headless 档位映射改为 plan(否则一个工具都用不了)
## 逆出 ZCode 的 MCP 权限判定,并据此让工具真的可用

逐字逆自 CLI 产物:

  Ari():  annotations.readOnlyHint === true → riskLevel "low"
          annotations.destructiveHint === true → riskLevel "high"
          needsApproval = true   ← **硬编码为真,与注解无关**
  checkBuildMode(): needsApproval || destructive || sideEffectScope !== "none" → ask
  checkPlanMode():  permissionName === "mcp" && !destructive → allow

两条合起来的结论不直观但很关键:

- **build 档下每一个 MCP 工具都要审批**(needsApproval 恒真),而 headless
  模式没有交互式审批客户端 ⇒ 全被拒。实测:模型连 read_inbox 都调不动,
  只能从提示词里猜;更糟的是它**绕道**用 Bash 去读网关的 sqlite WAL 文件
  (它自己在回信里如实交代了这件事)。
- **plan 档下只要不声明 destructive,MCP 工具直接放行**。

于是两处改动:

1. `lib/tools.mjs` 给每个工具加真实注解(读类 readOnlyHint,写类
   destructiveHint:false——它们确实不破坏任何东西);`lib/mcp-rpc.mjs` 透传
   annotations。**漏传不是"少个提示",而是工具在该档下全被拒**。
2. `src/turn-mode.mjs` 的 workspace 档映射从 build 改为 **plan**。
   build 在本环境等于「什么都不能做」,那不是保守而是不可用;plan 才是真的
   fail-closed:危险的自带工具被平台直接拒,能用的只有我们声明为非破坏性的工具。
   日志会明确写出为什么退档。可用 `AGENTMAIL_ZCODE_MODE_MAP` 覆盖
   (平台修好钩子后只改配置就能恢复 build,不必等发版)。

## 真模型验证

场景 A 的判据同时加强:**正文本标记只出现在邮件正文里**(驱动的提示词只带主题
与 mail_id),所以模型必须真的读信才可能答对。通过 —— 约 20-30 秒一轮。

反过来说,早先那版「通过」是假的:标记在主题里,模型从提示词抄一遍就行。

## 仍然做不到的(见 README 已知缺口)

授权桥(PermissionRequest 钩子)在本版本(3.10.2 / CLI 0.16.5)**不可用**:
有时根本不触发,触发时在 ~5ms 内失败且**命令从未被 spawn**
(用「钩子写 marker 文件」的副作用验证,process 与 command 两种类型都一样)。
所以 workspace 档「危险操作问人」目前在 headless 下无法实现。

单元 329/329。
2026-09-12 16:49:28 +08:00

371 lines
14 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 默认映射到 planbuild 在 headless 下连 MCP 工具都要审批而无人可批
['workspace', 'plan'],
['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');
} 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 });
});