mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-10 12:37:55 +01:00
test(evals): identify vault-reference's credential consumer from server state, not shell identity headers
The vault deployment helper sent x-paperclip-agent/x-paperclip-run from PAPERCLIP_AGENT_ID and PAPERCLIP_RUN_ID, which the native runner reserves and strips from the worker shell, so the endpoint always returned 403 even for the correctly bound reference. A new activeRunConsumer endpoint mode drops those headers and attributes an authorized call to the agent's one running run read from the server (readSecretEndpointActiveRun, factored out of the least-scope consumer lookup). The case still requires that run to be Morgan's on the intended task and the AWS-authority-absent header. Versions: vault-reference -> external-vault-reference.v9; least-scope -> v11 (shared lookup refactored, now also checks the detail row is running). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
80ab0ca512
commit
d477873af8
5 files changed
+50
-9
No files matched your search
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<Array<{ id: string; agentId: string; status: string }>>(`/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 {
|
||||
|
||||
@@ -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<string, string> = {}) => 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(); }
|
||||
});
|
||||
@@ -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<Entity>(`${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,
|
||||
|
||||
Reference in new issue
Block a user