fix(agentrun): 继承 Aipod 会话持久身份

This commit is contained in:
Codex
2026-07-13 10:36:04 +02:00
parent 7340302ae4
commit eb63823b66
2 changed files with 233 additions and 1 deletions
+172
View File
@@ -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<string, any>[] = [];
const sendBodies: Record<string, any>[] = [];
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<string, any>;
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<string, any>;
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", () => {
+61 -1
View File
@@ -1027,7 +1027,13 @@ export function agentRunSessionRunPolicyDisclosure(runBody: Record<string, unkno
}
export async function sessionSendWithAipodRest(sessionId: string, aipod: string, args: string[]): Promise<Record<string, unknown>> {
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<string, unknown>,
sessionId: string,
existing: Record<string, unknown> | null,
): Record<string, unknown> {
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<Record<string, unknown> | null> {
try {
return record(innerData(await agentRunRestRequest("agentrun sessions show", "GET", `/api/v1/sessions/${encodeURIComponent(sessionId)}${agentRunQuery(args, ["reader-id"])}`)));