import { expect, test } from '@playwright/test'; import { Client } from 'pg'; test.skip(true, '2.0.0 S5 单人版已移除市场、handoff 或多用户前端入口,本 spec 隔离保留待恢复'); /** * 跨空间 handoff → agent 物化兑现端到端 e2e(真实后端,反假绿)。 * * 正路: * 1. market bind-precheck/createHandoff 签一次性 token; * 2. agent precheck 只提交 sourceType=market_agent + token + assetId,不提交 sourceAgentId; * 3. bind 阶段后端按 asset.source_id 复制发布者 agent,物化为安装者本地 user agent,并把槽位指向本地副本; * 4. 用该槽位创建 AI task,证明 runtime requireVisibleAgent 不再因裸 market 引用拒绝。 */ const API = 'http://localhost:48080/app-api/muse'; const AUTH = { Authorization: 'Bearer test1', 'tenant-id': '1', 'X-API-Version': '1' }; const WORK_ID = 4; const SLOT_KEY = 'handoff-writer'; const MARKET_AGENT_ASSET_ID = 1; function createPgClient(): Client { return new Client({ host: process.env.MUSE_POSTGRES_HOST, port: Number(process.env.MUSE_POSTGRES_PORT ?? '5433'), database: process.env.MUSE_POSTGRES_DATABASE ?? 'muse_slice_live', user: process.env.MUSE_POSTGRES_USERNAME ?? 'root', password: process.env.MUSE_POSTGRES_PASSWORD, ssl: false, connectionTimeoutMillis: 10_000, }); } async function readMaterializedAgent(): Promise<{ agentId: number; version: string; bindingAgentId: number } | null> { const c = createPgClient(); await c.connect(); try { const r = await c.query( `SELECT a.id AS agent_id, av.version, b.agent_id AS binding_agent_id FROM muse_agent a JOIN muse_agent_version av ON av.agent_id=a.id AND av.tenant_id=a.tenant_id AND av.deleted=false AND av.status='active' JOIN muse_agent_slot_binding b ON b.agent_id=a.id AND b.tenant_id=a.tenant_id AND b.deleted=false AND b.status='active' WHERE a.tenant_id=1 AND a.owner_user_id=1 AND a.agent_type='user' AND a.status='active' AND a.source_market_asset_id=$1 AND b.work_id=$2 AND b.slot_key=$3 AND a.deleted=false ORDER BY a.id DESC LIMIT 1`, [MARKET_AGENT_ASSET_ID, WORK_ID, SLOT_KEY] ); if (r.rowCount === 0) return null; return { agentId: Number(r.rows[0].agent_id), version: String(r.rows[0].version), bindingAgentId: Number(r.rows[0].binding_agent_id), }; } finally { await c.end(); } } async function seedRuntimeGrantForMaterializedAgent(agentId: number): Promise { const c = createPgClient(); await c.connect(); try { const grantKey = `security:local:e2e-handoff-agent:${agentId}:${WORK_ID}:ai-runtime`; const commandId = `e2e-handoff-agent-runtime-grant-${agentId}-${WORK_ID}`; const scope = { scopeType: 'work', ownerType: 'user', operationId: 'createAiTask', targetType: 'aiTask', targetId: String(WORK_ID), targetKey: `work:${WORK_ID}`, agentId: String(agentId), resourceRefs: [{ resourceType: 'work', resourceId: String(WORK_ID) }], }; // AI runtime 不能自授权。活体 e2e 在发起任务前显式种入 Security 已批准投影, // 这样验证的仍是后端权限门真实通过,而不是绕过 runtime permission。 await c.query( `INSERT INTO muse_tool_grant (grant_key, agent_id, tool_name, action, purpose, authority_owner, scope, budget, outbound_policy, approval_status, approved_by, status, command_id, creator, updater, tenant_id) VALUES ($1,$2,'ai-runtime','security:local:e2e-handoff-agent', 'e2e:允许 handoff 物化 agent 在目标作品内执行 AI runtime','security',$3::jsonb, $4::jsonb,$5::jsonb,'approved','security-policy-local','active',$6,'1','1',1) ON CONFLICT (tenant_id, grant_key) DO UPDATE SET agent_id=EXCLUDED.agent_id, scope=EXCLUDED.scope, budget=EXCLUDED.budget, outbound_policy=EXCLUDED.outbound_policy, approval_status='approved', approved_by='security-policy-local', status='active', command_id=EXCLUDED.command_id, updater='1', update_time=CURRENT_TIMESTAMP, deleted=false`, [ grantKey, agentId, JSON.stringify(scope), JSON.stringify({ period: 'per_task', maxCalls: 3 }), JSON.stringify({ networkMode: 'restricted', allowedDestinations: ['new-api'], dataPolicy: 'runtime_only' }), commandId, ] ); } finally { await c.end(); } } async function readTaskStatus(taskId: number): Promise<{ status: string; runtimeStatus: string; errorCode: string | null } | null> { const c = createPgClient(); await c.connect(); try { const r = await c.query( `SELECT status, runtime_status, error_code FROM muse_ai_generation WHERE tenant_id=1 AND id=$1 AND deleted=false`, [taskId] ); if (r.rowCount === 0) return null; return { status: String(r.rows[0].status), runtimeStatus: String(r.rows[0].runtime_status), errorCode: r.rows[0].error_code == null ? null : String(r.rows[0].error_code), }; } finally { await c.end(); } } async function waitForTaskRuntimeVisible(taskId: number): Promise { const deadline = Date.now() + 120_000; while (Date.now() < deadline) { const status = await readTaskStatus(taskId); if (status?.errorCode === 'AI_AGENT_SCOPE_FORBIDDEN') { throw new Error(`runtime 仍拒绝 market agent 槽位: ${JSON.stringify(status)}`); } if (status && ['completed', 'succeeded', 'done'].includes(status.status)) { return; } if (status && ['failed', 'cancelled'].includes(status.status)) { throw new Error(`AI task 进入失败终态: ${JSON.stringify(status)}`); } await new Promise((resolve) => setTimeout(resolve, 1000)); } const last = await readTaskStatus(taskId); throw new Error(`AI task 未进入运行/完成态: ${JSON.stringify(last)}`); } test.setTimeout(120_000); test('正路:市场 agent handoff 物化为本地 user agent 并可被 AI runtime 使用', async ({ request }) => { // 前提:asset1 是 global-setup 复位后的 listed agent 资产,source_id 指向发布者 user agent。 await request .post(`${API}/marketplace/assets/${MARKET_AGENT_ASSET_ID}/purchase`, { headers: AUTH, data: { commandId: `e2e-agent-acq-${Date.now()}` }, }) .catch(() => undefined); const bp = await request.post(`${API}/marketplace/assets/${MARKET_AGENT_ASSET_ID}/bind-precheck`, { headers: AUTH, data: { commandId: `e2e-agent-bp-${Date.now()}`, targetOwner: 'agent', targetAction: 'bind', targetWorkId: WORK_ID, }, }); const bpBody = await bp.json(); expect(bpBody.code, `bind-precheck: ${JSON.stringify(bpBody)}`).toBe(0); const ho = await request.post(`${API}/marketplace/handoffs`, { headers: AUTH, data: { commandId: `e2e-agent-ho-${Date.now()}`, assetId: String(MARKET_AGENT_ASSET_ID), targetOwner: 'agent', targetAction: 'bind', targetWorkId: WORK_ID, authorizationSummaryId: bpBody.data.authorizationSummaryId, returnUrl: 'http://localhost/market', }, }); const hoBody = await ho.json(); expect(hoBody.code, `createHandoff: ${JSON.stringify(hoBody)}`).toBe(0); const token = hoBody.data?.handoffToken; expect(token).toBeTruthy(); const pc = await request.post(`${API}/works/${WORK_ID}/agent-slots/${SLOT_KEY}/prechecks`, { headers: AUTH, data: { commandId: `e2e-agent-pc-${Date.now()}`, sourceType: 'market_agent', handoffToken: token, expectedSlotRevision: 1, sourceId: String(MARKET_AGENT_ASSET_ID), sourceVersion: 1, authorizationSummaryId: String(bpBody.data.authorizationSummaryId), }, }); const pcBody = await pc.json(); expect(pcBody.code, `agent precheck: ${JSON.stringify(pcBody)}`).toBe(0); const agentSlotPrecheckId = pcBody.data?.agentSlotPrecheckId; expect(agentSlotPrecheckId).toBeTruthy(); const bd = await request.post(`${API}/works/${WORK_ID}/agent-slots/${SLOT_KEY}/bind`, { headers: AUTH, data: { commandId: `e2e-agent-bd-${Date.now()}`, agentSlotPrecheckId, expectedSlotRevision: 1, }, }); const bdBody = await bd.json(); expect(bdBody.code, `bind: ${JSON.stringify(bdBody)}`).toBe(0); expect(bdBody.data?.slotRevision).toBe(2); const materialized = await readMaterializedAgent(); expect(materialized, 'bind 后应物化安装者本地 user agent,并让槽位指向它').not.toBeNull(); expect(materialized!.bindingAgentId).toBe(materialized!.agentId); expect(materialized!.version).toBe('1'); await seedRuntimeGrantForMaterializedAgent(materialized!.agentId); const task = await request.post(`${API}/ai/tasks`, { headers: AUTH, data: { commandId: `e2e-agent-runtime-${Date.now()}`, intent: { kind: 'continuation', userInstruction: '用已安装市场智能体写一句星环大陆的场景描写', outputTarget: 'suggestion', }, workId: WORK_ID, agentSlotKey: SLOT_KEY, contextScope: 'work', }, }); const taskBody = await task.json(); expect(taskBody.code, `AI task: ${JSON.stringify(taskBody)}`).toBe(0); expect(taskBody.data?.taskId).toBeTruthy(); await waitForTaskRuntimeVisible(Number(taskBody.data.taskId)); }); test('负路:伪造 handoffToken 调 agent precheck 被后端核验拒绝', async ({ request }) => { const resp = await request.post(`${API}/works/${WORK_ID}/agent-slots/${SLOT_KEY}/prechecks`, { headers: AUTH, data: { commandId: `e2e-agent-forge-${Date.now()}`, sourceType: 'market_agent', handoffToken: 'handoff_forged_invalid_token_000000000000', expectedSlotRevision: 1, sourceId: String(MARKET_AGENT_ASSET_ID), sourceVersion: 1, authorizationSummaryId: '9001', }, }); const body = await resp.json().catch(() => ({ code: -1 })); expect(body.code).not.toBe(0); });