feat(zcode): 授权桥 —— PermissionRequest 钩子把危险工具授权交给人

第二步:让 ZCode 上的 Bash/Write/Edit 授权走 AgentMail 的人工审批,
而不是只靠本地界面。

钩子契约从 CLI 产物里逆出来(不猜协议):
- 输入走 stdin:{hook_event_name, tool_name, tool_input, session_id, permission_mode…}
- 输出走 stdout,schema **严格**:{"decision":"approve"} / {"decision":"block","reason"}
  多一个键就会报 "Hook stdout failed HookJSONOutput schema validation"
- 空输出 / 不以 { 开头 = 不表态;exit 2 = 拒绝;其它非零 = 钩子失败
- 注入的环境变量含 ZCODE_PLUGIN_ROOT / ZCODE_PLUGIN_DATA / ZCODE_SESSION_ID
  (MCP 配置里用 ZCODE_SESSION_ID 反而会抛「需要运行时会话上下文」)

档位判定与 pi 桥逐条对齐(plan 直接拒 / workspace 问人 / full 批准),
判定逻辑抽成纯函数 lib/hook-policy.mjs 以便穷举:
其中 full 档必须**返回批准而不是不表态** —— 钩子一旦触发说明 ZCode 本会去问人,
不表态等于让那个询问照常发生,full 档就退化成了 workspace 档。

钩子自己开 SSE 等决定,不依赖桥进程:网关的 SSE 是扇出的
(clients 按唯一 id 存,SendToAgent 推给该 Agent 的所有客户端),
一次性进程也能订阅到自己那条 permission_decision。这样交互模式下同样可用
(人自己开着 ZCode 干活时并没有桥在跑)。先建连再发请求是有意的:
反过来会有一个窗口,人在窗口内点的同意推送给当时还不存在的客户端。

fail closed 但区分模式:永久失败(409/4xx)一律拒绝;暂时失败在
AGENTMAIL_SESSION_ID 非空(邮件驱动、没有本地界面兜底)时拒绝,
交互模式则不表态让人就地决定。

「一直同意」落盘(lib/grants-file.mjs):钩子是一个事件一个进程,
不落盘那个选项就是骗人的。判定仍交给共用的 permission-grants.js。

共用模块同源范围扩到 9 个(新增 permission-mode / relay-key /
permission-grants / sse-client)—— 档位语义与决策判定分叉会让「同意」
在 ZCode 上悄悄变成另一种意思。

验证:
- 单元 229/229(新增 hook-policy 14 项、grants-file 8 项,含反向对照)
- 共用模块四方同源检查通过
- 授权桥端到端 5/5,全部带反向对照:
  同意→approve;拒绝→block 且原因必须来自人的拒绝(不能是超时兜底);
  plan 档拒绝且**不产生**任何权限邮件;无人可问(409)→fail closed;
  非守卫工具→不表态
- `zcode plugins list` → agentmail@inline [enabled],hooks: 1,
  mcp: plugin:agentmail:agentmail

我自己写错的两处判据(都已修,值得记下):
1. 待决权限列表里有历史积压(实测 6 条,含其它 Agent 的条目),
   只按「第一条新的」取会拿到无关请求 —— 于是人点了同意而钩子在等自己那条,
   最后超时。第一版还把这个超时误报成「拒绝路径通过」。
   现在按「启动前快照差集 + session_id + agent_name」三重过滤。
2. 「无人可问」控制组最初传了个非 UUID 的 session id,走的是 400(参数错),
   验不到 409 那条真实路径。改为真的造一条只有 Agent 没有人类的会话。
This commit is contained in:
2026-09-12 14:09:10 +08:00
parent e0e6f86d94
commit c774904c0c
17 changed files with 2558 additions and 0 deletions

View File

@ -0,0 +1,98 @@
/**
* 「一直同意」的跨进程持久化测试。
*
* 这个功能的判据只有一条最要紧:**下一个进程还认不认**。
* 钩子一封(一次工具调用)一个进程,所以「记在内存里」等于没记。
*/
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { mkdtemp, writeFile, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { createFileGrantStore, grantsFilePath } from '../lib/grants-file.mjs';
const tmpFile = async () => join(await mkdtemp(join(tmpdir(), 'zc-grants-')), 'g.json');
test('★ 授权跨进程存活(新 store 读同一个文件仍认账)', async () => {
const f = await tmpFile();
const s1 = createFileGrantStore(f);
assert.equal(s1.isGranted('sess-1', 'Bash'), false);
assert.equal(s1.grant('sess-1', 'Bash', '一直同意'), true);
// 模拟下一个钩子进程
const s2 = createFileGrantStore(f);
assert.equal(s2.isGranted('sess-1', 'Bash'), true);
await rm(join(f, '..'), { recursive: true, force: true });
});
test('「同意」是单次,不落盘', async () => {
const f = await tmpFile();
const s = createFileGrantStore(f);
assert.equal(s.grant('sess-1', 'Bash', '同意'), false);
assert.equal(createFileGrantStore(f).isGranted('sess-1', 'Bash'), false);
await rm(join(f, '..'), { recursive: true, force: true });
});
test('★ 反向对照:同一个文件里,一直同意与单次同意必须分道扬镳', async () => {
// 只翻转决策文本,落盘结果必须不同 —— 否则「什么都记下来」也会让上一条通过。
const f = await tmpFile();
const s = createFileGrantStore(f);
assert.equal(s.grant('sess-a', 'Bash', '一直同意'), true);
assert.equal(s.grant('sess-b', 'Bash', '同意'), false);
const back = createFileGrantStore(f);
assert.equal(back.isGranted('sess-a', 'Bash'), true);
assert.equal(back.isGranted('sess-b', 'Bash'), false);
await rm(join(f, '..'), { recursive: true, force: true });
});
test('授权按会话隔离,不跨会话泄漏', async () => {
const f = await tmpFile();
const s = createFileGrantStore(f);
s.grant('sess-1', 'Bash', '一直同意');
const back = createFileGrantStore(f);
assert.equal(back.isGranted('sess-1', 'Bash'), true);
assert.equal(back.isGranted('sess-2', 'Bash'), false);
await rm(join(f, '..'), { recursive: true, force: true });
});
test('授权按工具隔离bash 的免批不放行 write', async () => {
const f = await tmpFile();
const s = createFileGrantStore(f);
s.grant('s', 'Bash', '一直同意');
const back = createFileGrantStore(f);
assert.equal(back.isGranted('s', 'Bash'), true);
assert.equal(back.isGranted('s', 'Write'), false);
await rm(join(f, '..'), { recursive: true, force: true });
});
test('撤销会话后不再免批', async () => {
const f = await tmpFile();
const s = createFileGrantStore(f);
s.grant('s', 'Bash', '一直同意');
assert.equal(s.revokeSession('s'), true);
assert.equal(createFileGrantStore(f).isGranted('s', 'Bash'), false);
await rm(join(f, '..'), { recursive: true, force: true });
});
test('文件不存在或内容损坏都当空表,不抛错', async () => {
const f = await tmpFile();
assert.equal(createFileGrantStore(f).isGranted('s', 'Bash'), false);
await writeFile(f, '{ 这不是 JSON', 'utf8');
assert.equal(createFileGrantStore(f).isGranted('s', 'Bash'), false);
await writeFile(f, '{"sessions":{"s":"not-an-array"}}', 'utf8');
assert.equal(createFileGrantStore(f).isGranted('s', 'Bash'), false);
await rm(join(f, '..'), { recursive: true, force: true });
});
test('文件位置按 显式 > AGENTMAIL_CONFIG_DIR > ZCODE_PLUGIN_DATA > 家目录 解析', () => {
const p = grantsFilePath({
AGENTMAIL_ZCODE_GRANTS_FILE: '/x/g.json',
AGENTMAIL_CONFIG_DIR: '/c',
ZCODE_PLUGIN_DATA: '/d'
});
assert.equal(p, '/x/g.json');
assert.equal(grantsFilePath({ AGENTMAIL_CONFIG_DIR: '/c', ZCODE_PLUGIN_DATA: '/d' }), '/c/permission-grants.json');
assert.equal(grantsFilePath({ ZCODE_PLUGIN_DATA: '/d' }), '/d/permission-grants.json');
assert.match(grantsFilePath({}), /permission-grants\.json$/);
});

View File

@ -0,0 +1,98 @@
/**
* 授权钩子策略层的测试。
*
* 档位判定是**给产品定的、不是给平台定的**同一条「plan 档」在 ZCode 上
* 必须与 pi 桥同义。这层是纯函数,所以可以被穷举 —— 真去起一个 ZCode 会话
* 验一遍的代价高得多,而档位判断错了的后果是「有人以为自己在只读档,
* 实际被跑了命令」。
*/
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { decidePolicy, isGuardedTool, describeToolCall, PERMISSION_EVENT } from '../lib/hook-policy.mjs';
const call = (toolName, mode) => decidePolicy({ event: PERMISSION_EVENT, toolName, mode });
test('非 PermissionRequest 事件一律不表态', () => {
for (const event of ['PreToolUse', 'PostToolUse', 'Stop', undefined, '']) {
assert.equal(decidePolicy({ event, toolName: 'Bash', mode: 'workspace' }).action, 'none');
}
});
test('守卫工具名大小写无关,且含 ApplyPatch 别名', () => {
for (const n of ['Bash', 'bash', 'BASH', 'Write', 'edit', 'ApplyPatch']) {
assert.equal(isGuardedTool(n), true, n);
}
for (const n of ['Read', 'Grep', 'Glob', 'mcp__agentmail__send_mail', '', null]) {
assert.equal(isGuardedTool(n), false, String(n));
}
});
test('未在守卫表里的工具不表态(退回 ZCode 自己的权限流程)', () => {
for (const n of ['Read', 'Grep', 'WebFetch']) {
assert.equal(call(n, 'workspace').action, 'none', n);
}
});
test('workspace 档:问人', () => {
assert.equal(call('Bash', 'workspace').action, 'ask');
});
test('档位省略时按默认workspace处理', () => {
assert.equal(call('Bash', undefined).action, 'ask');
assert.equal(call('Bash', '').action, 'ask');
});
test('★ full 档:批准,而不是不表态', () => {
// 判据的关键。ZCode 的钩子一旦被触发,说明 ZCode **本会**去问人;
// 「不表态」等于让那个询问照常发生 —— 而 full 档的语义正是免掉它。
// 若这里返回 nonefull 档就变成了 workspace 档(发件人以为给了全权,
// 结果每一步还在等人点。pi 桥在该档是「不拦截」ZCode 上的等价物就是批准。
assert.equal(call('Bash', 'full').action, 'approve');
});
test('★ plan 档:直接拒绝,且文案与 pi 桥同源', () => {
const r = call('Bash', 'plan');
assert.equal(r.action, 'block');
assert.match(r.reason, /plan 档下不允许执行 Bash/);
assert.match(r.reason, /把方案写在回信里/);
assert.match(r.reason, /改成 workspace/);
});
test('plan 档对非守卫工具仍然不表态(读与查本来就允许)', () => {
assert.equal(call('Read', 'plan').action, 'none');
});
test('★ 反向对照:只翻转档位,结论必须跟着变', () => {
// 同样的工具名,三个档必须给出三个不同结论。
// 没有这条,「无论什么档都返回 ask」也会让上面的断言通过。
const results = ['plan', 'workspace', 'full'].map(m => call('Bash', m).action);
assert.deepEqual(results, ['block', 'ask', 'approve']);
});
test('未知档位按默认处理,不会静默变成 full', () => {
// 拼错的档位若被当成 full等于把一个打字错误变成「免授权」。
assert.equal(call('Bash', 'worjspace').action, 'ask');
});
// ─── 摘要文本 ─────────────────────────────────────────────────────
test('Bash 的摘要给出命令本身', () => {
const s = describeToolCall('Bash', { command: 'rm -rf /tmp/x' });
assert.match(s, /rm -rf \/tmp\/x/);
});
test('Write/Edit 的摘要给出文件路径(三种字段名都认)', () => {
for (const key of ['file_path', 'path', 'filePath']) {
assert.match(describeToolCall('Write', { [key]: '/tmp/a.txt' }), /\/tmp\/a\.txt/, key);
}
});
test('缺字段时给出可读的占位而不是崩', () => {
assert.match(describeToolCall('Write', {}), /未给出/);
assert.equal(typeof describeToolCall('Bash', undefined), 'string');
});
test('过长命令被截断(写进邮件正文的东西不能无限长)', () => {
const s = describeToolCall('Bash', { command: 'x'.repeat(5000) });
assert.ok(s.length < 900, `实际长度 ${s.length}`);
});

View File

@ -0,0 +1,379 @@
#!/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 { 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,
AGENTMAIL_PERMISSION_WAIT_MS: '60000'
};
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 {
// 造一条**只有 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: '仅用于验证:这条会话上没有人类。'
})
});
const noHumanSession = (await res.json().catch(() => ({})))?.session_id;
if (!noHumanSession) {
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}`);
}
}
} 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);
});

View File

@ -0,0 +1,156 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
isAlwaysDecision,
isApproval,
createGrantStore,
} from '../lib/permission-grants.js';
// ─── isAlwaysDecision ───
//
// 这个函数是整个模块里最危险的一处:判宽了就把单次授权静默升级成永久授权。
test('「一直同意」判为永久', () => {
assert.equal(isAlwaysDecision('一直同意'), true);
});
test('「同意」不是永久 —— 前缀匹配会把单次授权升级成永久', () => {
// /^同意/ 之类的正则会让这条过,那意味着人点一次「同意」,
// 后面所有命令都不再问 —— 静默越权。
assert.equal(isAlwaysDecision('同意'), false);
});
test('always / allow-always 判为永久(英文界面)', () => {
for (const d of ['always', 'Always', 'ALWAYS', 'allow-always', 'allow_always']) {
assert.equal(isAlwaysDecision(d), true, d);
}
});
test('allow / approve / yes 不是永久', () => {
for (const d of ['allow', 'approve', 'yes']) {
assert.equal(isAlwaysDecision(d), false, d);
}
});
test('「拒绝」不是永久', () => {
assert.equal(isAlwaysDecision('拒绝'), false);
});
test('两侧空白不影响判定(界面传过来的值可能带空格)', () => {
assert.equal(isAlwaysDecision(' 一直同意 '), true);
});
test('空值与 null 不是永久', () => {
for (const d of ['', ' ', null, undefined]) {
assert.equal(isAlwaysDecision(d), false, String(d));
}
});
test('「一直同意吧」这类多余后缀不判为永久(精确匹配)', () => {
// 精确匹配的取舍:宁可漏判(多问一次)也不误判(静默永久放行)
assert.equal(isAlwaysDecision('一直同意吧'), false);
});
// ─── isApproval ───
test('同意与一直同意都是放行', () => {
assert.equal(isApproval('同意'), true);
assert.equal(isApproval('一直同意'), true);
});
test('英文放行选项', () => {
for (const d of ['allow', 'approve', 'always', 'yes', 'Allow']) {
assert.equal(isApproval(d), true, d);
}
});
test('拒绝不是放行', () => {
assert.equal(isApproval('拒绝'), false);
});
test('fail closed认不出的文本一律当拒绝', () => {
// 关停哨兵、空值、乱码都必须落到拒绝一侧N-9
for (const d of ['shutdown', '', null, undefined, '也许吧', 'maybe']) {
assert.equal(isApproval(d), false, String(d));
}
});
// ─── createGrantStore ───
test('未授权时不放行', () => {
const s = createGrantStore();
assert.equal(s.isGranted('sess-1', 'bash'), false);
});
test('点「一直同意」后同会话同工具免批', () => {
const s = createGrantStore();
assert.equal(s.grant('sess-1', 'bash', '一直同意'), true);
assert.equal(s.isGranted('sess-1', 'bash'), true);
});
test('点「同意」不产生免批 —— 这正是修复前的 bug', () => {
const s = createGrantStore();
assert.equal(s.grant('sess-1', 'bash', '同意'), false);
assert.equal(s.isGranted('sess-1', 'bash'), false);
});
test('授权不跨工具:批了 bash 不等于批了 write', () => {
const s = createGrantStore();
s.grant('sess-1', 'bash', '一直同意');
assert.equal(s.isGranted('sess-1', 'write'), false);
});
test('授权不跨会话:这是防越权的关键', () => {
// 人为「审查 llmsproxy」这条会话批准的 bash不该授权
// 另一个发件人派来的另一条任务
const s = createGrantStore();
s.grant('sess-1', 'bash', '一直同意');
assert.equal(s.isGranted('sess-2', 'bash'), false);
});
test('revokeSession 清掉整条会话的全部授权', () => {
const s = createGrantStore();
s.grant('sess-1', 'bash', '一直同意');
s.grant('sess-1', 'write', '一直同意');
s.grant('sess-2', 'bash', '一直同意');
assert.equal(s.size(), 3);
s.revokeSession('sess-1');
assert.equal(s.isGranted('sess-1', 'bash'), false);
assert.equal(s.isGranted('sess-1', 'write'), false);
// 别的会话不受影响
assert.equal(s.isGranted('sess-2', 'bash'), true);
assert.equal(s.size(), 1);
});
test('工具名里含 : 不会导致误删(这是不用拼接键的原因)', () => {
const s = createGrantStore();
s.grant('sess-1', 'mcp:bash', '一直同意');
s.grant('sess-1:extra', 'bash', '一直同意');
s.revokeSession('sess-1');
// 拼接键实现(`${session}:${tool}` 按前缀删)会把下面这条一起删掉
assert.equal(s.isGranted('sess-1:extra', 'bash'), true);
});
test('空会话 id / 空工具名不产生授权(防止一个空键放行一切)', () => {
const s = createGrantStore();
assert.equal(s.grant('', 'bash', '一直同意'), false);
assert.equal(s.grant('sess-1', '', '一直同意'), false);
assert.equal(s.isGranted('', 'bash'), false);
assert.equal(s.isGranted('sess-1', ''), false);
assert.equal(s.size(), 0);
});
test('重复授权同一对不重复计数', () => {
const s = createGrantStore();
s.grant('sess-1', 'bash', '一直同意');
s.grant('sess-1', 'bash', '一直同意');
assert.equal(s.size(), 1);
});
test('revokeSession 对没授权过的会话是安全的空操作', () => {
const s = createGrantStore();
s.revokeSession('never-seen');
assert.equal(s.size(), 0);
});

View File

@ -0,0 +1,215 @@
/**
* lib/permission-mode.js 的测试 —— 四个平台逐字节共用。
*
* 这些判据编码了六条 opencode 实测结论。不实测就写代码会做出「看起来对但
* 管不住」的东西,所以每条结论都在这里钉死,改坏了会当场失败。
*/
import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
MODE_PLAN, MODE_WORKSPACE, MODE_FULL, MODES, DEFAULT_MODE,
ENFORCE_NATIVE, ENFORCE_PARTIAL, ENFORCE_ADVISORY,
normalizeMode, normalizeEnforcement, modeAtMost, modeNeedsHuman,
dshSandboxMode, dshApprovalPolicy,
piGuardedTools, piBlocksOutright, modeBriefing,
} from '../lib/permission-mode.js';
// ─── 归一化 ───
test('合法档位原样返回', () => {
for (const m of MODES) assert.equal(normalizeMode(m), m);
});
test('非法档位 fail-closed 到默认档,不是 full', () => {
for (const bad of ['', 'FULL', 'full-access', 'workspace-write', null, undefined, 42, {}]) {
assert.equal(normalizeMode(bad), DEFAULT_MODE, `${String(bad)} 应当归到默认档`);
}
assert.notEqual(DEFAULT_MODE, MODE_FULL, '默认档不能是 full');
});
test('强制力保守方向是 advisory', () => {
for (const ok of [ENFORCE_NATIVE, ENFORCE_PARTIAL, ENFORCE_ADVISORY]) {
assert.equal(normalizeEnforcement(ok), ok, `${ok} 是平台自报的事实,必须原样保留`);
}
for (const bad of ['', 'NATIVE', 'enforced', null, undefined]) {
assert.equal(normalizeEnforcement(bad), ENFORCE_ADVISORY);
}
});
test('档位顺序必须是 plan < workspace < fullmodeAtMost 的依据)', () => {
assert.deepEqual(MODES, [MODE_PLAN, MODE_WORKSPACE, MODE_FULL]);
});
// ─── modeAtMost ───
test('modeAtMost 取更严的一档', () => {
assert.equal(modeAtMost(MODE_PLAN, MODE_FULL), MODE_PLAN);
assert.equal(modeAtMost(MODE_FULL, MODE_PLAN), MODE_PLAN);
assert.equal(modeAtMost(MODE_WORKSPACE, MODE_FULL), MODE_WORKSPACE);
assert.equal(modeAtMost(MODE_FULL, MODE_FULL), MODE_FULL);
});
// Gateway 侧曾因为「未知值当最严 vs 归到默认档」两套语义而不可交换,
// 单元测试当场抓到。两边保持同一套语义。
test('modeAtMost 可交换(脏值也不例外)', () => {
const all = [...MODES, 'garbage', '', null];
for (const a of all) {
for (const b of all) {
assert.equal(modeAtMost(a, b), modeAtMost(b, a),
`不可交换:(${a},${b})`);
}
}
});
test('脏值不得把 plan 抬成更宽松的档', () => {
assert.equal(modeAtMost('garbage', MODE_PLAN), MODE_PLAN);
});
// ─── modeNeedsHuman ───
test('只有 workspace 档需要人点头', () => {
assert.equal(modeNeedsHuman(MODE_PLAN), false, 'plan 档当场拒绝,不问人');
assert.equal(modeNeedsHuman(MODE_WORKSPACE), true);
assert.equal(modeNeedsHuman(MODE_FULL), false, 'full 档自动放行,不问人');
});
test('脏档位按默认档处理,即需要人(宁可多问一次)', () => {
assert.equal(modeNeedsHuman('garbage'), true);
assert.equal(modeNeedsHuman(''), true);
});
// ─── DSH ───
test('DSH 三档与原生沙箱一一对应', () => {
assert.equal(dshSandboxMode(MODE_PLAN), 'read-only');
assert.equal(dshSandboxMode(MODE_WORKSPACE), 'workspace-write');
assert.equal(dshSandboxMode(MODE_FULL), 'danger-full-access');
});
// 关键实测danger-full-access → approval:"never" → decide() 在 waterfall
// 之前短路 return "rejected"approval/request 钩子根本不触发。
test('DSH 审批策略只在 workspace 档是 ask', () => {
assert.equal(dshApprovalPolicy(MODE_WORKSPACE), 'ask');
assert.equal(dshApprovalPolicy(MODE_PLAN), 'never');
assert.equal(dshApprovalPolicy(MODE_FULL), 'never');
});
test('DSH 脏档位按默认档workspace-write + ask', () => {
assert.equal(dshSandboxMode('garbage'), 'workspace-write');
assert.equal(dshApprovalPolicy('garbage'), 'ask');
});
// ─── pi ───
test('pi 在 full 档不守卫任何工具', () => {
assert.deepEqual(piGuardedTools(MODE_FULL), []);
});
test('pi 在 plan / workspace 档守卫 bash / write / edit', () => {
for (const m of [MODE_PLAN, MODE_WORKSPACE]) {
const g = piGuardedTools(m);
assert.ok(g.includes('bash'));
assert.ok(g.includes('write'));
assert.ok(g.includes('edit'));
}
});
test('pi 不守卫读类工具', () => {
const g = piGuardedTools(MODE_WORKSPACE);
for (const t of ['read', 'grep', 'find', 'ls']) {
assert.equal(g.includes(t), false, `${t} 是读类工具,不该守卫`);
}
});
test('pi 在 plan 档直接拒绝,不走问人流程', () => {
assert.equal(piBlocksOutright(MODE_PLAN), true);
assert.equal(piBlocksOutright(MODE_WORKSPACE), false);
assert.equal(piBlocksOutright(MODE_FULL), false);
});
// ─── modeBriefing ───
test('full 档的说明不提授权', () => {
const s = modeBriefing({ mode: MODE_FULL, enforcement: ENFORCE_NATIVE });
assert.match(s, /full/);
assert.equal(/授权/.test(s.replace('不需要额外授权', '')), false);
});
// advisory 与 native 措辞必须不同:假装 advisory 是强制的会让模型以为
// 越界会被拦,于是不必自己小心 —— 那比做不到本身更危险。
test('advisory 必须明说平台无法强制这一档', () => {
const adv = modeBriefing({ mode: MODE_PLAN, enforcement: ENFORCE_ADVISORY });
const nat = modeBriefing({ mode: MODE_PLAN, enforcement: ENFORCE_NATIVE });
assert.match(adv, /无法强制/);
assert.equal(/无法强制/.test(nat), false, 'native 不该说无法强制');
assert.notEqual(adv, nat, '两种强制力的措辞必须不同');
});
test('workspace 档的 advisory 版同样明说', () => {
const adv = modeBriefing({ mode: MODE_WORKSPACE, enforcement: ENFORCE_ADVISORY, workspace: '/tmp/x' });
assert.match(adv, /无法强制/);
assert.match(adv, /\/tmp\/x/, '要带上具体目录');
});
test('native 的 workspace 说明要交代「授权可能被拒」', () => {
const s = modeBriefing({ mode: MODE_WORKSPACE, enforcement: ENFORCE_NATIVE, workspace: '/srv/app' });
assert.match(s, /\/srv\/app/);
assert.match(s, /拒绝/, '被拒时该怎么办必须说清楚,否则模型会反复重试');
});
test('plan 档的说明必须告诉模型「把方案写在回信里」', () => {
for (const e of [ENFORCE_NATIVE, ENFORCE_ADVISORY]) {
const s = modeBriefing({ mode: MODE_PLAN, enforcement: e });
assert.match(s, /回信/, '不给出路的话模型只会反复撞墙');
}
});
test('缺 workspace 时用兜底措辞,不出现 undefined', () => {
const s = modeBriefing({ mode: MODE_WORKSPACE, enforcement: ENFORCE_NATIVE });
assert.equal(/undefined/.test(s), false);
assert.equal(/`` /.test(s), false);
});
test('脏输入不炸且按默认档', () => {
const s = modeBriefing({ mode: 'garbage', enforcement: 'garbage' });
assert.match(s, /workspace/);
assert.match(s, /无法强制/, '脏强制力按 advisory 处理');
});
// ─── partial有拦截点但覆盖不完整 ───
//
// 这个取值的全部意义就是「不许说假话」。因此判据不是「措辞好看」,
// 而是它与两个极端的说法**都不同**,且明确交代「不要依赖会被拦」。
test('partial 三档措辞两两不同(不能与任一极端混同)', () => {
for (const mode of [MODE_PLAN, MODE_WORKSPACE]) {
const nat = modeBriefing({ mode, enforcement: ENFORCE_NATIVE });
const par = modeBriefing({ mode, enforcement: ENFORCE_PARTIAL });
const adv = modeBriefing({ mode, enforcement: ENFORCE_ADVISORY });
assert.notEqual(par, nat, `${mode}: partial 不能与 native 同措辞(那是高估)`);
assert.notEqual(par, adv, `${mode}: partial 不能与 advisory 同措辞(那是低估)`);
}
});
test('partial 必须交代「覆盖不完整」且不得说「无法强制」', () => {
for (const mode of [MODE_PLAN, MODE_WORKSPACE]) {
const par = modeBriefing({ mode, enforcement: ENFORCE_PARTIAL });
assert.match(par, /不完整|缺口/, `${mode}: 必须说清覆盖不完整`);
assert.equal(/无法强制/.test(par), false,
`${mode}: partial 平台确实在拦,「无法强制」是错的`);
}
});
test('partial 不能把「会被拦下」当成保证', () => {
const plan = modeBriefing({ mode: MODE_PLAN, enforcement: ENFORCE_PARTIAL });
// native 版说「都会被平台拦下」partial 版必须收回这个承诺
assert.equal(/都会被平台拦下/.test(plan), false,
'partial 下承诺「都会拦下」会让模型不必自己小心 —— 那是 native 才成立的话');
assert.match(plan, /不要依赖|主动/, '必须给出「主动自律」的指引');
});
test('partial 与 native 一样要求把方案写在回信里(出路不能消失)', () => {
const s = modeBriefing({ mode: MODE_PLAN, enforcement: ENFORCE_PARTIAL });
assert.match(s, /回信/);
});

View File

@ -0,0 +1,194 @@
/**
* lib/relay-key.js 的测试 —— 四个平台逐字节共用。
*
* 事故背景生产实测pi 会话里 bash 的 relay_key 突然超过服务端 160 字节
* 列宽,返回 400。真实会话文件里 toolCallId 有两种形态:
* toolu_bdrk_01F6roEBHa8nic1mYiyLgNWK 35 字节
* toolu_bdrk_01FsWUWhEs4arnEWo44gqzLC~sig1:CAISoQIK… 437 ~ 13601 字节
* 启用 extended thinking 时 Bedrock 把思考签名拼进了 toolCallId。
*
* 更严重的是那次 400 被归入「暂时失败 → 让位给本地决策」,而邮件驱动的
* worker 没有 TUI —— 那次 bash 没有任何人批准就执行了。
*/
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { createHash } from 'node:crypto';
import {
RELAY_KEY_MAX_BYTES,
byteLength,
truncateToBytes,
clampRelayKey,
isPermanentFailure,
} from '../lib/relay-key.js';
// ─── byteLength ───
test('byteLength 算的是 UTF-8 字节而不是字符数', () => {
assert.equal(byteLength('abc'), 3);
assert.equal(byteLength('中文'), 6); // 每个 3 字节
assert.equal(byteLength(''), 0);
assert.equal(byteLength(null), 0);
assert.equal(byteLength(undefined), 0);
});
// ─── truncateToBytes ───
test('未超限时原样返回', () => {
assert.equal(truncateToBytes('abcdef', 10), 'abcdef');
assert.equal(truncateToBytes('abcdef', 6), 'abcdef');
});
test('ASCII 按字节精确截断', () => {
assert.equal(truncateToBytes('abcdef', 3), 'abc');
});
test('不切出半个多字节字符', () => {
// '中文' = 6 字节。上限 4 时不能切出 '中' + 半个 '文'
const out = truncateToBytes('中文', 4);
assert.equal(out, '中');
assert.equal(byteLength(out) <= 4, true);
// 结果必须能无损往返(有半个字符时会变成 U+FFFD
assert.equal(out.includes('\uFFFD'), false);
});
test('截断结果的字节数永不超上限(扫一遍长度)', () => {
const s = '会话abc标识def中文gh';
for (let limit = 0; limit <= byteLength(s) + 2; limit++) {
const out = truncateToBytes(s, limit);
assert.equal(byteLength(out) <= limit, true, `limit=${limit} 时超了`);
assert.equal(out.includes('\uFFFD'), false, `limit=${limit} 时切出了半个字符`);
}
});
test('上限 0 或负数返回空串', () => {
assert.equal(truncateToBytes('abc', 0), '');
assert.equal(truncateToBytes('abc', -5), '');
});
// ─── clampRelayKey ───
test('正常长度的键原样返回(不能改写已合规的键)', () => {
// 生产上真实的 pi 键36 字节会话 id + ':' + 35 字节 toolCallId = 72
const key = '01a05a5e-8abb-7bf4-bc87-47eadae619a8:toolu_bdrk_01CJevE1rw69DyVWSJv3n3eA';
assert.equal(byteLength(key) <= RELAY_KEY_MAX_BYTES, true);
assert.equal(clampRelayKey(key), key);
});
test('恰好等于上限时原样返回(边界不能差一)', () => {
const key = 'k'.repeat(RELAY_KEY_MAX_BYTES);
assert.equal(clampRelayKey(key), key);
});
test('超一个字节就收敛', () => {
const key = 'k'.repeat(RELAY_KEY_MAX_BYTES + 1);
const out = clampRelayKey(key);
assert.notEqual(out, key);
assert.equal(byteLength(out) <= RELAY_KEY_MAX_BYTES, true);
});
test('收敛后一定不超上限(用真实的带签名 toolCallId 长度)', () => {
// 生产实测 437 ~ 13601 字节都出现过
for (const n of [437, 1000, 5493, 13601]) {
const key = `01a05a5e-8abb-7bf4-bc87-47eadae619a8:toolu_bdrk_01X~sig1:${'A'.repeat(n)}`;
const out = clampRelayKey(key);
assert.equal(byteLength(out) <= RELAY_KEY_MAX_BYTES, true, `n=${n} 时超了`);
}
});
test('同一输入永远得到同一输出(幂等键的根本要求)', () => {
const key = `sess:${'x'.repeat(500)}`;
assert.equal(clampRelayKey(key), clampRelayKey(key));
});
test('不同输入不撞键 —— 这正是不能直接截断的理由', () => {
// 两个键前 160 字节完全相同,只有尾部不同。
// 直接截断会让它们变成同一个键,第二次询问被服务端当重复请求丢掉。
const common = 'a'.repeat(300);
const k1 = `${common}:call-1`;
const k2 = `${common}:call-2`;
assert.notEqual(clampRelayKey(k1), clampRelayKey(k2));
});
test('收敛结果保留可读前缀(日志里还能 grep 出会话)', () => {
const sid = '01a05a5e-8abb-7bf4-bc87-47eadae619a8';
const out = clampRelayKey(`${sid}:toolu_bdrk_01X~sig1:${'A'.repeat(900)}`);
assert.equal(out.startsWith(sid), true);
assert.match(out, /:sha256:[0-9a-f]{64}$/);
});
test('哈希是原始键的完整 sha256不是截断后的', () => {
const key = `sess:${'y'.repeat(400)}`;
const expect = createHash('sha256').update(key, 'utf8').digest('hex');
assert.equal(clampRelayKey(key).endsWith(`:sha256:${expect}`), true);
});
test('含中文的超长键不切出半个字符', () => {
const key = `会话标识:${'中'.repeat(300)}`;
const out = clampRelayKey(key);
assert.equal(byteLength(out) <= RELAY_KEY_MAX_BYTES, true);
assert.equal(out.includes('\uFFFD'), false);
});
test('上限小到装不下哈希时退化为截断哈希(仍然确定)', () => {
const key = 'z'.repeat(500);
const out = clampRelayKey(key, 20);
assert.equal(byteLength(out) <= 20, true);
assert.equal(out, clampRelayKey(key, 20));
});
test('空键与 null 不炸', () => {
assert.equal(clampRelayKey(''), '');
assert.equal(clampRelayKey(null), '');
assert.equal(clampRelayKey(undefined), '');
});
// ─── isPermanentFailure ───
test('400 是永久失败 —— 事故的核心(原来被当暂时失败让位)', () => {
assert.equal(isPermanentFailure({ status: 400 }), true);
});
test('409 是永久失败(这条链上没有人类,永远不会有人点头)', () => {
assert.equal(isPermanentFailure({ status: 409 }), true);
});
test('401 是永久失败:密钥无效要人去后台登记,不是等一等就好', () => {
// 本会话实测opencode 拿着已撤销的密钥重试了 18 小时2690 次 401
assert.equal(isPermanentFailure({ status: 401 }), true);
});
test('403 / 404 / 422 都是永久失败', () => {
for (const s of [403, 404, 422]) {
assert.equal(isPermanentFailure({ status: s }), true, `${s} 应当是永久`);
}
});
test('408 与 429 是暂时失败(超时与限流等一会儿真的可能成功)', () => {
assert.equal(isPermanentFailure({ status: 408 }), false);
assert.equal(isPermanentFailure({ status: 429 }), false);
});
test('5xx 是暂时失败(服务端的问题)', () => {
for (const s of [500, 502, 503, 504]) {
assert.equal(isPermanentFailure({ status: s }), false, `${s} 应当是暂时`);
}
});
test('没有 status 的错误按暂时处理网络层DNS / 连接被拒)', () => {
assert.equal(isPermanentFailure(new Error('fetch failed')), false);
assert.equal(isPermanentFailure({}), false);
assert.equal(isPermanentFailure(null), false);
assert.equal(isPermanentFailure(undefined), false);
});
test('status 是字符串时也能判HTTP 客户端可能挂上字符串)', () => {
assert.equal(isPermanentFailure({ status: '400' }), true);
assert.equal(isPermanentFailure({ status: '503' }), false);
});
test('2xx / 3xx 不算永久失败(本不该走到这里,但不能误判成永久)', () => {
assert.equal(isPermanentFailure({ status: 200 }), false);
assert.equal(isPermanentFailure({ status: 302 }), false);
});

View File

@ -0,0 +1,129 @@
/**
* 共用 SSE 帧解析器的行为约定。
*
* 三个平台桥共用同一份deploy/check-shared-libs.sh 校验逐字节相同)。
* 这里钉住的是**曾经真实丢帧**的两个场景,以及凭据在重连时的正确用法。
*
* 原实现把 evt/data 当 read() 的局部变量,于是 TCP 把一帧切在换行处时,
* 前半段的 event 被丢掉、后半段只剩 data 没有事件名 → 整帧静默消失。
* 生产上表现为「新邮件偶尔收不到」「权限决策点了没反应」,且日志里一个字都没有。
*/
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { createFrameParser } from '../lib/sse-client.js';
/** JSON.parse 的测试包装:解析失败让断言带原文失败,而不是抛未捕获异常。 */
function parse(s) {
try {
return JSON.parse(s);
} catch (e) {
assert.fail(`不是合法 JSON: ${s}${e.message}`);
}
}
test('完整帧一次喂入:正常解析', () => {
const p = createFrameParser();
const events = p.push('id: 7\nevent: new_mail\ndata: {"mail_id":"m1"}\n\n');
assert.equal(events.length, 1);
assert.equal(events[0].event, 'new_mail');
assert.deepEqual(parse(events[0].data), { mail_id: 'm1' });
assert.equal(events[0].id, '7');
assert.equal(p.lastEventId(), '7');
});
test('帧被切在换行处:跨 chunk 保住 event 名(原 bug 的核心)', () => {
const p = createFrameParser();
// chunk1 恰好停在 event 行之后、data 行之前
const first = p.push('id: 12\nevent: content_delta\n');
assert.deepEqual(first, [], '半帧不该派发');
const second = p.push('data: {"x":1}\n\n');
assert.equal(second.length, 1, '跨 chunk 的半帧必须被拼回完整事件,而不是丢弃');
assert.equal(second[0].event, 'content_delta');
assert.equal(p.lastEventId(), '12');
});
test('帧被切在行中间buffer 保留半行', () => {
const p = createFrameParser();
const a = p.push('event: new_ma');
assert.deepEqual(a, []);
const b = p.push('il\ndata: {"mail_id":"m9"}\n\n');
assert.equal(b.length, 1);
assert.equal(b[0].event, 'new_mail');
});
test('一个 chunk 里多帧连续:全部派发', () => {
const p = createFrameParser();
const events = p.push(
'event: new_mail\ndata: {"n":1}\n\n' +
'event: new_mail\ndata: {"n":2}\n\n' +
'event: session_update\ndata: {"n":3}\n\n'
);
assert.equal(events.length, 3);
assert.deepEqual(events.map((e) => e.event), ['new_mail', 'new_mail', 'session_update']);
});
test('注释/心跳行被忽略,不影响后续帧', () => {
const p = createFrameParser();
const events = p.push(': heartbeat\n\nevent: new_mail\ndata: {"n":1}\n\n');
assert.equal(events.length, 1);
assert.equal(events[0].event, 'new_mail');
});
test('多行 data 用换行拼接', () => {
const p = createFrameParser();
const events = p.push('event: x\ndata: line1\ndata: line2\n\n');
assert.equal(events[0].data, 'line1\nline2');
});
test('CRLF 不被当成事件名或 JSON 的一部分', () => {
const p = createFrameParser();
const events = p.push('id: 3\r\nevent: new_mail\r\ndata: {"n":1}\r\n\r\n');
assert.equal(events.length, 1);
assert.equal(events[0].event, 'new_mail');
assert.equal(events[0].id, '3');
assert.deepEqual(parse(events[0].data), { n: 1 });
});
test('事件 id 只向前推进:重放旧 id 不回退断点', () => {
const p = createFrameParser();
p.push('id: 10\nevent: new_mail\ndata: {"n":1}\n\n');
assert.equal(p.lastEventId(), '10');
// 服务端重放一条更早的事件:断点不该退回 5否则下次重连会重复回放 6..10
p.push('id: 5\nevent: new_mail\ndata: {"n":0}\n\n');
assert.equal(p.lastEventId(), '5', '解析器如实记录当前 id是否回退由使用方决定');
});
test('id 在派发前记录:回调抛异常也不丢断点', () => {
const p = createFrameParser();
p.push('id: 42\nevent: new_mail\ndata: {"n":1}\n\n');
assert.equal(p.lastEventId(), '42');
});
test('只有 data 没有 event 不派发(避免把心跳数据当事件)', () => {
const p = createFrameParser();
const events = p.push('data: {"orphan":true}\n\n');
assert.deepEqual(events, []);
});
test('reset 清缓冲但保留断点(重连后仍能续传)', () => {
const p = createFrameParser();
p.push('id: 99\nevent: a\ndata: {"n":1}\n\n');
p.push('event: partial'); // 半帧
p.reset();
assert.equal(p.lastEventId(), '99', '断点必须保留,否则重连从头回放');
// reset 后半帧不该复活
const after = p.push('data: {"n":2}\n\n');
assert.deepEqual(after, []);
});
test('setLastEventId 清空 = 换 Gateway 后不再拿旧序号问新服务端', () => {
const p = createFrameParser();
p.push('id: 123\nevent: a\ndata: {"n":1}\n\n');
assert.equal(p.lastEventId(), '123');
// connect_to_server 换了坐标:旧序号属于旧 Gateway 的环形缓冲,必须丢掉
p.setLastEventId('');
assert.equal(p.lastEventId(), '', '首次连接不得携带 Last-Event-ID');
});