feat(zcode): 邮件驱动 —— 收到来信就自动开工,并把结论回信

第三步(补齐一等 Agent 的另一半):驱动进程订阅 SSE,按邮件起一轮 headless
ZCode,取最终文本回信。

## --mode 是必传的(不传等于关掉授权系统)

ZCode 的权限判定里 `mode === "yolo"` 一律 allow
("Yolo mode bypasses permission prompts"),而 `--prompt` 的默认 mode **就是 yolo**。
所以驱动不传 --mode 时:授权钩子根本不会触发,整个授权系统**静默消失** ——
不报错,只是没有任何询问,看起来一切正常。

档位映射(依据是 CLI 产物里的规则表,不是猜):
  plan → --mode plan    (mode.plan.nonReadOnly:非只读一律拒)
  workspace → --mode build(mode.build.highRisk / sideEffect:Bash/Write/Edit → ask)
  full → --mode yolo    (刻意绕过)
buildRunArgs 收不到 mode 直接抛错;测试里有一条反向对照钉住「只有 full 能得到 yolo」,
含大写 FULL(共用库 normalizeMode 严格匹配,落回 default 而不是 yolo —— 好性质,也钉住)。

## 一轮怎么跑

  node <zcode.cjs> --prompt <提示词> --output-format stream-json \
       --cwd <工作目录> --mode <m> [--resume sess_xxx] --max-turns N

用 stream-json 而不是 --json:`--json` 全程无输出,一个卡住的回合与一个正在
干活的回合在外部完全一样,而邮件驱动的会话没有界面,日志是唯一能看见它的地方。
输出契约(逐条事件 + 末尾 {type:"result",sessionId,response})同样逆自 CLI 产物。
会话延续靠 --resume + 存回的 sess_…:丢了它模型每封信都从零开始。

## 回信策略(与另三桥同源)

- 人来信 → 自动把本轮最终文本回过去(relay:'summary' + relay_key 走免配额通道)
- Agent 来信 → **不**自动回(Agent 间必须自己 send_mail,否则两边把对方的
  「已收到」当待办,无限客套)
- 一轮跑不起来 → **必回**失败信,且给出 ZCode 自己的成因(没登录/缺模型配置/
  CLI 路径不对)。没有本地界面时,什么都不发等于「信发出去了,然后再无音讯」。
  刻意不复用共用库那份 renderFailureReport:它的建议是「调整可用模型范围」,
  对 ZCode 什么也解决不了。
- 模型这一轮自己发过信 → 让位。工具跑在 ZCode 派生的 MCP 服务器**进程**里,
  与驱动内存不通,所以经 lib/explicit-sends.mjs 落盘对齐(不记的后果线上实测过:
  收件箱里两封说同一件事的邮件,311 与 342 字节)。

## 两处健壮性(都是实现时自己发现的真问题)

- 超时必须**必然** settle:既不退也不报错的孩子会让 Promise 永不 settle,
  而队列是串行的 → 那封信永远挂住、后面的信全都不再被处理。
  现在 SIGTERM → SIGKILL → 无论如何收尾;定时器刻意不 unref
  (unref 过的定时器让「没有其它句柄」的进程直接退出,收尾根本没机会跑)。
- 关停时终止在途回合:否则 systemd 杀掉驱动后那个 ZCode 还在跑工具,
  而既没有驱动看着它、也没有本地界面看着它。

## 自报强制力只声明得出来的事

驱动启动时读自己的 hooks/hooks.json,确认 PermissionRequest 已注册才报 native,
否则报 advisory 并在日志里写明原因 —— 不替一个不存在的能力背书。

## 验证

- 单元 320/320(新增 90 项:turn-mode 8、zcode-run 17、driver 19、prompt 14 +
  继承的共用测试;含反向对照)
- 邮件驱动端到端 7/7 × 3 次连跑稳定:桩 CLI 替掉 ZCode,真网关真邮件 ——
  SSE 订阅、去重、工作目录、档位映射、参数拼装(--mode 必须对)、
  stream-json 解析、回信、Agent 来信不回、CLI 失败必回失败信
- 授权桥端到端 5/5 × 3 次连跑稳定
- 共用模块四方同源(新纳入 catchup/relay-dedup/relay-policy/workspace,
  反向验证:让 workspace.js 分叉会被抓住)

## 我自己写错并被测试抓出来的三处(值得记)

1. 验证脚本把人类发信写成了 /api/v1/mail/send(**Agent** 路由)→ 401。
   报错「Missing Authorization: Bearer …」其实已经指明走错了路由表。
2. findReply 按「驱动验证(人)」这种片段找,第二次跑时命中了**上一轮遗留的回信**
   → 正文比对失败、后续参数核对变成「无法判定」。收件箱是跨轮次共享的持久状态,
   必须按唯一 marker 定位(与之前「待决权限列表」那次是同一类错误)。
3. 停旧驱动只发 SIGTERM 不等退出 → 新旧两个驱动同时订阅 SSE,
   同一封信被回两次,判据取到哪封取决于时序 → 时灵时不灵。改成等 exit 事件。
   另:桩脚本用 process.exit 截断管道写入,导致 stderr 时有时无 —— 改用 exitCode。
This commit is contained in:
2026-09-12 14:58:36 +08:00
parent 4b129b5f49
commit c5e1d562eb
23 changed files with 2912 additions and 52 deletions

View File

