#!/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); });