From eb63823b66ebb66d8d7314469720a4483ca8bfc7 Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 13 Jul 2026 10:36:04 +0200 Subject: [PATCH] =?UTF-8?q?fix(agentrun):=20=E7=BB=A7=E6=89=BF=20Aipod=20?= =?UTF-8?q?=E4=BC=9A=E8=AF=9D=E6=8C=81=E4=B9=85=E8=BA=AB=E4=BB=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- scripts/src/agentrun.test.ts | 172 ++++++++++++++++++++++++++++ scripts/src/agentrun/rest-bridge.ts | 62 +++++++++- 2 files changed, 233 insertions(+), 1 deletion(-) diff --git a/scripts/src/agentrun.test.ts b/scripts/src/agentrun.test.ts index 647db52f..4b94923f 100644 --- a/scripts/src/agentrun.test.ts +++ b/scripts/src/agentrun.test.ts @@ -1332,6 +1332,178 @@ describe("AgentRun default transport contract", () => { rmSync(tempConfigPath, { force: true }); } }); + + test("Aipod follow-up inherits cancelled durable session identity and keeps boundary conflicts fail-closed", async () => { + const sessionId = "sess_artificer_cancelled_selfmedia"; + const durableSession = { + sessionId, + tenantId: "unidesk", + projectId: "pikainc/selfmedia", + providerId: "NC01", + backendProfile: "codex", + conversationId: "conv_selfmedia", + threadId: "thread_selfmedia", + metadata: { lastTurnId: "turn_cancelled", durable: true }, + executionState: "terminal", + terminalStatus: "cancelled", + failureKind: "cancelled", + }; + const renderBodies: Record[] = []; + const sendBodies: Record[] = []; + let sessionReads = 0; + const server = Bun.serve({ + port: 0, + async fetch(request) { + const url = new URL(request.url); + expect(request.headers.get("authorization")).toBe("Bearer secret-value"); + if (request.method === "GET" && url.pathname === `/api/v1/sessions/${sessionId}`) { + sessionReads += 1; + return Response.json({ ok: true, data: durableSession }); + } + if (request.method === "POST" && url.pathname === "/api/v1/aipod-specs/Artificer/render") { + const body = await request.json() as Record; + renderBodies.push(body); + return Response.json({ + ok: true, + data: { + queueTask: { + tenantId: body.tenantId ?? "unidesk", + projectId: body.projectId ?? "default", + queue: "commander", + lane: "v0.2", + title: "Artificer cancelled continuation", + backendProfile: "codex", + providerId: "NC01", + workspaceRef: { kind: "opaque", path: "." }, + sessionRef: body.sessionRef ?? { sessionId }, + executionPolicy: { + sandbox: "workspace-write", + approval: "never", + timeoutMs: 1_800_000, + network: "enabled", + secretScope: { + allowCredentialEcho: false, + providerCredentials: [{ + profile: "codex", + secretRef: { name: "agentrun-v01-provider-codex", keys: ["auth.json", "config.toml"] }, + }], + }, + }, + resourceBundleRef: null, + payload: { prompt: body.prompt }, + }, + dispatchDefaults: { runnerJob: {} }, + }, + }); + } + if (request.method === "POST" && url.pathname === `/api/v1/sessions/${sessionId}/send`) { + const body = await request.json() as Record; + sendBodies.push(body); + if (body.run?.tenantId !== durableSession.tenantId || body.run?.projectId !== durableSession.projectId) { + return Response.json({ + ok: false, + failureKind: "tenant-policy-denied", + message: "sessionRef cannot be reused across tenant or project boundary", + details: { sessionId, valuesPrinted: false }, + }, { status: 403 }); + } + return Response.json({ + ok: true, + data: { + action: "session-send-plan", + dryRun: true, + mutation: false, + sessionId, + decision: "turn", + internalCommandType: "turn", + activeBefore: null, + request: { method: "POST", path: `/api/v1/sessions/${sessionId}/send`, commandType: "turn", createRunnerJob: true, valuesPrinted: false }, + next: { confirm: { action: "send-session", operation: "send", resourceKind: "session", resourceName: sessionId } }, + valuesPrinted: false, + }, + }); + } + return new Response("not found", { status: 404 }); + }, + }); + const previousConfig = process.env.AGENTRUN_CLIENT_CONFIG; + const previousKey = process.env.HWLAB_API_KEY; + process.env.HWLAB_API_KEY = "secret-value"; + const tempConfigPath = `/tmp/unidesk-agentrun-cancelled-session-test-${process.pid}-${Date.now()}.yaml`; + try { + process.env.AGENTRUN_CLIENT_CONFIG = tempConfigPath; + await Bun.write(tempConfigPath, agentRunMinimalClientYaml.replace("http://agentrun.example.local:8080", server.url.href.replace(/\/$/u, ""))); + + const continued = await runAgentRunCommand(null, [ + "send", + `session/${sessionId}`, + "--aipod", + "Artificer", + "--prompt", + "continue cancelled selfmedia task", + "--dry-run", + "-o", + "json", + ]); + expect(continued.ok).toBe(true); + expect(JSON.parse(renderedTextOf(continued))).toMatchObject({ + kind: "SessionSendPlan", + decision: "turn", + dryRun: true, + mutation: false, + }); + expect(sessionReads).toBe(1); + expect(renderBodies).toHaveLength(1); + expect(renderBodies[0]).toMatchObject({ + tenantId: durableSession.tenantId, + projectId: durableSession.projectId, + sessionId, + sessionRef: { + sessionId, + conversationId: durableSession.conversationId, + threadId: durableSession.threadId, + metadata: durableSession.metadata, + }, + }); + expect(sendBodies).toHaveLength(1); + expect(sendBodies[0]?.run).toMatchObject({ + tenantId: durableSession.tenantId, + projectId: durableSession.projectId, + sessionRef: { + sessionId, + conversationId: durableSession.conversationId, + threadId: durableSession.threadId, + metadata: durableSession.metadata, + }, + }); + + const conflict = await runAgentRunCommand(null, [ + "send", + `session/${sessionId}`, + "--aipod", + "Artificer", + "--project-id", + "pikasTech/unidesk", + "--prompt", + "must stay fail closed", + "--dry-run", + "-o", + "json", + ]); + expect(conflict.ok).toBe(false); + expect(renderedTextOf(conflict)).toContain("tenant-policy-denied"); + expect(sessionReads).toBe(2); + expect(renderBodies).toHaveLength(1); + expect(sendBodies).toHaveLength(1); + } finally { + server.stop(true); + if (previousConfig === undefined) delete process.env.AGENTRUN_CLIENT_CONFIG; + else process.env.AGENTRUN_CLIENT_CONFIG = previousConfig; + if (previousKey === undefined) delete process.env.HWLAB_API_KEY; + else process.env.HWLAB_API_KEY = previousKey; + rmSync(tempConfigPath, { force: true }); + } + }); }); describe("AgentRun YAML tool credential binding", () => { diff --git a/scripts/src/agentrun/rest-bridge.ts b/scripts/src/agentrun/rest-bridge.ts index 23803caf..596202ab 100644 --- a/scripts/src/agentrun/rest-bridge.ts +++ b/scripts/src/agentrun/rest-bridge.ts @@ -1027,7 +1027,13 @@ export function agentRunSessionRunPolicyDisclosure(runBody: Record> { - const rendered = await agentRunRestRequest("agentrun aipod-specs render", "POST", `/api/v1/aipod-specs/${encodeURIComponent(aipod)}/render`, await aipodRenderInputFromArgs(args, 2, { sessionId })); + const existing = await fetchAgentRunSessionOrNull(sessionId, args); + const renderInput = inheritDurableSessionAipodIdentity( + await aipodRenderInputFromArgs(args, 2, { sessionId }), + sessionId, + existing, + ); + const rendered = await agentRunRestRequest("agentrun aipod-specs render", "POST", `/api/v1/aipod-specs/${encodeURIComponent(aipod)}/render`, renderInput); const renderedData = record(innerData(rendered)); const task = normalizeAipodRenderedQueueTask(record(renderedData.queueTask), args, aipod); if (Object.keys(task).length === 0) throw new AgentRunRestError("schema-mismatch", `aipod-spec ${aipod} render did not return queueTask`); @@ -1079,6 +1085,60 @@ export async function sessionSendWithAipodRest(sessionId: string, aipod: string, }; } +export function inheritDurableSessionAipodIdentity( + renderInput: Record, + sessionId: string, + existing: Record | null, +): Record { + if (existing === null) return renderInput; + const durableSessionId = stringOrNull(existing.sessionId); + const tenantId = stringOrNull(existing.tenantId); + const projectId = stringOrNull(existing.projectId); + if (durableSessionId !== sessionId || tenantId === null || projectId === null) { + throw new AgentRunRestError("schema-mismatch", `session ${sessionId} durable identity is incomplete or mismatched`, { + details: { sessionId, valuesPrinted: false }, + }); + } + assertDurableSessionBoundary(renderInput.tenantId, tenantId, sessionId, "tenantId"); + assertDurableSessionBoundary(renderInput.projectId, projectId, sessionId, "projectId"); + const requestedSessionRef = record(renderInput.sessionRef); + const requestedSessionId = stringOrNull(requestedSessionRef.sessionId); + if (requestedSessionId !== null && requestedSessionId !== sessionId) { + throw durableSessionBoundaryError(sessionId, "sessionRef.sessionId"); + } + const conversationId = stringOrNull(existing.conversationId); + const threadId = stringOrNull(existing.threadId); + const expiresAt = stringOrNull(existing.expiresAt); + return { + ...renderInput, + tenantId, + projectId, + sessionId, + sessionRef: { + sessionId, + ...(conversationId === null ? {} : { conversationId }), + ...(threadId === null ? {} : { threadId }), + ...(expiresAt === null ? {} : { expiresAt }), + metadata: { + ...record(requestedSessionRef.metadata), + ...record(existing.metadata), + }, + }, + }; +} + +function assertDurableSessionBoundary(value: unknown, durable: string, sessionId: string, field: string): void { + if (value === undefined || value === null || value === durable) return; + throw durableSessionBoundaryError(sessionId, field); +} + +function durableSessionBoundaryError(sessionId: string, field: string): AgentRunRestError { + return new AgentRunRestError("tenant-policy-denied", "sessionRef cannot be reused across tenant or project boundary", { + httpStatus: 403, + details: { sessionId, field, valuesPrinted: false }, + }); +} + export async function fetchAgentRunSessionOrNull(sessionId: string, args: string[]): Promise | null> { try { return record(innerData(await agentRunRestRequest("agentrun sessions show", "GET", `/api/v1/sessions/${encodeURIComponent(sessionId)}${agentRunQuery(args, ["reader-id"])}`)));