oh-my-muse/muse-studio/e2e/handoff-agent.spec.ts
lili 3585219637
Some checks failed
Backend Maven CI / backend-local (push) Has been cancelled
feat(mvp): 收束1.0.0线A交付闭环
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 10:52:10 -07:00

267 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { expect, test } from '@playwright/test';
import { Client } from 'pg';
/**
* 跨空间 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<void> {
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<void> {
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);
});