## 逆出 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。
371 lines
14 KiB
JavaScript
371 lines
14 KiB
JavaScript
/**
|
||
* 驱动的整条流水线测试(不需要模型、不需要 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 默认映射到 plan:build 在 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 });
|
||
});
|