/** * 能力可见性用例(任务 A3.5)。 * * 验收:不同身份看到的工具集不同,切换身份重建会话。 * 另外锁住两条容易漏的边界:能力清单与引擎工具集必须同源;白名单外的能力对模型也不可见。 */ import assert from 'node:assert/strict'; import { test } from 'node:test'; import { createFakeRuntime } from '@aps/server/agent/fake-runtime'; const A = { user: 'user-a', tenant: 't-1' }; const B = { user: 'user-b', tenant: 't-1' }; /** 记录每次 createSession 收到的工具集,用来断言「引擎看到的工具」而不是只看 HTTP 清单 */ function recordingRuntime() { const calls = []; const runtime = createFakeRuntime({ name: 'recording', turns: [{ text: ['收到。'] }] }); const original = runtime.createSession.bind(runtime); runtime.createSession = async (input) => { calls.push({ userId: input.subject.userId, tools: input.tools.map((tool) => tool.capabilityId).sort() }); return original(input); }; return { runtime, calls }; } async function startPlatform(visibility, runtime) { process.env.APS_DEV_PROVIDER = '1'; const { startServer } = await import('../server/dist/index.js'); return startServer({ host: '127.0.0.1', port: 0, version: 'test', databasePath: ':memory:', engine: 'fake', ...(runtime === undefined ? {} : { runtime }), capabilityVisibility: visibility, }); } const headersFor = (who) => ({ 'content-type': 'application/json', 'x-aps-user': who.user, 'x-aps-tenant': who.tenant }); async function createSession(server, who) { const response = await fetch(`${server.url}/v1/sessions`, { method: 'POST', headers: headersFor(who), body: JSON.stringify({ scope: 'project', projectId: 'p-default', title: `${who.user} 的会话` }), }); assert.equal(response.status, 200); return (await response.json()).session.sessionId; } const capabilitiesOf = async (server, who) => { const response = await fetch(`${server.url}/v1/capabilities`, { headers: headersFor(who) }); assert.equal(response.status, 200); return (await response.json()).capabilities.map((item) => item.id).sort(); }; test('白名单决定可见能力:清单与引擎工具集同源', async () => { const { runtime, calls } = recordingRuntime(); const server = await startPlatform( { defaultAllow: false, denyWrite: false, grants: [ { userId: A.user, allow: ['fake.echo'] }, { userId: B.user, allow: ['fake.write'] }, ], }, runtime, ); try { assert.deepEqual(await capabilitiesOf(server, A), ['fake.echo']); assert.deepEqual(await capabilitiesOf(server, B), ['fake.write']); // 各发一轮,看引擎实际拿到的工具 for (const who of [A, B]) { const sessionId = await createSession(server, who); const response = await fetch(`${server.url}/v1/sessions/${sessionId}/messages`, { method: 'POST', headers: headersFor(who), body: JSON.stringify({ text: '你好', idempotencyKey: `idem-${who.user}-0001` }), }); assert.equal(response.status, 202); } await new Promise((resolve) => setTimeout(resolve, 300)); const forA = calls.find((call) => call.userId === A.user); const forB = calls.find((call) => call.userId === B.user); assert.deepEqual(forA?.tools, ['fake.echo'], '引擎给 A 的工具应与 A 的能力清单一致'); assert.deepEqual(forB?.tools, ['fake.write'], '引擎给 B 的工具应与 B 的能力清单一致'); assert.notDeepEqual(forA?.tools, forB?.tools, '不同身份的工具集必须不同'); } finally { await server.close(); delete process.env.APS_DEV_PROVIDER; } }); test('别人的会话不能访问,更不能复用它的运行时会话', async () => { const { runtime, calls } = recordingRuntime(); const server = await startPlatform({ defaultAllow: true, denyWrite: false, grants: [] }, runtime); try { const sessionId = await createSession(server, A); const read = await fetch(`${server.url}/v1/sessions/${sessionId}`, { headers: headersFor(B) }); assert.equal(read.status, 403, 'B 读 A 的会话应被拒'); assert.equal((await read.json()).error.code, 'forbidden'); const send = await fetch(`${server.url}/v1/sessions/${sessionId}/messages`, { method: 'POST', headers: headersFor(B), body: JSON.stringify({ text: '插一句', idempotencyKey: 'idem-b-0001' }), }); assert.equal(send.status, 403, 'B 不能在 A 的会话里发消息'); assert.equal(calls.length, 0, '被拒的请求不应创建任何运行时会话(工具集不能跨身份复用)'); const caps = await fetch(`${server.url}/v1/capabilities?sessionId=${sessionId}`, { headers: headersFor(B) }); assert.equal(caps.status, 403, '按会话取能力清单也要校验归属'); } finally { await server.close(); delete process.env.APS_DEV_PROVIDER; } }); test('可见能力变化后重建运行时会话(工具集不会被沿用)', async () => { const { runtime, calls } = recordingRuntime(); const server = await startPlatform({ defaultAllow: true, denyWrite: false, grants: [] }, runtime); try { const sessionId = await createSession(server, A); const send = () => fetch(`${server.url}/v1/sessions/${sessionId}/messages`, { method: 'POST', headers: headersFor(A), body: JSON.stringify({ text: '你好', idempotencyKey: `idem-${Math.random().toString(36).slice(2)}` }), }); assert.equal((await send()).status, 202); await new Promise((resolve) => setTimeout(resolve, 200)); assert.equal(calls.length, 1, '第一次调用创建运行时会话'); // 注册一个新 Provider:可见工具集变了 const { createFakeProvider } = await import('@aps/provider-sdk/testing'); await server.service.registerProvider(createFakeProvider('extra')); assert.equal((await send()).status, 202); await new Promise((resolve) => setTimeout(resolve, 200)); assert.equal(calls.length, 2, '工具集变化后必须重建运行时会话'); assert.ok(calls[1].tools.length > calls[0].tools.length, `第二次应拿到更多工具:${calls[0].tools.length} → ${calls[1].tools.length}`); } finally { await server.close(); delete process.env.APS_DEV_PROVIDER; } }); test('只读模式屏蔽写能力;白名单里不存在的能力不会出现', async () => { const server = await startPlatform( { defaultAllow: true, denyWrite: true, grants: [{ userId: A.user, allow: ['fake.nonexistent'] }], }, createFakeRuntime({ name: 'noop' }), ); try { const visible = await capabilitiesOf(server, A); assert.ok(!visible.includes('fake.write'), `只读模式不应出现写能力:${visible.join(',')}`); assert.ok(!visible.includes('fake.nonexistent'), '未登记的能力不该被授权表凭空造出来'); assert.ok(visible.includes('fake.echo'), '读能力应保留'); } finally { await server.close(); delete process.env.APS_DEV_PROVIDER; } });