@ -0,0 +1,81 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { MAX_CATCHUP, mailToEvent, selectCatchup } from '../lib/catchup.js';
const mail = (over = {}) => ({
mail_id: 'm1',
session_id: 's1',
from_name: 'admin',
subject: '主题',
mail_type: 'normal',
to_workspace: '/tmp/ws',
...over,
});
test('mailToEvent 产出与 SSE new_mail 同形的对象', () => {
const ev = mailToEvent(mail());
// 投递侧读的就是这几个键,形状不一致会让补拉那条路径静默地少带信息
for (const k of ['mail_id', 'session_id', 'from_name', 'subject', 'mail_type', 'to_workspace']) {
assert.ok(k in ev, `缺少 ${k}`);
}
assert.equal(ev.role, 'to');
assert.equal(ev.catchup, true);
});
test('mailToEvent 对缺字段的行给出空串而非 undefined', () => {
const ev = mailToEvent({});
assert.equal(ev.mail_id, '');
assert.equal(ev.to_workspace, '');
assert.equal(ev.mail_type, 'normal');
});
test('已经通过 SSE 投过的不再补投', () => {
const mails = [mail({ mail_id: 'a' }), mail({ mail_id: 'b' })];
const got = selectCatchup(mails, new Set(['a']));
assert.deepEqual(got.map(e => e.mail_id), ['b']);
});
test('按时间正序补投(收件箱是倒序返回的)', () => {
// 收件箱:新的在前
const mails = [mail({ mail_id: 'new' }), mail({ mail_id: 'mid' }), mail({ mail_id: 'old' })];
const got = selectCatchup(mails, new Set());
assert.deepEqual(
got.map(e => e.mail_id),
['old', 'mid', 'new'],
'先来的邮件必须先处理,否则同一会话里的上下文顺序是乱的',
);
});
test('permission 类邮件不补投', () => {
const mails = [mail({ mail_id: 'p', mail_type: 'permission' }), mail({ mail_id: 'n' })];
const got = selectCatchup(mails, new Set());
assert.deepEqual(got.map(e => e.mail_id), ['n']);
});
test('超过上限的部分留在收件箱里', () => {
const mails = Array.from({ length: MAX_CATCHUP + 4 }, (_, i) => mail({ mail_id: 'm' + i }));
const got = selectCatchup(mails, new Set());
assert.equal(got.length, MAX_CATCHUP, '一次补拉不该把几十封邮件同时放出去');
});
test('上限可显式压到 0(用于禁用补拉)', () => {
const got = selectCatchup([mail()], new Set(), 0);
assert.deepEqual(got, []);
});
test('空输入与非数组不炸', () => {
assert.deepEqual(selectCatchup([], new Set()), []);
assert.deepEqual(selectCatchup(undefined, new Set()), []);
assert.deepEqual(selectCatchup(null, new Set()), []);
});
test('没有 mail_id 的行跳过', () => {
const got = selectCatchup([mail({ mail_id: '' }), mail({ mail_id: 'ok' })], new Set());
assert.deepEqual(got.map(e => e.mail_id), ['ok']);
});
test('seen 传 undefined 时不去重也不报错', () => {
const got = selectCatchup([mail({ mail_id: 'x' })], undefined);
assert.deepEqual(got.map(e => e.mail_id), ['x']);
});

View File

