/** * 授权往返(lib/approval.mjs)的测试。 * * 这是全项目最该被测死的一块:它决定「什么算同意」。所以每一组都配了 * **反向对照** —— 不是只验「同意时放行了」,而要同时验「别的任何东西都不放行」。 * * 时序靠注入的假 SSE 控制:真网关的 SSE 是扇出的,假实现只需要保留 * `onEvent` 回调并在合适的时候投喂事件,就能精确复现「先建连、再发请求、 * 决策在请求之后到达」以及几个边界(决策在请求之前就到了 / 一直没到 / * 来的是别人的决策)。 */ import { test } from 'node:test'; import assert from 'node:assert/strict'; import { requestApproval, tierOf, hasLocalUi } from '../lib/approval.mjs'; import { createGrantStore } from '../lib/permission-grants.js'; /** 可控的假 SSE:把 onEvent 抓住,测试自己决定何时投喂什么。 */ function makeSSE() { const s = { onEvent: null, connected: false, stopped: false }; const factory = ({ onEvent }) => { s.onEvent = onEvent; // 真实现在建连后立刻下发 connected;这里用 microtask 复现「不等它也能跑」。 queueMicrotask(() => { s.connected = true; onEvent('connected', {}); }); return { stop: () => { s.stopped = true; } }; }; return { factory, s }; } /** 记录请求体;可选在请求成功后投喂一条决定。 * `onRequest` 的**返回值会被当作 HTTP 响应体**返回给被测代码 —— * 这一点至关重要:网关的幂等命中是一个 200 + `{status:"duplicate_relay"}`, * 判据就看它。之前这里硬编码 `return {}`,把响应体丢了,于是「重复请求」 * 那条测试变成干等到超时,而失败信息看起来像被测代码的 bug。 */ function makeClient({ onRequest } = {}) { const state = { requests: [] }; const client = { baseURL: 'http://gw.test', authHeaders: () => ({ 'X-Agent-Secret': 's' }), async post(path, body) { state.requests.push({ path, body }); if (onRequest) { const res = await onRequest(state, body); return res === undefined ? {} : res; } return {}; } }; return { client, state }; } const base = env => ({ toolName: 'run_command', question: '要执行一条命令', context: 'echo hi', sessionId: 'sess-1', log: () => {}, env, ...env }); test('★ plan 档直接拒绝执行类工具,且根本不发请求', async () => { const { factory } = makeSSE(); const { client, state } = makeClient(); const r = await requestApproval({ ...base({ tier: 'plan', createSSE: factory }) }); assert.equal(r.allowed, false); assert.equal(r.via, 'tier'); assert.match(r.reason, /plan 档/); // 反向对照:不该在「注定拒绝」的档位上去打扰人。 assert.equal(state.requests.length, 0, 'plan 档不该发出授权请求'); }); test('★ full 档直接放行', async () => { const { client } = makeClient(); const r = await requestApproval({ toolName: 'run_command', tier: 'full', sessionId: 's1' }); assert.equal(r.allowed, true); assert.equal(r.via, 'tier'); }); test('★ 「一直同意」命中时不发请求(钩子与工具共用同一张表)', async () => { const grants = createGrantStore(); grants.grant('sess-1', 'run_command', '一直同意'); const { client, state } = makeClient(); const r = await requestApproval({ ...base({}), client, tier: 'workspace', grants }); assert.equal(r.allowed, true); assert.equal(r.via, 'grant'); assert.equal(state.requests.length, 0); }); test('★ 人同意 → 放行,且请求里带上了 relay_key 与选项', async () => { const { factory, s } = makeSSE(); const { client, state } = makeClient({ onRequest: async st => { // 真网关是「先受理、后有人决策」,所以决策必须晚于请求。 queueMicrotask(() => s.onEvent('permission_decision', { relay_key: st.requests[0].body.relay_key, decision: '同意', decided_by: 'gui-lab' })); } }); const r = await requestApproval({ ...base({}), client, tier: 'workspace', createSSE: factory, waitMs: 1000 }); assert.equal(r.allowed, true); assert.equal(r.via, 'human'); assert.equal(r.decidedBy, 'gui-lab'); const sent = state.requests[0].body; assert.equal(sent.session_id, 'sess-1'); assert.ok(sent.relay_key, '请求必须带 relay_key(决定回执怎么配对)'); assert.deepEqual(sent.options, ['同意', '一直同意', '拒绝']); assert.equal(s.stopped, true, 'SSE 必须被关掉(否则短命进程不退出)'); }); test('★ 「一直同意」放行并落进授权表;「同意」不落', async () => { for (const [decision, shouldPersist] of [ ['一直同意', true], ['同意', false] ]) { const { factory, s } = makeSSE(); const grants = createGrantStore(); const { client } = makeClient({ onRequest: async st => { queueMicrotask(() => s.onEvent('permission_decision', { relay_key: st.requests[0].body.relay_key, decision })); } }); const r = await requestApproval({ ...base({}), client, tier: 'workspace', grants, createSSE: factory, waitMs: 1000 }); assert.equal(r.allowed, true, decision); assert.equal( grants.isGranted('sess-1', 'run_command'), shouldPersist, `${decision} 的落表行为不对` ); } }); test('★ 人拒绝 → 不放行,且原因里带上决策人', async () => { const { factory, s } = makeSSE(); const { client } = makeClient({ onRequest: async st => { queueMicrotask(() => s.onEvent('permission_decision', { relay_key: st.requests[0].body.relay_key, decision: '拒绝', decided_by: 'gui-lab', note: '这条命令会删数据' }) ); } }); const r = await requestApproval({ ...base({}), client, tier: 'workspace', createSSE: factory, waitMs: 1000 }); assert.equal(r.allowed, false); assert.equal(r.via, 'human'); assert.match(r.reason, /拒绝/); assert.match(r.reason, /gui-lab/); assert.match(r.reason, /会删数据/); }); test('★ 反向对照:一切「不是明确同意」的文本都不放行', async () => { // 判据是「在放行白名单里」,不是「不等于拒绝」。所以拒绝、看不懂的东西、 // 平台自己的 shutdown 哨兵、空串都不能放行。 // // 注意白名单本身是共用库的前缀匹配(`^同意|一直同意|allow|approve|always|yes`, // 四个桥共用同一份)。所以「不同意」不放行(前缀不是同意),而「同意吧」放行 —— // 后者是刻意接受的:决策文本来自界面按钮,前缀匹配是为了容错,不是为了放宽。 // 这里把两类都钉住,避免哪天有人把前缀匹配改成 includes 而无人发现 // (那会让「我不同意」变成同意)。 for (const decision of ['', 'maybe', 'ok?', 'shutdown', 'deny', '拒绝', '不同意', '否', 'no', undefined, null]) { const { factory, s } = makeSSE(); const { client } = makeClient({ onRequest: async st => { queueMicrotask(() => s.onEvent('permission_decision', { relay_key: st.requests[0].body.relay_key, decision })); } }); const r = await requestApproval({ ...base({}), client, tier: 'workspace', createSSE: factory, waitMs: 300 }); assert.equal(r.allowed, false, `decision=${JSON.stringify(decision)} 不该放行`); } // 反向对照的对照:确实在白名单里的必须放行,否则上面全绿可能只是因为门槛坏死了。 for (const decision of ['同意', '一直同意', 'allow', 'yes']) { const { factory, s } = makeSSE(); const { client } = makeClient({ onRequest: async st => { queueMicrotask(() => s.onEvent('permission_decision', { relay_key: st.requests[0].body.relay_key, decision })); } }); const r = await requestApproval({ ...base({}), client, tier: 'workspace', createSSE: factory, waitMs: 300 }); assert.equal(r.allowed, true, `decision=${JSON.stringify(decision)} 应当放行`); } }); test('★ 超时 → 拒绝(不能靠「没消息就是好消息」)', async () => { const { factory } = makeSSE(); const { client } = makeClient(); // 从不投喂决策 const t0 = Date.now(); const r = await requestApproval({ ...base({}), client, tier: 'workspace', createSSE: factory, waitMs: 120 }); assert.equal(r.allowed, false); assert.equal(r.via, 'timeout'); assert.match(r.reason, /超时/); assert.ok(Date.now() - t0 >= 100, '必须真的等过,而不是立刻返回'); }); test('★ 别人的决策不能拿来用(relay_key 配对)', async () => { // 同一个 Agent 可能同时有多个调用在等(模型并行发起两个动作)。 // 若不按 relay_key 过滤,B 的同意会放行 A。 const { factory, s } = makeSSE(); const { client } = makeClient({ onRequest: async st => { const mine = st.requests[0].body.relay_key; queueMicrotask(() => { s.onEvent('permission_decision', { relay_key: `${mine}-other`, decision: '同意' }); setTimeout(() => s.onEvent('permission_decision', { relay_key: mine, decision: '拒绝' }), 30); }); } }); const r = await requestApproval({ ...base({}), client, tier: 'workspace', createSSE: factory, waitMs: 1000 }); assert.equal(r.allowed, false, '拿到别人的「同意」就是越权放行'); assert.match(r.reason, /拒绝/); }); test('★ 永久失败(409 无人可问)当场拒绝,并把服务端建议带给模型', async () => { const { factory } = makeSSE(); const err = Object.assign(new Error('409'), { status: 409, body: { error: '本线索内找不到可决策的人类', suggestion: '请让发件人把档位改成 full' } }); const { client } = makeClient({ onRequest: async () => { throw err; } }); const r = await requestApproval({ ...base({}), client, tier: 'workspace', createSSE: factory, waitMs: 1000 }); assert.equal(r.allowed, false); assert.equal(r.via, 'permanent-failure'); assert.match(r.reason, /找不到可决策的人类/); assert.match(r.reason, /改成 full/, '服务端的建议必须原样带给模型,否则它只能盲试'); }); test('★ 暂时失败:没有本地界面时必须拒绝(fail closed)', async () => { const { factory } = makeSSE(); const { client } = makeClient({ onRequest: async () => { throw new Error('ECONNREFUSED'); } }); const r = await requestApproval({ ...base({}), client, tier: 'workspace', createSSE: factory, waitMs: 1000 }); assert.equal(r.allowed, false); assert.equal(r.via, 'transport'); assert.match(r.reason, /没有本地界面/); }); test('★ 暂时失败:有本地界面时明确说明没有放行', async () => { // 桌面模式下平台自己还有流程,所以这里不放行是安全的 —— 但**不能说**放行了。 const { factory } = makeSSE(); const { client } = makeClient({ onRequest: async () => { throw new Error('ECONNREFUSED'); } }); const r = await requestApproval({ toolName: 'Bash', tier: 'workspace', client, createSSE: factory, waitMs: 1000 }); assert.equal(r.allowed, false); assert.match(r.reason, /授权询问失败/); }); test('★ 「有没有本地界面」由调用方传的 sessionId 判定(单一事实来源)', async () => { // 反向对照:同一个暂时失败,在「有会话」与「没会话」下必须给出不同的拒绝理由。 // 这里刻意把 process.env.AGENTMAIL_SESSION_ID 设成反的,验证模块**不看它** —— // 两个事实来源不一致时,谁也说不清到底算有界面还是没界面。 const prev = process.env.AGENTMAIL_SESSION_ID; process.env.AGENTMAIL_SESSION_ID = '来自进程环境的干扰值'; try { const mk = () => { const { factory } = makeSSE(); const { client } = makeClient({ onRequest: async () => { throw new Error('boom'); } }); return { factory, client }; }; const a = mk(); const withSession = await requestApproval({ ...base({}), client: a.client, tier: 'workspace', createSSE: a.factory, waitMs: 500 }); assert.match(withSession.reason, /没有本地界面/, '带会话 = 邮件驱动,必须 fail closed'); const b = mk(); const noSession = await requestApproval({ toolName: 'Bash', tier: 'workspace', client: b.client, createSSE: b.factory, waitMs: 500 }); assert.doesNotMatch(noSession.reason, /没有本地界面/, '不带会话 = 有界面,不该说成没界面'); } finally { if (prev === undefined) delete process.env.AGENTMAIL_SESSION_ID; else process.env.AGENTMAIL_SESSION_ID = prev; } }); test('tierOf / hasLocalUi 的判据', () => { assert.equal(tierOf({}), 'workspace'); assert.equal(tierOf({ AGENTMAIL_PERMISSION_MODE: 'full' }), 'full'); // 认不出来的值 → workspace(共用库的约定),不是「免问」 assert.equal(tierOf({ AGENTMAIL_PERMISSION_MODE: 'FULL' }), 'workspace'); // 有会话 id = 邮件驱动 = 没有本地界面 assert.equal(hasLocalUi({}), true); assert.equal(hasLocalUi({ AGENTMAIL_SESSION_ID: 'sess-1' }), false); assert.equal(hasLocalUi({ AGENTMAIL_SESSION_ID: ' ' }), true, '空白串不算会话'); }); // ─── 幂等键必须按「这一次调用」唯一 ───────────────────────────────────── // 一个实测缺陷,失败方式极隐蔽:键取成「会话+工具」之后,同一会话里**第二次** // run_command 被网关判成重复请求 → HTTP 200 duplicate_relay → 请求**没发出去**、 // 永远没人来决策 → 工具干等到被 MCP 调用超时砍掉 → 模型回报「30 秒内未获批准」。 // 从状态码到措辞全都看不出问题,归因还完全错了(像是人没理它)。 test('★ 同一会话同一工具的两次调用必须用不同的幂等键', async () => { const keys = []; for (let i = 0; i < 2; i++) { const { factory, s } = makeSSE(); const { client } = makeClient({ onRequest: async st => { keys.push(st.requests[0].body.relay_key); queueMicrotask(() => s.onEvent('permission_decision', { relay_key: st.requests[0].body.relay_key, decision: '同意' })); } }); const r = await requestApproval({ ...base({}), client, tier: 'workspace', createSSE: factory, waitMs: 500 }); assert.equal(r.allowed, true); } assert.equal(keys.length, 2); assert.notEqual(keys[0], keys[1], '两次调用的键相同 ⇒ 第二次会被网关当重复丢弃'); // 键里仍保留会话与工具,便于事后从邮件反查(但唯一性来自随机尾) assert.match(keys[0], /sess-1/); assert.match(keys[0], /run_command/); }); test('★ 网关判为重复请求时当场拒绝(不能干等到被超时砍掉)', async () => { const { factory } = makeSSE(); const { client } = makeClient({ onRequest: async () => ({ status: 'duplicate_relay', detail: '该权限询问已转发过,本次调用未产生新邮件' }) }); const t0 = Date.now(); const r = await requestApproval({ ...base({}), client, tier: 'workspace', createSSE: factory, waitMs: 60000 }); const dt = Date.now() - t0; assert.equal(r.allowed, false); assert.equal(r.via, 'duplicate-relay'); assert.match(r.reason, /重复/); assert.match(r.reason, /没有人会看到这次询问/); assert.ok(dt < 5000, `必须立刻返回,实际等了 ${dt}ms(说明它在干等一个永远不会来的决策)`); }); test('★ 反向对照:正常的 200(非 duplicate_relay)仍要等决策', async () => { // 否则上面那条可能只是因为「任何 200 都被当成重复」。 const { factory } = makeSSE(); const { client } = makeClient({ onRequest: async () => ({ status: 'pending' }) }); const r = await requestApproval({ ...base({}), client, tier: 'workspace', createSSE: factory, waitMs: 150 }); assert.equal(r.via, 'timeout', '非重复的正常请求应该等,然后超时'); });