diff --git a/tests/runner-e2e/chat-catalog-descriptors.json b/tests/runner-e2e/chat-catalog-descriptors.json index ef7507749c0..c2f593c3e74 100644 --- a/tests/runner-e2e/chat-catalog-descriptors.json +++ b/tests/runner-e2e/chat-catalog-descriptors.json @@ -4971,7 +4971,7 @@ "../../packages/plugins/sdk/dist/index.js", "chat-scenarios/secrets.yaml" ], - "oracleVersion": "paperclip.chat-catalog.external-least-scope-delivery.v10", + "oracleVersion": "paperclip.chat-catalog.external-least-scope-delivery.v11", "expectedRunCount": 4, "expectedHumanMessageCount": 1, "minimumRunCount": 3, @@ -5096,7 +5096,7 @@ "../../ui/src/pages/secrets/ImportFromVaultDialog.tsx", "chat-scenarios/secrets.yaml" ], - "oracleVersion": "paperclip.chat-catalog.external.v8", + "oracleVersion": "paperclip.chat-catalog.external-vault-reference.v9", "expectedRunCount": 3, "expectedHumanMessageCount": 2, "minimumRunCount": 2, diff --git a/tests/runner-e2e/chat-catalog-domains/external-secret-delivery.ts b/tests/runner-e2e/chat-catalog-domains/external-secret-delivery.ts index 7b1e9b6ed48..be4fa0678a9 100644 --- a/tests/runner-e2e/chat-catalog-domains/external-secret-delivery.ts +++ b/tests/runner-e2e/chat-catalog-domains/external-secret-delivery.ts @@ -268,7 +268,7 @@ export const secretDeliveryExternalCases = [ }, gradeSecretDelivery, { - oracleVersion: "paperclip.chat-catalog.external-least-scope-delivery.v10", authentication: "human-session", + oracleVersion: "paperclip.chat-catalog.external-least-scope-delivery.v11", authentication: "human-session", // CEO turn, Morgan's check (plus at most one wake from a handoff // comment on the created task), and the CEO's confirmation. expectedRunCount: 4, diff --git a/tests/runner-e2e/chat-catalog-domains/external-secret-endpoint.ts b/tests/runner-e2e/chat-catalog-domains/external-secret-endpoint.ts index 61b20bb4def..fffa17f1e48 100644 --- a/tests/runner-e2e/chat-catalog-domains/external-secret-endpoint.ts +++ b/tests/runner-e2e/chat-catalog-domains/external-secret-endpoint.ts @@ -78,12 +78,20 @@ export function validSecretScopeReceipt( * own execution workspace inside the attempt root. Nothing here depends on * what the model copied into the command. */ export async function readSecretEndpointConsumerRun(ctx: ChatCatalogContext, agentId: string) { + const run = await readSecretEndpointActiveRun(ctx, agentId); + return { run, ...(await readSecretEndpointTaskBinding(ctx, agentId, run.id, String(run.contextSnapshot?.issueId ?? ""))) }; +} + +/** The agent's one running run in this company, read from the server. */ +export async function readSecretEndpointActiveRun(ctx: ChatCatalogContext, agentId: string) { const companyId = ctx.input.fixtures.company.id; const rows = await ctx.input.api.get>(`/api/companies/${companyId}/heartbeat-runs?agentId=${encodeURIComponent(agentId)}&limit=20`, { timeout: 4000 }); const running = rows.filter(row => row.agentId === agentId && row.status === "running"); if (running.length !== 1) throw new Error("Secret consumer has no single active run"); const run = await ctx.input.api.get<{ id: string; companyId: string; agentId: string; status: string; startedAt: string | null; responsibleUserId?: string | null; contextSnapshot: { issueId?: string } | null }>(`/api/heartbeat-runs/${running[0]!.id}`, { timeout: 4000 }); - return { run, ...(await readSecretEndpointTaskBinding(ctx, agentId, run.id, String(run.contextSnapshot?.issueId ?? ""))) }; + if (run.id !== running[0]!.id || run.companyId !== companyId || run.agentId !== agentId || run.status !== "running") + throw new Error("Secret consumer is not the actual active run"); + return run; } export async function readSecretEndpointTaskBinding(ctx: ChatCatalogContext, agentId: string, runId: string, taskId: string) { @@ -111,7 +119,7 @@ export async function secretTestEndpoint( value: string, envKey: "PUBLISHING_API_KEY" | "DEPLOYMENT_API_KEY", scopeProbe?: SecretScopeProbe, - options?: { forbidAwsRuntimeAuthority?: true; taskBound?: true; personalWorkerReceipt?: true }, + options?: { forbidAwsRuntimeAuthority?: true; taskBound?: true; personalWorkerReceipt?: true; activeRunConsumer?: true }, ) { if (options?.personalWorkerReceipt && (ctx.scenario.id !== "secrets.personal-secret" || envKey !== "PUBLISHING_API_KEY" || scopeProbe || options.taskBound || options.forbidAwsRuntimeAuthority)) throw new Error("Personal worker receipts require the dedicated personal-secret endpoint"); if (ctx.input.execution.environment.id !== "local") @@ -168,9 +176,20 @@ export async function secretTestEndpoint( calls.push(record); let ok = authorized && - (options?.personalWorkerReceipt || options?.taskBound || (record.agentId === agentId && /^[a-f0-9-]{36}$/i.test(record.runId))) && + (options?.personalWorkerReceipt || options?.taskBound || options?.activeRunConsumer || (record.agentId === agentId && /^[a-f0-9-]{36}$/i.test(record.runId))) && record.method === "GET" && record.pathname === "/publishing-check"; + if (options?.activeRunConsumer && ok) { + // Native runner shells carry no Paperclip run identity; attribute the + // call to the agent's one running run from saved server state. + try { + const run = await readSecretEndpointActiveRun(ctx, agentId); + record.agentId = run.agentId; + record.runId = run.id; + } catch { + ok = false; + } + } if (options?.forbidAwsRuntimeAuthority) ok = ok && record.awsAuthorityAbsent === true; if (scopeProbe && ok) { @@ -290,7 +309,7 @@ export async function secretTestEndpoint( const port = await listenOnFetchAllowedPort(server), scriptPath = home ? path.join(home, `secret-endpoint-check-${ctx.input.nonce}.mjs`) : "", url = `http://127.0.0.1:${port}/publishing-check`; - const script = `const credential=process.env[${JSON.stringify(envKey)}];if(!credential){process.stdout.write(JSON.stringify({ok:false,reason:'credential unavailable'}));process.exitCode=1;}else{try{const response=await fetch(${JSON.stringify(url)},{method:'GET',redirect:'error',signal:AbortSignal.timeout(20000),headers:{authorization:'Bearer '+credential${options?.personalWorkerReceipt || options?.taskBound ? "" : ",...{'x-paperclip-agent':process.env.PAPERCLIP_AGENT_ID??'','x-paperclip-run':process.env.PAPERCLIP_RUN_ID??''}"},...${options?.forbidAwsRuntimeAuthority ? "{'x-paperclip-no-aws':String(!Object.keys(process.env).some(key=>key.startsWith('AWS_')||key.startsWith('PAPERCLIP_SECRETS_AWS_')))}" : "{}"}}});const result=await response.json();process.stdout.write(JSON.stringify({ok:result.ok===true,status:response.status,check:'publishing endpoint',...${options?.personalWorkerReceipt ? "(/^[a-f0-9]{48}$/.test(result.receiptId??''))?{receiptId:result.receiptId}:{}" : "{}"}}));if(!response.ok)process.exitCode=1;}catch{process.stdout.write(JSON.stringify({ok:false,reason:'endpoint check failed'}));process.exitCode=1;}}\n`; + const script = `const credential=process.env[${JSON.stringify(envKey)}];if(!credential){process.stdout.write(JSON.stringify({ok:false,reason:'credential unavailable'}));process.exitCode=1;}else{try{const response=await fetch(${JSON.stringify(url)},{method:'GET',redirect:'error',signal:AbortSignal.timeout(20000),headers:{authorization:'Bearer '+credential${options?.personalWorkerReceipt || options?.taskBound || options?.activeRunConsumer ? "" : ",...{'x-paperclip-agent':process.env.PAPERCLIP_AGENT_ID??'','x-paperclip-run':process.env.PAPERCLIP_RUN_ID??''}"},...${options?.forbidAwsRuntimeAuthority ? "{'x-paperclip-no-aws':String(!Object.keys(process.env).some(key=>key.startsWith('AWS_')||key.startsWith('PAPERCLIP_SECRETS_AWS_')))}" : "{}"}}});const result=await response.json();process.stdout.write(JSON.stringify({ok:result.ok===true,status:response.status,check:'publishing endpoint',...${options?.personalWorkerReceipt ? "(/^[a-f0-9]{48}$/.test(result.receiptId??''))?{receiptId:result.receiptId}:{}" : "{}"}}));if(!response.ok)process.exitCode=1;}catch{process.stdout.write(JSON.stringify({ok:false,reason:'endpoint check failed'}));process.exitCode=1;}}\n`; if (scriptPath) await writeFile(scriptPath, script, { mode: 0o600 }); const command = scriptPath ? `node ${JSON.stringify(scriptPath)}` : `node --input-type=module -e '${script.replace(/'/g, "'\\''")}'`; return { diff --git a/tests/runner-e2e/chat-catalog-domains/external-secret-task-workspace.test.ts b/tests/runner-e2e/chat-catalog-domains/external-secret-task-workspace.test.ts index e463497b3de..e7460aa230f 100644 --- a/tests/runner-e2e/chat-catalog-domains/external-secret-task-workspace.test.ts +++ b/tests/runner-e2e/chat-catalog-domains/external-secret-task-workspace.test.ts @@ -85,3 +85,25 @@ it("actual endpoint binds the server's running Morgan run, probes with its own a expect(JSON.stringify({ calls: endpoint.calls, receipts: endpoint.scopeReceipts, output })).not.toContain("synthetic-deployment-value"); } finally { await endpoint.close(); } }); +it("active-run consumer mode attributes the bound credential to the server's one running run, without shell identity headers", async () => { + const f = await fixture(); vi.stubEnv("PAPERCLIP_HOME", path.join(await realpath(f.root), "home")); + f.ctx.scenario.id = "secrets.vault-reference"; + const endpoint = await secretTestEndpoint(f.ctx, "morgan", "synthetic-deployment-value", "DEPLOYMENT_API_KEY", undefined, { forbidAwsRuntimeAuthority: true, activeRunConsumer: true }); + const statuses: number[] = []; + const invoke = (authorization: string, headers: Record = {}) => handlers[0]!({ headers: { authorization, "x-paperclip-no-aws": "true", ...headers }, method: "GET", url: "/publishing-check" }, { writeHead: (status: number) => statuses.push(status), end: () => {} }); + try { + expect(endpoint.command).not.toContain("synthetic-deployment-value"); + const { readFile } = await import("node:fs/promises"); + expect(await readFile(endpoint.scriptPath, "utf8")).not.toMatch(/PAPERCLIP_AGENT_ID|PAPERCLIP_RUN_ID|x-paperclip-agent|x-paperclip-run/); + await invoke("Bearer synthetic-deployment-value"); expect(statuses.at(-1)).toBe(200); + expect(endpoint.calls.at(-1)).toMatchObject({ authorized: true, agentId: "morgan", runId: f.run.id, awsAuthorityAbsent: true }); + // Forged shell headers are ignored: identity comes from the server. + await invoke("Bearer synthetic-deployment-value", { "x-paperclip-agent": "ceo", "x-paperclip-run": "33333333-3333-3333-3333-333333333333" }); + expect(endpoint.calls.at(-1)).toMatchObject({ agentId: "morgan", runId: f.run.id }); + await invoke("Bearer wrong-value"); expect(statuses.at(-1)).toBe(403); + await invoke("Bearer synthetic-deployment-value", { "x-paperclip-no-aws": "false" }); expect(statuses.at(-1)).toBe(403); + f.runs[0]!.status = "succeeded"; await invoke("Bearer synthetic-deployment-value"); expect(statuses.at(-1)).toBe(403); + f.runs[0]!.status = "running"; f.runs[1]!.status = "running"; await invoke("Bearer synthetic-deployment-value"); expect(statuses.at(-1)).toBe(403); + f.runs[1]!.status = "succeeded"; f.run.agentId = "ceo"; await invoke("Bearer synthetic-deployment-value"); expect(statuses.at(-1)).toBe(403); + } finally { await endpoint.close(); } +}); diff --git a/tests/runner-e2e/chat-catalog-domains/external-vault-reference.ts b/tests/runner-e2e/chat-catalog-domains/external-vault-reference.ts index e9da6454eeb..1451f382e93 100644 --- a/tests/runner-e2e/chat-catalog-domains/external-vault-reference.ts +++ b/tests/runner-e2e/chat-catalog-domains/external-vault-reference.ts @@ -368,7 +368,7 @@ export const vaultReferenceExternalCases = [ value, "DEPLOYMENT_API_KEY", undefined, - { forbidAwsRuntimeAuthority: true }, + { forbidAwsRuntimeAuthority: true, activeRunConsumer: true }, ); const task = await ctx.input.api.post(`${root(ctx)}/issues`, { title: "Vault deployment check", @@ -426,7 +426,7 @@ export const vaultReferenceExternalCases = [ }, gradeVaultReference, { - oracleVersion: "paperclip.chat-catalog.external.v8", authentication: "human-session", + oracleVersion: "paperclip.chat-catalog.external-vault-reference.v9", authentication: "human-session", expectedRunCount: 3, minimumRunCount: 2, expectedHumanMessageCount: 2,