第三步(补齐一等 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。
369 lines
16 KiB
JavaScript
369 lines
16 KiB
JavaScript
#!/usr/bin/env node
|
||
/**
|
||
* 授权钩子的端到端验证 —— 不需要 ZCode 登录也能跑。
|
||
*
|
||
* 为什么要单独验这一层:钩子是本插件里**唯一会放行危险工具**的地方。
|
||
* 单测能证明「档位判定正确」,但证明不了「人点了同意,钩子真的收到了那条决定」——
|
||
* 中间隔着 SSE 扇出、relay_key 配对、决策文案判定三处真实耦合。
|
||
*
|
||
* # 判据设计
|
||
*
|
||
* - **正向**:真建一条含人类的会话 → 起钩子 → 人点「同意」→ 钩子必须输出 approve
|
||
* - **反向对照 1**:同一路径改点「拒绝」→ 必须输出 block(证明它不是恒 approve)
|
||
* - **反向对照 2**:plan 档 → 必须**不产生任何权限邮件**就拒绝(证明档位真的在拦)
|
||
* - **反向对照 3**:无人可问(无会话)→ 必须 fail closed 拒绝
|
||
* - **反向对照 4**:非守卫工具(Read)→ 必须不表态(证明不是恒输出)
|
||
*
|
||
* 三态结论:某一项无法判定时明确报「无法判定」,不并入通过。
|
||
*
|
||
* 用法:node test/manual/permission-e2e.mjs [--keep]
|
||
*/
|
||
|
||
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';
|
||
import { join, dirname } from 'node:path';
|
||
import { fileURLToPath } from 'node:url';
|
||
|
||
const HERE = dirname(fileURLToPath(import.meta.url));
|
||
const HOOK = join(HERE, '../../hooks/permission.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}` : ''}`);
|
||
};
|
||
|
||
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}`);
|
||
const cookie = (res.headers.getSetCookie?.() ?? []).map(c => c.split(';')[0]).join('; ');
|
||
if (!cookie) throw new Error('登录成功但没拿到 cookie');
|
||
return cookie;
|
||
}
|
||
|
||
/** agent 身份发一封邮件,得到一条含人类的会话。 */
|
||
async function openSession(agent) {
|
||
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: HUMAN.username,
|
||
subject: `授权桥验证 ${new Date().toISOString().slice(11, 19)}`,
|
||
body: '这是一封用于验证授权钩子的邮件,无需处理。'
|
||
})
|
||
});
|
||
const data = await res.json().catch(() => ({}));
|
||
if (!res.ok || !data.session_id) {
|
||
throw new Error(`建会话失败 HTTP ${res.status} ${JSON.stringify(data).slice(0, 200)}`);
|
||
}
|
||
return { sessionId: data.session_id, mailId: data.mail_id, subject: `授权桥验证` };
|
||
}
|
||
|
||
/**
|
||
* 以人类身份等一条**属于本会话**的待决权限。
|
||
*
|
||
* 必须同时按「不在启动前快照里」+「session_id 是本会话」+「agent 是 zcode」三重过滤:
|
||
* `/permission/pending` 返回的是所有历史待决请求(本机实测积压了 6 条,
|
||
* 里面还有 pi 桥的小写 `bash` 条目)。只按「第一条新的」取,会取到一条**无关**的
|
||
* 旧请求 —— 于是人在界面上点了同意,钩子却在等自己那条,最后超时。
|
||
* 第一版脚本就是这么错的:它把「钩子超时」报成了「拒绝路径通过」。
|
||
*/
|
||
async function waitPending(cookie, timeoutMs, { before, sessionId } = {}) {
|
||
const deadline = Date.now() + timeoutMs;
|
||
while (Date.now() < deadline) {
|
||
const res = await fetch(`${GATEWAY}/api/v1/permission/pending`, { headers: { Cookie: cookie } });
|
||
if (res.ok) {
|
||
const { requests } = await res.json().catch(() => ({ requests: [] }));
|
||
const fresh = (requests || []).find(
|
||
r =>
|
||
!before?.has(r.mail_id) &&
|
||
r.agent_name === 'zcode' &&
|
||
(!sessionId || r.session_id === sessionId)
|
||
);
|
||
if (fresh) return fresh;
|
||
}
|
||
await new Promise(r => setTimeout(r, 400));
|
||
}
|
||
return null;
|
||
}
|
||
|
||
async function decide(cookie, mailId, decision) {
|
||
const res = await fetch(`${GATEWAY}/api/v1/permission/decide`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json', Cookie: cookie },
|
||
body: JSON.stringify({ mail_id: mailId, decision, note: `e2e:${decision}` })
|
||
});
|
||
const body = await res.text();
|
||
return { ok: res.ok, status: res.status, body: body.slice(0, 200) };
|
||
}
|
||
|
||
/**
|
||
* 跑一次钩子。
|
||
*
|
||
* 返回值区分三种情况:输出 approve / 输出 block / 没有输出(不表态)。
|
||
* 把「进程崩了」与「明确拒绝」分开记 —— 两者在业务上后果完全不同。
|
||
*/
|
||
function runHook({ input, env, timeoutMs = 90000 }) {
|
||
return new Promise(resolve => {
|
||
const child = spawn(process.execPath, [HOOK], {
|
||
env: { ...process.env, ...env },
|
||
stdio: ['pipe', 'pipe', 'pipe']
|
||
});
|
||
let out = '';
|
||
let err = '';
|
||
let done = false;
|
||
const finish = () => {
|
||
if (done) return;
|
||
done = true;
|
||
const trimmed = out.trim();
|
||
let parsed = null;
|
||
if (trimmed) {
|
||
try {
|
||
parsed = JSON.parse(trimmed);
|
||
} catch {
|
||
parsed = { __unparsable: trimmed.slice(0, 200) };
|
||
}
|
||
}
|
||
resolve({ stdout: trimmed, parsed, stderr: err.trim().split('\n').slice(-4).join('\n'), code: child.exitCode });
|
||
};
|
||
child.stdout.on('data', d => (out += d));
|
||
child.stderr.on('data', d => (err += d));
|
||
child.on('close', () => {
|
||
if (!done) {
|
||
// close 之后 exitCode 才是最终值;这里直接读即可
|
||
finish();
|
||
}
|
||
});
|
||
const timer = setTimeout(() => {
|
||
child.kill('SIGKILL');
|
||
finish();
|
||
}, timeoutMs);
|
||
child.on('close', () => clearTimeout(timer));
|
||
child.stdin.end(JSON.stringify(input));
|
||
});
|
||
}
|
||
|
||
const hookInput = (toolName, toolUseId) => ({
|
||
hook_event_name: 'PermissionRequest',
|
||
tool_name: toolName,
|
||
tool_input: { command: 'echo e2e' },
|
||
session_id: 'zcode-session-probe',
|
||
tool_use_id: toolUseId,
|
||
permission_mode: 'default',
|
||
cwd: '/tmp'
|
||
});
|
||
|
||
async function main() {
|
||
const agent = { name: 'zcode' };
|
||
// 密钥从部署环境读;没有就退回 secret
|
||
try {
|
||
agent.key = readFileSync('/etc/agentmail/zcode.env', 'utf8').match(
|
||
/AGENTMAIL_AGENT_KEY=(.+)/
|
||
)?.[1]?.trim();
|
||
} catch {}
|
||
if (!agent.key) {
|
||
try {
|
||
agent.secret = readFileSync('/root/gotmp/zcode-agent-secret.txt', 'utf8').trim();
|
||
} catch {}
|
||
}
|
||
if (!agent.key && !agent.secret) {
|
||
console.error('拿不到 zcode 的凭据,无法验证');
|
||
process.exit(2);
|
||
}
|
||
|
||
const cookie = await login();
|
||
console.log('已登录人类账号,开始验证\n');
|
||
|
||
const grantsFile = join(await mkdtemp(join(tmpdir(), 'zc-e2e-')), 'grants.json');
|
||
const baseEnv = {
|
||
AGENTMAIL_GATEWAY_URL: GATEWAY,
|
||
AGENTMAIL_AGENT_NAME: 'zcode',
|
||
...(agent.key ? { AGENTMAIL_AGENT_KEY: agent.key } : { AGENTMAIL_AGENT_SECRET: agent.secret }),
|
||
AGENTMAIL_ZCODE_GRANTS_FILE: grantsFile,
|
||
// 必须**明显小于**下面 runHook 的杀进程上限:两者相等时钩子会在
|
||
// 正要输出结论的瞬间被 SIGKILL,于是「不表态」与「来不及答」分不开。
|
||
AGENTMAIL_PERMISSION_WAIT_MS: '20000'
|
||
};
|
||
|
||
const seen = new Set();
|
||
// 启动前先快照一次:待决列表里有历史积压(含其他 Agent 的条目),
|
||
// 不排除掉就会把「别人的旧请求」当成我们自己刚建的。
|
||
{
|
||
const res = await fetch(`${GATEWAY}/api/v1/permission/pending?all=true`, {
|
||
headers: { Cookie: cookie }
|
||
});
|
||
const { requests } = await res.json().catch(() => ({ requests: [] }));
|
||
for (const r of requests || []) seen.add(r.mail_id);
|
||
console.log(`启动前已有 ${seen.size} 条历史待决请求(已排除)\n`);
|
||
}
|
||
|
||
// ── 1. 正向:workspace 档 + 同意 ─────────────────────────────
|
||
try {
|
||
const { sessionId, subject } = await openSession(agent);
|
||
const hookPromise = runHook({
|
||
input: hookInput('Bash', `toolu-approve-${Date.now()}`),
|
||
env: {
|
||
...baseEnv,
|
||
AGENTMAIL_SESSION_ID: sessionId,
|
||
AGENTMAIL_PERMISSION_MODE: 'workspace',
|
||
AGENTMAIL_MAIL_SUBJECT: subject
|
||
}
|
||
});
|
||
const pending = await waitPending(cookie, 30000, { before: seen, sessionId });
|
||
if (!pending) {
|
||
record('workspace 档 · 同意 → approve', '无法判定', '30 秒内没等到本会话的待决权限邮件');
|
||
} else {
|
||
seen.add(pending.mail_id);
|
||
const d = await decide(cookie, pending.mail_id, '同意');
|
||
const r = await hookPromise;
|
||
if (!d.ok) {
|
||
record('workspace 档 · 同意 → approve', '无法判定', `决策接口 HTTP ${d.status}`);
|
||
} else if (r.parsed?.decision === 'approve') {
|
||
record('workspace 档 · 同意 → approve', '通过', '钩子收到决定并放行');
|
||
} else {
|
||
record('workspace 档 · 同意 → approve', '失败', `钩子输出 ${r.stdout || '(空)'} code=${r.code}`);
|
||
}
|
||
}
|
||
} catch (e) {
|
||
record('workspace 档 · 同意 → approve', '失败', e.message);
|
||
}
|
||
|
||
// ── 2. 反向对照:同一路径改点拒绝 ────────────────────────────
|
||
try {
|
||
const { sessionId, subject } = await openSession(agent);
|
||
const hookPromise = runHook({
|
||
input: hookInput('Bash', `toolu-deny-${Date.now()}`),
|
||
env: {
|
||
...baseEnv,
|
||
AGENTMAIL_SESSION_ID: sessionId,
|
||
AGENTMAIL_PERMISSION_MODE: 'workspace',
|
||
AGENTMAIL_MAIL_SUBJECT: subject
|
||
}
|
||
});
|
||
const pending = await waitPending(cookie, 30000, { before: seen, sessionId });
|
||
if (!pending) {
|
||
record('反向对照 · 拒绝 → block', '无法判定', '没等到本会话的待决权限邮件');
|
||
} else {
|
||
seen.add(pending.mail_id);
|
||
await decide(cookie, pending.mail_id, '拒绝');
|
||
const r = await hookPromise;
|
||
// 判据必须验**原因来自人的拒绝**,不能只验「输出是 block」——
|
||
// 超时也会输出 block。第一版就因此把超时误报成了通过。
|
||
if (r.parsed?.decision === 'block' && /拒绝/.test(r.parsed.reason || '')) {
|
||
record('反向对照 · 拒绝 → block', '通过', '钩子把人的拒绝转成了拒绝');
|
||
} else if (r.parsed?.decision === 'block') {
|
||
record(
|
||
'反向对照 · 拒绝 → block',
|
||
'失败',
|
||
`block 但不是人拒绝造成的(原因:${(r.parsed.reason || '').slice(0, 50)})`
|
||
);
|
||
} else {
|
||
record('反向对照 · 拒绝 → block', '失败', `钩子输出 ${r.stdout || '(空)'}`);
|
||
}
|
||
}
|
||
} catch (e) {
|
||
record('反向对照 · 拒绝 → block', '失败', e.message);
|
||
}
|
||
|
||
// ── 3. 反向对照:plan 档必须不产生邮件就拒绝 ─────────────────
|
||
try {
|
||
// 隔离一个全新会话:不然「有没有产生新请求」会被别处的请求干扰,
|
||
// 而判据一旦看错对象,就会把「别人的旧请求」当成我们的泄漏(第一版即如此)。
|
||
const { sessionId } = await openSession(agent);
|
||
const r = await runHook({
|
||
input: hookInput('Bash', `toolu-plan-${Date.now()}`),
|
||
env: { ...baseEnv, AGENTMAIL_SESSION_ID: sessionId, AGENTMAIL_PERMISSION_MODE: 'plan' },
|
||
timeoutMs: 20000
|
||
});
|
||
if (r.parsed?.decision !== 'block') {
|
||
record('反向对照 · plan 档直接拒绝', '失败', `钩子输出 ${r.stdout || '(空)'}`);
|
||
} else if (!/plan 档/.test(r.parsed.reason || '')) {
|
||
record('反向对照 · plan 档直接拒绝', '失败', `拒绝原因不是档位判定:${r.parsed.reason}`);
|
||
} else {
|
||
const leaked = await waitPending(cookie, 3000, { before: seen, sessionId });
|
||
if (leaked) {
|
||
seen.add(leaked.mail_id);
|
||
record('反向对照 · plan 档直接拒绝', '失败', `仍产生了权限邮件 ${leaked.mail_id}`);
|
||
} else {
|
||
record('反向对照 · plan 档直接拒绝', '通过', '按档位拒绝,且未给任何人发权限邮件');
|
||
}
|
||
}
|
||
} catch (e) {
|
||
record('反向对照 · plan 档直接拒绝', '失败', e.message);
|
||
}
|
||
|
||
// ── 4. 反向对照:无人可问 → fail closed ─────────────────────
|
||
try {
|
||
// 用「**合法 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
|
||
});
|
||
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 {
|
||
record('反向对照 · 无人可问 → 拒绝', '失败', `钩子输出了 ${r.stdout}`);
|
||
}
|
||
} catch (e) {
|
||
record('反向对照 · 无人可问 → 拒绝', '失败', e.message);
|
||
}
|
||
|
||
// ── 5. 反向对照:非守卫工具不表态 ───────────────────────────
|
||
try {
|
||
const r = await runHook({
|
||
input: hookInput('Read', `toolu-read-${Date.now()}`),
|
||
env: { ...baseEnv, AGENTMAIL_PERMISSION_MODE: 'workspace' },
|
||
timeoutMs: 20000
|
||
});
|
||
if (r.parsed === null && r.code === 0) {
|
||
record('反向对照 · Read 不表态', '通过', '无输出即无意见(退回 ZCode 自己的流程)');
|
||
} else {
|
||
record('反向对照 · Read 不表态', '失败', `输出 ${r.stdout || '(空)'} code=${r.code}`);
|
||
}
|
||
} catch (e) {
|
||
record('反向对照 · Read 不表态', '失败', e.message);
|
||
}
|
||
|
||
// ── 小结 ────────────────────────────────────────────────────
|
||
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} 无法判定`);
|
||
|
||
if (!KEEP) await rm(join(grantsFile, '..'), { recursive: true, force: true });
|
||
process.exit(fail > 0 ? 1 : 0);
|
||
}
|
||
|
||
main().catch(e => {
|
||
console.error('验证脚本自身出错:', e);
|
||
process.exit(2);
|
||
});
|