fix(agentrun): 继承 Aipod 会话持久身份
This commit is contained in:
@@ -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", () => {
|
||||
|
||||
@@ -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"])}`)));
|
||||
|
||||
Reference in New Issue
Block a user