第三步(补齐一等 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。
323 lines
14 KiB
JavaScript
323 lines
14 KiB
JavaScript
#!/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);
|
||
});
|