Some checks failed
Backend Maven CI / backend-local (push) Has been cancelled
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
267 lines
10 KiB
TypeScript
267 lines
10 KiB
TypeScript
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);
|
||
});
|