@ -0,0 +1,369 @@
/**
* 驱动的整条流水线测试(不需要模型、不需要 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', 'build'],
['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 });
});

View File

@ -0,0 +1,322 @@
#!/usr/bin/env node
/**
* 邮件驱动的端到端验证 —— 用**桩 CLI** 替掉 ZCode,不需要模型、不需要登录。
*
* # 它验的是什么
*
* 除了「ZCode 收到提示词后能不能干活」(那需要模型),链路上其余每一环都真的跑:
*
* SSE 订阅 → 收到 new_mail → 去重 → 解析工作目录与档位 → 拼参数起一轮
* → 解析 stream-json → 判定要不要自动回信 → 真的把回信投到网关
*
* 桩 CLI 与真 ZCode 的差别只在「谁来产出那段最终文本」,而参数拼装、
* 输出解析、回信策略、去重都是同一份代码。
*
* # 判据(每条都配反向对照)
*
* 1. 人来信 → 驱动回信,且回信内容来自 CLI 的 `response`
* 2. **反向**:Agent 来信 → 驱动不回信(Agent 间必须自己 send_mail)
* 3. **反向**:桩 CLI 报错 → 驱动必须回一封失败信(否则发件人白等)
* 4. 参数核对:桩 CLI 把它收到的 argv 与环境变量落盘,逐项断言
* ——`--mode` 尤其重要(漏了就是 yolo,授权会被绕过)
*
* 用法:node test/manual/driver-e2e.mjs [--keep]
*/
import { spawn } from 'node:child_process';
import { mkdtemp, rm, readFile, writeFile, mkdir } from 'node:fs/promises';
import { mkdirSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
const HERE = dirname(fileURLToPath(import.meta.url));
const DRIVER = join(HERE, '../../src/index.mjs');
const GATEWAY = process.env.GATEWAY || 'http://127.0.0.1:8180';
const HUMAN = { username: 'gui-lab', password: 'gui123456' };
const KEEP = process.argv.includes('--keep');
const results = [];
const record = (name, state, detail) => {
results.push({ name, state, detail });
const icon = state === '通过' ? '✓' : state === '失败' ? '✗' : '?';
console.log(` ${icon} ${name}${detail ? ` —— ${detail}` : ''}`);
};
const sleep = ms => new Promise(r => setTimeout(r, ms));
/** 桩 CLI:说 stream-json,并把收到的参数落盘供断言。 */
const STUB = `#!/usr/bin/env node
// 桩 ZCode CLI:只做两件事 —— 把收到的参数落盘,然后按契约吐事件。
import { writeFileSync, appendFileSync } from 'node:fs';
const argv = process.argv.slice(2);
const rec = {
argv,
session: process.env.AGENTMAIL_SESSION_ID || '',
tier: process.env.AGENTMAIL_PERMISSION_MODE || '',
subject: process.env.AGENTMAIL_MAIL_SUBJECT || ''
};
appendFileSync(process.env.STUB_LOG, JSON.stringify(rec) + '\\n');
if (process.env.STUB_MODE === 'fail') {
// 用 exitCode 而不是 process.exit():后者会截断还没刷进管道的 stderr,
// 于是驱动收到的 stderrTail 时有时无 —— 判据就跟着时灵时不灵(实测过)。
process.stderr.write('Model config is missing. Create /root/.zcode/cli/config.json\\n');
process.exitCode = 3;
} else {
const prompt = argv[argv.indexOf('--prompt') + 1] || '';
const marker = (prompt.match(/\\[MARKER:[^\\]]+\\]/) || ['(无标记)'])[0];
process.stdout.write(JSON.stringify({ type: 'tool.call.started', toolName: 'Read' }) + '\\n');
process.stdout.write(JSON.stringify({
type: 'result',
sessionId: 'sess_stub_1',
response: '桩回答:收到 ' + marker,
eventCount: 1
}) + '\\n');
}
`;
async function login() {
const res = await fetch(`${GATEWAY}/api/v1/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(HUMAN)
});
if (!res.ok) throw new Error(`登录失败 HTTP ${res.status}`);
return (res.headers.getSetCookie?.() ?? []).map(c => c.split(';')[0]).join('; ');
}
/** 以人类身份发一封给 zcode。
*
* 注意路径是 `/me/mail/send`(人类登录态)—— `/mail/send` 是 **Agent** 路由,
* 拿 cookie 去调会得到「Missing Authorization: Bearer …」,
* 而那条报错恰恰是在提示走错了路由表。
*/
async function humanSend(cookie, subject, body) {
const res = await fetch(`${GATEWAY}/api/v1/me/mail/send`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Cookie: cookie },
body: JSON.stringify({ to: 'zcode', subject, body })
});
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(`发信失败 HTTP ${res.status} ${JSON.stringify(data).slice(0, 160)}`);
return data;
}
/**
* 以人类身份读收件箱,找主题含**唯一标记**的回信。
*
* 必须用唯一标记(而不是「驱动验证(人)」这种片段)来定位:
* 收件箱是**跨轮次共享的持久状态**,上一轮的回信还在里面。
* 只按片段找,第二轮会命中第一轮那封,于是「正文里有没有本轮标记」必然失败,
* 而后面的参数核对也会因为驱动其实还没开始跑而变成「无法判定」。
* 实测踩过。
*/
async function findReply(cookie, subjectFragment, timeoutMs = 40000) {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
const res = await fetch(`${GATEWAY}/api/v1/me/mail/inbox?limit=40`, { headers: { Cookie: cookie } });
if (res.ok) {
const data = await res.json().catch(() => ({}));
const hit = (data.mails || []).find(
m => String(m.subject || '').includes(subjectFragment) && String(m.from_name || '') === 'zcode'
);
if (hit) return hit;
}
await sleep(700);
}
return null;
}
async function main() {
const work = await mkdtemp(join(tmpdir(), 'zc-drv-e2e-'));
const stubCli = join(work, 'stub-zcode.mjs');
const stubLog = join(work, 'stub-log.jsonl');
const cfgDir = join(work, 'cfg');
const wsRoot = join(work, 'ws');
await writeFile(stubCli, STUB, 'utf8');
await writeFile(stubLog, '', 'utf8');
mkdirSync(cfgDir, { recursive: true });
mkdirSync(wsRoot, { recursive: true });
// 凭据:优先用 /etc/agentmail/zcode.env,否则用注册时存的 secret
let env = { ...process.env };
try {
const e = await readFile('/etc/agentmail/zcode.env', 'utf8');
const key = e.match(/AGENTMAIL_AGENT_KEY=(.+)/)?.[1]?.trim();
if (key) env.AGENTMAIL_AGENT_KEY = key;
} catch {}
if (!env.AGENTMAIL_AGENT_KEY) {
try {
env.AGENTMAIL_AGENT_SECRET = (await readFile('/root/gotmp/zcode-agent-secret.txt', 'utf8')).trim();
} catch {
console.error('拿不到 zcode 凭据,无法验证');
process.exit(2);
}
}
env = {
...env,
AGENTMAIL_GATEWAY_URL: GATEWAY,
AGENTMAIL_AGENT_NAME: 'zcode',
AGENTMAIL_ZCODE_CLI: stubCli,
AGENTMAIL_CONFIG_DIR: cfgDir,
AGENTMAIL_WORKSPACE_ROOT: wsRoot,
AGENTMAIL_TURN_TIMEOUT_MS: '60000',
STUB_LOG: stubLog
};
const cookie = await login();
console.log('已登录人类账号,启动驱动…\n');
const driver = spawn(process.execPath, [DRIVER], { env, stdio: ['ignore', 'pipe', 'pipe'] });
const driverLog = [];
driver.stdout.on('data', d => driverLog.push(d.toString()));
driver.stderr.on('data', d => driverLog.push(d.toString()));
const stop = () => {
if (!driver.killed) driver.kill('SIGTERM');
};
/**
* 停掉一个驱动并**等它真的退出**。
*
* 只发 SIGTERM 不等退出会留下一个仍然订阅着 SSE 的旧进程:它还会处理
* 后面发的邮件,于是同一个 mail_id 会被两个驱动各回一次
* (一个用旧配置、一个用新配置),而判据取到哪一封取决于时序 —— 实测就是
* 这样时灵时不灵的。
*/
const stopAndWait = async d => {
if (!d || d.exitCode !== null) return;
const done = new Promise(r => d.once('exit', r));
d.kill('SIGTERM');
await Promise.race([done, sleep(4000)]);
};
try {
// 等驱动接上 SSE(日志里会说就绪)
let ready = false;
for (let i = 0; i < 40 && !ready; i++) {
await sleep(250);
ready = driverLog.join('').includes('驱动就绪');
if (driver.exitCode !== null) break;
}
if (!ready) {
record('驱动启动', '失败', driverLog.join('').slice(-400) || `退出码 ${driver.exitCode}`);
return;
}
record('驱动启动并接上 SSE', '通过', '自报强制力见日志');
// ── 1. 人来信 → 回信 ──────────────────────────────────────
const marker1 = `[MARKER:h-${Date.now()}]`;
const subj1 = `驱动验证(人)${marker1}`;
await humanSend(cookie, subj1, `请回复 ${marker1}`);
const reply1 = await findReply(cookie, marker1);
if (!reply1) {
record('人来信 → 驱动回信', '失败', '40 秒内没收到回信');
} else if (String(reply1.body || '').includes(marker1)) {
record('人来信 → 驱动回信', '通过', `回信正文含标记(主题 ${reply1.subject})`);
} else {
record('人来信 → 驱动回信', '失败', `回信正文没有标记:${String(reply1.body).slice(0, 60)}`);
}
// ── 2. 参数核对(桩 CLI 落盘的那一行)──────────────────────
const lines = (await readFile(stubLog, 'utf8')).split('\n').filter(Boolean).map(l => JSON.parse(l));
const first = lines[0];
if (!first) {
record('参数核对', '无法判定', '桩 CLI 没有记录到调用');
} else {
const modeIdx = first.argv.indexOf('--mode');
const mode = modeIdx >= 0 ? first.argv[modeIdx + 1] : '(未传)';
// 人的来信默认档位是 workspace → build;漏传就是 yolo,授权会被绕过
if (mode === 'build') {
record('★ --mode 传对了', '通过', `--mode ${mode}(workspace 档)`);
} else {
record('★ --mode 传对了', '失败', `实际 --mode ${mode}(漏传即 yolo,会绕过授权)`);
}
const outputFormat = first.argv[first.argv.indexOf('--output-format') + 1];
record('--output-format stream-json', outputFormat === 'stream-json' ? '通过' : '失败', outputFormat);
record(
'钩子要的环境变量已注入',
first.tier === 'workspace' && first.session ? '通过' : '失败',
`tier=${first.tier} session=${first.session ? '有' : '无'}`
);
}
// ── 3. 反向对照:Agent 来信不回信 ─────────────────────────
const marker3 = `[MARKER:a-${Date.now()}]`;
const agentSubj = `驱动验证(Agent)${marker3}`;
// 用 zcode 自己以外的 Agent 发(pi 的密钥从环境文件读)
let sentAsAgent = false;
try {
const piEnv = await readFile('/etc/agentmail/pi.env', 'utf8');
const piKey = piEnv.match(/AGENTMAIL_AGENT_KEY=(.+)/)?.[1]?.trim();
if (piKey) {
const res = await fetch(`${GATEWAY}/api/v1/mail/send`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-Agent-Name': 'pi', Authorization: `Bearer ${piKey}` },
body: JSON.stringify({ to: 'zcode', subject: agentSubj, body: `来自 Agent 的来信 ${marker3}` })
});
sentAsAgent = res.ok;
if (!res.ok) console.log(` (Agent 发信失败 HTTP ${res.status})`);
}
} catch {}
if (!sentAsAgent) {
record('反向对照 · Agent 来信不回信', '无法判定', '没发出 Agent 来信(缺 pi 凭据?)');
} else {
await sleep(6000);
const leaked = await findReply(cookie, marker3, 1500);
const stubLines = (await readFile(stubLog, 'utf8')).split('\n').filter(Boolean).length;
if (leaked) {
record('反向对照 · Agent 来信不回信', '失败', `驱动给 Agent 回信了:${String(leaked.body).slice(0, 50)}`);
} else if (stubLines < 2) {
// 更硬的判据:Agent 来信**根本没起一轮**(不是起了轮但没回)
record('反向对照 · Agent 来信不回信', '通过', '既没回信,也没为它起一轮');
} else {
record('反向对照 · Agent 来信不回信', '通过', `起了 ${stubLines} 轮但没有回信(交由模型自己 send_mail)`);
}
}
// ── 4. 反向对照:CLI 失败 → 必须回失败信 ───────────────────
await stopAndWait(driver);
await writeFile(stubLog, '', 'utf8');
const env2 = { ...env, STUB_MODE: 'fail' };
const driver2 = spawn(process.execPath, [DRIVER], { env: env2, stdio: ['ignore', 'pipe', 'pipe'] });
const log2 = [];
driver2.stdout.on('data', d => log2.push(d.toString()));
driver2.stderr.on('data', d => log2.push(d.toString()));
try {
for (let i = 0; i < 40; i++) {
await sleep(250);
if (log2.join('').includes('驱动就绪') || driver2.exitCode !== null) break;
}
const marker4 = `[MARKER:f-${Date.now()}]`;
await humanSend(cookie, `驱动验证(失败)${marker4}`, '这封会失败');
const failReply = await findReply(cookie, marker4, 40000);
if (failReply && String(failReply.body || '').includes('Model config is missing')) {
record('★ 反向对照 · CLI 失败必回失败信', '通过', `主题「${failReply.subject}」`);
} else if (failReply) {
record('★ 反向对照 · CLI 失败必回失败信', '通过', '回了失败信(正文未含原始报错)');
} else {
record('★ 反向对照 · CLI 失败必回失败信', '失败', '40 秒内没有失败回信 —— 发件人会白等');
}
} finally {
await stopAndWait(driver2);
}
} finally {
stop();
await sleep(500);
if (KEEP) console.log(`\n工作目录保留在 ${work}`);
else await rm(work, { recursive: true, force: true });
}
const pass = results.filter(r => r.state === '通过').length;
const fail = results.filter(r => r.state === '失败').length;
const unknown = results.filter(r => r.state === '无法判定').length;
console.log(`\n结果:${pass} 通过 / ${fail} 失败 / ${unknown} 无法判定`);
console.log('\n驱动日志尾部:');
console.log(` ${driverLog.join('').split('\n').slice(-8).join('\n ')}`);
process.exit(fail > 0 ? 1 : 0);
}
main().catch(e => {
console.error('验证脚本自身出错:', e);
process.exit(2);
});

View File

@ -20,6 +20,7 @@
*/
import { spawn } from 'node:child_process';
import { randomUUID } from 'node:crypto';
import { mkdtemp, rm } from 'node:fs/promises';
import { readFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
@ -194,7 +195,9 @@ async function main() {
AGENTMAIL_AGENT_NAME: 'zcode',
...(agent.key ? { AGENTMAIL_AGENT_KEY: agent.key } : { AGENTMAIL_AGENT_SECRET: agent.secret }),
AGENTMAIL_ZCODE_GRANTS_FILE: grantsFile,
AGENTMAIL_PERMISSION_WAIT_MS: '60000'
// 必须**明显小于**下面 runHook 的杀进程上限:两者相等时钩子会在
// 正要输出结论的瞬间被 SIGKILL,于是「不表态」与「来不及答」分不开。
AGENTMAIL_PERMISSION_WAIT_MS: '20000'
};
const seen = new Set();
@ -306,42 +309,28 @@ async function main() {
// ── 4. 反向对照:无人可问 → fail closed ─────────────────────
try {
// 造一条**只有 Agent、没有人类**的会话(zcode → pi),这才是真实的 409 场景:
// 服务端按 会话 owner → 线索里最近的人类 解析不出决策人。
// 传个不存在的 session id 会走 400(参数错),验不到这条路径。
const res = await fetch(`${GATEWAY}/api/v1/mail/send`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Agent-Name': agent.name,
...(agent.key ? { Authorization: `Bearer ${agent.key}` } : { 'X-Agent-Secret': agent.secret })
},
body: JSON.stringify({
to: 'pi',
subject: `无人可问控制组 ${Date.now()}`,
body: '仅用于验证:这条会话上没有人类。'
})
// 用「**合法 UUID 但不存在**的会话」。实测服务端稳定回 409:
// {"error":"权限询问无法送达:该任务链上没有人类用户", …}
//
// 为什么不真的造一条「只有 Agent、没有人类」的会话:那不可靠 ——
// 发信可能落进一条**已有会话**(实测 zcode→pi 落进了带 gui-lab 的会话),
// 于是服务端正常受理、钩子开始等人,而人不会来,最后得到的是超时 block。
// 那样这条控制组就验不到 409 那条路径,还容易被误读成通过。
const ghost = randomUUID();
const r = await runHook({
input: hookInput('Bash', `toolu-nohuman-${Date.now()}`),
env: { ...baseEnv, AGENTMAIL_SESSION_ID: ghost, AGENTMAIL_PERMISSION_MODE: 'workspace' },
timeoutMs: 60000
});
const noHumanSession = (await res.json().catch(() => ({})))?.session_id;
if (!noHumanSession) {
record('反向对照 · 无人可问 → 拒绝', '无法判定', '未能造出无人类的会话');
if (r.parsed?.decision === 'block' && /没有人类|无人可问|无法送达/.test(r.parsed.reason || '')) {
record('反向对照 · 无人可问 → 拒绝', '通过', (r.parsed.reason || '').split('\n')[0].slice(0, 60));
} else if (r.parsed?.decision === 'block') {
// 时间到也是 block,但那不是这条控制组要验的东西 —— 明确报「无法判定」
record('反向对照 · 无人可问 → 拒绝', '无法判定', `block 但不是 409 造成的:${(r.parsed.reason || '').slice(0, 50)}`);
} else if (r.parsed === null) {
record('反向对照 · 无人可问 → 拒绝', '失败', '不表态等于放行(邮件驱动下不允许)');
} else {
const r = await runHook({
input: hookInput('Bash', `toolu-nohuman-${Date.now()}`),
env: {
...baseEnv,
AGENTMAIL_SESSION_ID: noHumanSession,
AGENTMAIL_PERMISSION_MODE: 'workspace'
},
timeoutMs: 60000
});
if (r.parsed?.decision === 'block') {
record('反向对照 · 无人可问 → 拒绝', '通过', (r.parsed.reason || '').split('\n')[0].slice(0, 70));
} else if (r.parsed === null) {
record('反向对照 · 无人可问 → 拒绝', '失败', '不表态等于放行(邮件驱动下不允许)');
} else {
record('反向对照 · 无人可问 → 拒绝', '失败', `钩子输出了 ${r.stdout}`);
}
record('反向对照 · 无人可问 → 拒绝', '失败', `钩子输出了 ${r.stdout}`);
}
} catch (e) {
record('反向对照 · 无人可问 → 拒绝', '失败', e.message);

View File

@ -0,0 +1,111 @@
/**
* 提示词与回信文案的测试。
*
* 这层没有 I/O,但它决定模型看到什么 —— 而模型看到的东西错了,表现是
* 「这个 Agent 就是不回信」或「两个 Agent 无限客套」,都不会报错。
*/
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { buildMailPrompt, replySubject, renderTurnFailure } from '../src/prompt.mjs';
const mail = (over = {}) => ({
mail_id: 'm1',
from_name: 'gui-lab',
from_human: true,
subject: '帮我看看',
reply_address: 'gui-lab@/w.alias',
...over
});
test('新任务:说明这是新邮件,并给出发件人/主题/邮件 id', () => {
const p = buildMailPrompt({ agentName: 'zcode', data: mail() });
assert.match(p, /gui-lab/);
assert.match(p, /帮我看看/);
assert.match(p, /m1/);
assert.match(p, /身份:你是 zcode/);
});
test('★ 回信不是新任务:带 in_reply_to 时要说清是回哪封', () => {
// 模型分不清「新任务」与「回信」时,会把对方一句「已收到」再当待办做一遍。
const p = buildMailPrompt({ agentName: 'zcode', data: mail({ in_reply_to: 'm0' }) });
assert.match(p, /回的是你那封:m0/);
});
test('★ 人来信 vs Agent 来信:回信责任必须不同', () => {
const fromHuman = buildMailPrompt({ agentName: 'z', data: mail({ from_human: true }) });
const fromAgent = buildMailPrompt({ agentName: 'z', data: mail({ from_human: false }) });
assert.match(fromHuman, /回信不用你自己发/);
assert.match(fromAgent, /不会替你回信/);
// 反向对照:两者不能出现对方的措辞
assert.doesNotMatch(fromHuman, /不会替你回信/);
assert.doesNotMatch(fromAgent, /回信不用你自己发/);
});
test('★ from_human 缺失时按「不是人」处理(宁多一次 send_mail,不许诺空头回信)', () => {
const p = buildMailPrompt({ agentName: 'z', data: mail({ from_human: undefined }) });
assert.match(p, /不会替你回信/);
});
test('补投的邮件标注 catchup(模型该知道这不是刚发生的)', () => {
const p = buildMailPrompt({ agentName: 'z', data: mail({ catchup: true }) });
assert.ok(p.length > 0);
// 复用会话时不重复交代身份(省 token,且身份没变过)
const reused = buildMailPrompt({ agentName: 'z', data: mail(), reused: true });
assert.doesNotMatch(reused, /身份:你是/);
});
test('权限结论走单独路径,不写成「新邮件」', () => {
const p = buildMailPrompt({
agentName: 'z',
kind: 'permission',
data: { decision: '同意', decided_by: 'gui-lab' }
});
assert.match(p, /同意/);
assert.match(p, /gui-lab/);
assert.doesNotMatch(p, /read_inbox/);
});
// ─── 回信主题 ─────────────────────────────────────────────────────
test('Re: 前缀不会越滚越长', () => {
assert.equal(replySubject('帮我看看'), 'Re: 帮我看看');
assert.equal(replySubject('Re: 帮我看看'), 'Re: 帮我看看');
assert.equal(replySubject('RE:帮我看看'), 'Re: 帮我看看');
assert.equal(replySubject('回复: 帮我看看'), 'Re: 帮我看看');
});
test('空主题回落到「本轮工作总结」', () => {
for (const s of ['', ' ', undefined, null]) {
assert.equal(replySubject(s), '本轮工作总结');
}
});
// ─── 失败回信 ─────────────────────────────────────────────────────
test('★ 失败回信给出 ZCode 自己的成因,而不是别处的建议', () => {
// 共用库那份 renderFailureReport 的建议是「调整可用模型范围」——
// 对 ZCode 而言那条建议什么也解决不了(它的常见成因是没登录)。
const body = renderTurnFailure([{ kind: 'CLI 失败', error: 'Model config is missing.' }], '帮我看看');
assert.match(body, /Model config is missing/);
assert.match(body, /没有登录/);
assert.match(body, /~\/\.zcode\/cli\/config\.json/);
assert.match(body, /AGENTMAIL_ZCODE_CLI/);
assert.doesNotMatch(body, /调整可用模型范围/);
});
test('失败回信列出每一次尝试', () => {
const body = renderTurnFailure(
[
{ kind: '超时', error: '回合超时' },
{ kind: 'CLI 失败', error: '退出码 7' }
],
's'
);
assert.match(body, /已尝试 2 次/);
assert.match(body, /超时/);
assert.match(body, /退出码 7/);
});
test('失败回信在没有任何尝试记录时也不崩', () => {
const body = renderTurnFailure(undefined, undefined);
assert.match(body, /已尝试 0 次/);
});

View File

@ -0,0 +1,131 @@
/**
* 自动转发适用范围的判定(lib/relay-policy.js)。
*
* 三个函数是同一件事的三个出口,必须一起看:
* - autoRelayDecision 插件该不该替模型把结论发出去
* - replyInstruction 提示词里怎么跟模型说这件事
* - inboundHeadline 进来的这封是新活、是回复、还是补投
*
* 分开写必然分叉,而分叉的代价是模型被骗:以为插件会替它回信,于是把话说完
* 就停手,那封信却永远不会发出去。所以这里逐条钉住它们的一致性。
*/
import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
addrName,
autoRelayDecision,
replyInstruction,
inboundHeadline,
} from '../lib/relay-policy.js';
// ─── addrName ───
test('addrName 取三维地址的名字段', () => {
assert.equal(addrName('pi@/home/program/agentmail.某别名'), 'pi');
assert.equal(addrName('jianf'), 'jianf');
assert.equal(addrName(' dsh@/x '), 'dsh');
assert.equal(addrName(''), '');
assert.equal(addrName(undefined), '');
});
// ─── autoRelayDecision ───
test('人类来信 → 自动转发', () => {
const d = autoRelayDecision({ fromHuman: true, replyTo: 'jianf' });
assert.equal(d.relay, true);
});
test('Agent 来信 → 不自动转发', () => {
const d = autoRelayDecision({ fromHuman: false, replyTo: 'dsh' });
assert.equal(d.relay, false,
'Agent 间通信必须由模型主动 send_mail —— 两边都自动回会无休止互相唤醒');
assert.match(d.reason, /dsh/, '日志要说清是谁');
assert.match(d.reason, /Agent/);
});
test('不知道回给谁 → 不转发,且理由与「对方是 Agent」区分得开', () => {
const d = autoRelayDecision({ fromHuman: true, replyTo: '' });
assert.equal(d.relay, false);
assert.match(d.reason, /不知道回给谁/,
'「本轮没有回信」有三种原因,日志里必须能分辨');
});
test('replyTo 带三维地址时也能认出 Agent 名', () => {
const d = autoRelayDecision({ fromHuman: false, replyTo: 'opencode@/home/x.别名' });
assert.equal(d.relay, false);
assert.match(d.reason, /opencode/);
});
test('缺省参数不抛错(畸形事件不该弄死投递)', () => {
assert.equal(autoRelayDecision().relay, false);
assert.equal(autoRelayDecision({}).relay, false);
});
// ─── replyInstruction 与 autoRelayDecision 的一致性 ───
test('人类来信的提示词承诺「插件会替你发」,且这与决策一致', () => {
const lines = replyInstruction({ fromHuman: true });
const text = lines.join('\n');
assert.match(text, /回信不用你自己发/);
assert.equal(autoRelayDecision({ fromHuman: true, replyTo: 'jianf' }).relay, true,
'承诺了就必须真的做');
});
test('Agent 来信的提示词必须明说「插件不会替你回信」', () => {
const text = replyInstruction({ fromHuman: false }).join('\n');
assert.match(text, /不会替你回信/);
assert.match(text, /send_mail/, '必须给出唯一可行的做法');
assert.doesNotMatch(text, /回信不用你自己发/,
'这句话在 Agent → Agent 时是假的 —— 说了它模型就会把话说完然后停手');
});
test('Agent 来信的提示词要劝阻纯客套', () => {
const text = replyInstruction({ fromHuman: false }).join('\n');
assert.match(text, /收到|确认/, '要点名那种没有信息量的回复');
assert.match(text, /互相客套|无休止/, '要说清后果,否则模型不知道为什么被劝阻');
});
test('Agent 来信时把回信地址带进提示词(有就带)', () => {
const withAddr = replyInstruction({ fromHuman: false, replyAddress: 'dsh@/x.别名' }).join('\n');
assert.match(withAddr, /dsh@\/x\.别名/,
'要它自己发信却不给地址,它会拼一个 .new 出来 —— 那会静默开新会话');
const without = replyInstruction({ fromHuman: false }).join('\n');
assert.doesNotMatch(without, /(回信地址:)/, '没有地址时不该留一个空括号');
});
// ─── inboundHeadline ───
test('回复到了 → 明说「这不是新任务」', () => {
const h = inboundHeadline({ inReplyTo: 'm-1', fromHuman: false });
assert.match(h, /回复/);
assert.match(h, /不是新任务/,
'把回复当新任务处理正是互相客套的起点');
});
test('回复的标题优先于续谈/补投标记', () => {
const h = inboundHeadline({ inReplyTo: 'm-1', fromHuman: true, reused: true, catchup: true });
assert.match(h, /回复/, 'in_reply_to 是最强信号');
});
test('Agent 来信在标题里就标出来', () => {
assert.match(inboundHeadline({ fromHuman: false }), /Agent/);
assert.doesNotMatch(inboundHeadline({ fromHuman: true }), /Agent/,
'人类来信不该带这个括号 —— 那是噪音');
});
test('补投要说明,否则模型按「刚到的」语气回', () => {
const h = inboundHeadline({ fromHuman: true, catchup: true });
assert.match(h, /积压|补投/);
});
test('续谈与新会话的措辞不同', () => {
assert.match(inboundHeadline({ fromHuman: true, reused: true }), /本会话/);
assert.match(inboundHeadline({ fromHuman: true, reused: false }), /你收到/);
});
test('缺省参数不抛错', () => {
assert.equal(typeof inboundHeadline(), 'string');
assert.equal(typeof inboundHeadline({}), 'string');
});

View File

@ -0,0 +1,68 @@
/**
* 档位 → ZCode `--mode` 映射的测试。
*
* 这个映射是**授权系统存不存在**的开关:ZCode 的判定里 yolo 一律 allow,
* 而 `--prompt` 的默认 mode 就是 yolo。映射写错不会报错,只会让全部授权询问
* 静默消失 —— 所以它是本项目里少数几个「错一个值等于功能整体失效」的地方。
*/
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { zcodeModeForTier, modeReachesPermissionHook, describeTier, ZCODE_MODES } from '../src/turn-mode.mjs';
test('三个档位映射到三个不同的 mode', () => {
assert.equal(zcodeModeForTier('plan'), 'plan');
assert.equal(zcodeModeForTier('workspace'), 'build');
assert.equal(zcodeModeForTier('full'), 'yolo');
});
test('★ 只有 full 档会得到 yolo', () => {
// 反向对照:如果任何其它档位(含拼错的、空的、未知的、大小写不对的)
// 也能得到 yolo,那就意味着一个打字错误会关掉整个授权系统。
for (const tier of ['plan', 'workspace', '', undefined, 'worjspace', 'default', 'FULL', 'Full']) {
assert.notEqual(
zcodeModeForTier(tier),
'yolo',
`档位 ${JSON.stringify(tier)} 不该得到 yolo(实际 ${zcodeModeForTier(tier)})`
);
}
});
test('★ 大写 FULL 不认,落在安全侧', () => {
// 共用库的 normalizeMode 是严格匹配的(只认小写),实测 FULL → workspace。
// 这是**刻意保留**的好性质:认不出来时不会掉进「免授权」那一档,
// 而是退回 default。这条断言把它钉住 —— 哪天有人「顺手」改成大小写不敏感,
// 就会有一个打字错误变成全权授权的风险面。
assert.equal(zcodeModeForTier('FULL'), 'build');
assert.equal(zcodeModeForTier('PLAN'), 'build');
});
test('未知档位退回 build(安全侧),不是 yolo', () => {
assert.equal(zcodeModeForTier('nonsense'), 'build');
assert.equal(zcodeModeForTier(undefined), 'build');
});
test('产出的 mode 必须是 ZCode 认识的值', () => {
for (const tier of ['plan', 'workspace', 'full', 'x', undefined]) {
assert.ok(ZCODE_MODES.includes(zcodeModeForTier(tier)), tier);
}
});
test('只有 build / edit 会让危险操作走到授权钩子', () => {
assert.equal(modeReachesPermissionHook('build'), true);
assert.equal(modeReachesPermissionHook('edit'), true);
// plan 由 ZCode 自己就拒了;yolo 直接放行 —— 两者都不产生询问
assert.equal(modeReachesPermissionHook('plan'), false);
assert.equal(modeReachesPermissionHook('yolo'), false);
});
test('★ 反向对照:plan 与 full 都不产生询问,但原因不同', () => {
// 两条路都不产生 PermissionRequest,却在日志里必须能区分:
// 一个是「只读,ZCode 拒了」,一个是「全权,刻意不问」。
const plan = describeTier('plan');
const full = describeTier('full');
assert.notEqual(plan, full);
assert.match(plan, /只读/);
assert.match(full, /全权/);
assert.match(describeTier('workspace'), /授权钩子/);
});

View File

@ -0,0 +1,128 @@
/**
* 工作目录解析的回归测试。
*
* 这是「dsh 指定工作目录完全失效,所有对话都落在未分组下」那次故障的直接回归:
* 插件曾无视寻址里的 path 位,每封邮件自己拼一个 ~/.dsh/mail-sessions/mail-<uuid>,
* 而 DSH 按 cwd 分组,于是所有邮件会话既不属于任何项目、彼此也不同组。
*
* node --test test/
*/
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir, homedir } from 'node:os';
import { join } from 'node:path';
import { resolveWorkspaceCwd, ensureCwd, mailSessionFallback } from '../lib/workspace.js';
// 兜底目录现在由调用方给(各平台不同)。DSH 用 mailSessionFallback,
// opencode 用插件启动时的 directory。
const fallbackOf = key => mailSessionFallback(key);
test('存在的绝对路径直接用作 cwd', () => {
const dir = mkdtempSync(join(tmpdir(), 'ws-test-'));
try {
const got = resolveWorkspaceCwd(dir, fallbackOf('mail-1'));
assert.equal(got.cwd, dir);
assert.equal(got.grouped, true);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
test('不变量:同一 path 的多封邮件得到同一个 cwd(这才能同组)', () => {
const dir = mkdtempSync(join(tmpdir(), 'ws-test-'));
try {
const a = resolveWorkspaceCwd(dir, fallbackOf('mail-aaa'));
const b = resolveWorkspaceCwd(dir, fallbackOf('mail-bbb'));
assert.equal(a.cwd, b.cwd, 'fallbackKey 不同却应得到同一个 cwd');
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
test('path 为空时回退到兜底目录', () => {
const got = resolveWorkspaceCwd('', fallbackOf('mail-2'));
assert.equal(got.cwd, fallbackOf('mail-2'));
assert.equal(got.grouped, false);
});
test('path 缺失/非字符串时回退', () => {
for (const v of [undefined, null, 42, {}]) {
const got = resolveWorkspaceCwd(v, fallbackOf('mail-3'));
assert.equal(got.grouped, false);
assert.equal(got.cwd, fallbackOf('mail-3'));
}
});
test('不变量:不存在的目录不创建,回退到兜底', () => {
// 一个笔误(/home/porgram/x)不该在磁盘上落下真目录 ——
// Agent 会在里面一无所获地干活,比明确回退更难排查。
const got = resolveWorkspaceCwd('/nonexistent/path/xyz-should-not-exist', fallbackOf('mail-4'));
assert.equal(got.grouped, false);
assert.equal(got.cwd, fallbackOf('mail-4'));
});
test('不变量:相对路径被拒绝', () => {
// cwd 的相对基准是 harness 进程的启动目录,systemd 下通常是 /,
// 那是个与邮件语义完全无关的量。
for (const rel of ['relative/path', './x', '../y', 'src']) {
const got = resolveWorkspaceCwd(rel, fallbackOf('mail-5'));
assert.equal(got.grouped, false, `${rel} 不该被当作工作目录`);
}
});
test('指向文件而非目录时回退', () => {
const dir = mkdtempSync(join(tmpdir(), 'ws-test-'));
const file = join(dir, 'a-file');
writeFileSync(file, 'x');
try {
const got = resolveWorkspaceCwd(file, fallbackOf('mail-6'));
assert.equal(got.grouped, false);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
test('两端空白被修掉', () => {
const dir = mkdtempSync(join(tmpdir(), 'ws-test-'));
try {
const got = resolveWorkspaceCwd(` ${dir} `, fallbackOf('mail-7'));
assert.equal(got.cwd, dir);
assert.equal(got.grouped, true);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
test('ensureCwd 只建兜底目录,不碰寻址指定的目录', () => {
const base = mkdtempSync(join(tmpdir(), 'ws-ensure-'));
try {
const target = join(base, 'made-by-ensure');
ensureCwd(target, false);
// 建出来了
const got = resolveWorkspaceCwd(target, '');
assert.equal(got.grouped, true, 'ensureCwd 应已创建该目录');
// grouped=true 时不该创建(那种目录本来就存在)
const never = join(base, 'should-not-exist');
ensureCwd(never, true);
assert.equal(resolveWorkspaceCwd(never, '').grouped, false);
} finally {
rmSync(base, { recursive: true, force: true });
}
});
test('兜底为空串时返回空 cwd(交给平台自己决定)', () => {
// opencode 没配 directory 时就是这种情况:session.create 不带 query.directory,
// 由平台按自己的默认规则选目录。比硬塞一个我们猜的路径好。
const got = resolveWorkspaceCwd('', '');
assert.equal(got.cwd, '');
assert.equal(got.grouped, false);
});
test('mailSessionFallback 同一 key 稳定、不同 key 不同', () => {
assert.equal(mailSessionFallback('a'), mailSessionFallback('a'));
assert.notEqual(mailSessionFallback('a'), mailSessionFallback('b'));
assert.match(mailSessionFallback('a'), /mail-sessions/);
});

View File

@ -0,0 +1,207 @@
/**
* 跑一轮的测试:参数拼装 + stream-json 解析 + 假进程的整轮行为。
*
* 不需要真的 ZCode、也不需要模型 —— 用可注入的 spawn 造一个说同样协议的假进程。
* 这样「--mode 忘了传」「结果行没解析对」「超时不杀进程树」这些问题
* 都能在本地断言,而不是等到线上某封信没人回。
*/
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { EventEmitter } from 'node:events';
import { buildRunArgs, parseStreamLine, runTurn } from '../src/zcode-run.mjs';
/** 造一个假子进程:按脚本吐 stdout/stderr,然后以指定退出码关闭。 */
function fakeSpawn({ stdout = '', stderr = '', exitCode = 0, onStart, neverExit = false } = {}) {
const calls = [];
const fn = (file, args, opts) => {
calls.push({ file, args, opts });
const child = new EventEmitter();
child.stdout = new EventEmitter();
child.stderr = new EventEmitter();
child.pid = 4242;
child.kill = () => true;
onStart?.(child, { file, args, opts });
setImmediate(() => {
if (stdout) child.stdout.emit('data', Buffer.from(stdout));
if (stderr) child.stderr.emit('data', Buffer.from(stderr));
if (!neverExit) {
child.exitCode = exitCode;
child.emit('close', exitCode);
}
});
return child;
};
fn.calls = calls;
return fn;
}
// ─── 参数拼装 ─────────────────────────────────────────────────────
test('★ 参数里必须带 --mode(不传等于默认 yolo,会绕过授权)', () => {
const args = buildRunArgs({ prompt: 'p', cwd: '/tmp', mode: 'build' });
assert.ok(args.includes('--mode'));
assert.equal(args[args.indexOf('--mode') + 1], 'build');
});
test('★ 漏传 mode 直接抛错,而不是悄悄退回默认', () => {
// 这是刻意的:静默退回意味着「授权系统消失但没人发现」。
assert.throws(() => buildRunArgs({ prompt: 'p', cwd: '/tmp' }), /mode/);
});
test('默认用 stream-json,便于把过程写进日志', () => {
const args = buildRunArgs({ prompt: 'p', cwd: '/tmp', mode: 'build' });
assert.equal(args[args.indexOf('--output-format') + 1], 'stream-json');
});
test('resume 只在有时才带(首轮不该带空 --resume)', () => {
const first = buildRunArgs({ prompt: 'p', cwd: '/tmp', mode: 'build' });
assert.equal(first.includes('--resume'), false);
const next = buildRunArgs({ prompt: 'p', cwd: '/tmp', mode: 'build', resumeSessionId: 'sess_1' });
assert.equal(next[next.indexOf('--resume') + 1], 'sess_1');
});
test('maxTurns 与工具黑白名单按需传递', () => {
const args = buildRunArgs({
prompt: 'p',
cwd: '/tmp',
mode: 'plan',
maxTurns: 6,
allowedTools: ['Read', 'Grep'],
disallowedTools: ['Bash']
});
assert.equal(args[args.indexOf('--max-turns') + 1], '6');
assert.equal(args[args.indexOf('--allowed-tools') + 1], 'Read,Grep');
assert.equal(args[args.indexOf('--disallowed-tools') + 1], 'Bash');
});
// ─── 解析 ─────────────────────────────────────────────────────────
test('result 行取出 sessionId 与 response', () => {
const r = parseStreamLine(
JSON.stringify({ type: 'result', sessionId: 'sess_abc', response: '做完了', eventCount: 3 })
);
assert.deepEqual(r, { kind: 'result', sessionId: 'sess_abc', response: '做完了', eventCount: 3, projection: undefined });
});
test('普通事件行归为 event', () => {
const e = parseStreamLine(JSON.stringify({ type: 'tool.call.started', toolName: 'Bash' }));
assert.equal(e.kind, 'event');
assert.equal(e.event.toolName, 'Bash');
});
test('非 JSON / 空行返回 null(不抛)', () => {
for (const line of ['', ' ', 'not json', '[1,2]', 'null', '42']) {
assert.equal(parseStreamLine(line), null, JSON.stringify(line));
}
});
test('★ 输出格式变了会被计数,而不是静默当成没输出', async () => {
// 若 CLI 换掉了输出格式,所有的行都会变成不可解析 —— 那时必须能看见
// 「解析不了的行有 N 条」,否则现象是「回合跑完了但什么都没回」。
const spawn = fakeSpawn({ stdout: 'human readable output\nmore text\n' });
const r = await runTurn({ prompt: 'p', cwd: '/tmp', mode: 'build' }, { spawn });
assert.equal(r.unparsable, 2);
assert.equal(r.response, '');
});
// ─── 整轮行为 ─────────────────────────────────────────────────────
test('整轮:解析事件、取出最终回复与会话 id', async () => {
const spawn = fakeSpawn({
stdout:
JSON.stringify({ type: 'tool.call.started', toolName: 'Read' }) +
'\n' +
JSON.stringify({ type: 'result', sessionId: 'sess_9', response: '结论:可以' }) +
'\n'
});
const r = await runTurn({ prompt: 'p', cwd: '/tmp', mode: 'build' }, { spawn });
assert.equal(r.sessionId, 'sess_9');
assert.equal(r.response, '结论:可以');
assert.equal(r.events.length, 1);
assert.equal(r.exitCode, 0);
});
test('最后一行没有换行也能收到', async () => {
const spawn = fakeSpawn({ stdout: JSON.stringify({ type: 'result', sessionId: 's', response: 'ok' }) });
const r = await runTurn({ prompt: 'p', cwd: '/tmp', mode: 'build' }, { spawn });
assert.equal(r.response, 'ok');
});
test('分块到达(一条 JSON 被切成两半)也能拼回来', async () => {
const payload = JSON.stringify({ type: 'result', sessionId: 'sess_split', response: '完整' });
const half = Math.floor(payload.length / 2);
const spawn = fakeSpawn({
onStart: child => {
setImmediate(() => {
child.stdout.emit('data', Buffer.from(payload.slice(0, half)));
child.stdout.emit('data', Buffer.from(`${payload.slice(half)}\n`));
child.emit('close', 0);
});
},
neverExit: true
});
const r = await runTurn({ prompt: 'p', cwd: '/tmp', mode: 'build' }, { spawn });
assert.equal(r.response, '完整');
});
test('非零退出码原样带出(不吞成成功)', async () => {
const spawn = fakeSpawn({ stdout: '', stderr: 'boom\n', exitCode: 7 });
const r = await runTurn({ prompt: 'p', cwd: '/tmp', mode: 'build' }, { spawn });
assert.equal(r.exitCode, 7);
assert.match(r.stderrTail, /boom/);
});
test('spawn 本身失败时给可读结果,而不是抛出去', async () => {
const spawn = () => {
throw new Error('ENOENT');
};
const r = await runTurn({ prompt: 'p', cwd: '/tmp', mode: 'build' }, { spawn });
assert.equal(r.exitCode, -1);
assert.match(r.stderrTail, /ENOENT/);
});
test('★ 超时会标记 timedOut 并杀进程树', async () => {
const signals = [];
const spawn = fakeSpawn({
neverExit: true,
onStart: child => {
child.kill = sig => {
signals.push(sig);
};
}
});
// 孩子永不退出:这是最坏情况 —— 既不退也不报错。
const r = await runTurn(
{ prompt: 'p', cwd: '/tmp', mode: 'build', turnTimeoutMs: 60, killGraceMs: 60, settleGraceMs: 60 },
{ spawn }
);
assert.equal(r.timedOut, true, '必须报告超时');
assert.equal(r.exitCode, -1);
// ★ 更关键的是**它一定会结束**:不结束的话驱动会对这封信永远挂住,
// 而队列是串行的,后面的信全都不再被处理。
// 升级阶梯:先礼后兵。只断言「杀过」会漏掉「SIGTERM 之后没升级」——
// 那种情况下要等满宽限期才能收尾,而工具子进程可能已经跑完了坏事。
assert.deepEqual(signals, ['SIGTERM', 'SIGKILL']);
});
test('进程组隔离:非 win32 平台用 detached 起', async () => {
const spawn = fakeSpawn({ stdout: '' });
await runTurn({ prompt: 'p', cwd: '/tmp', mode: 'build' }, { spawn });
const opts = spawn.calls[0].opts;
if (process.platform !== 'win32') assert.equal(opts.detached, true);
assert.equal(opts.cwd, '/tmp');
});
test('注入的环境变量会传给子进程(钩子靠它判断档位与有无本地界面)', async () => {
const spawn = fakeSpawn({ stdout: '' });
await runTurn(
{
prompt: 'p',
cwd: '/tmp',
mode: 'build',
env: { AGENTMAIL_SESSION_ID: 'sess-x', AGENTMAIL_PERMISSION_MODE: 'workspace' }
},
{ spawn }
);
const env = spawn.calls[0].opts.env;
assert.equal(env.AGENTMAIL_SESSION_ID, 'sess-x');
assert.equal(env.AGENTMAIL_PERMISSION_MODE, 'workspace');
});