diff --git a/tests/runner-e2e/chat-catalog-descriptors.json b/tests/runner-e2e/chat-catalog-descriptors.json index e3d1667742f..3c5041fa2f6 100644 --- a/tests/runner-e2e/chat-catalog-descriptors.json +++ b/tests/runner-e2e/chat-catalog-descriptors.json @@ -4971,8 +4971,8 @@ "../../packages/plugins/sdk/dist/index.js", "chat-scenarios/secrets.yaml" ], - "oracleVersion": "paperclip.chat-catalog.external-least-scope-delivery.v9", - "expectedRunCount": 3, + "oracleVersion": "paperclip.chat-catalog.external-least-scope-delivery.v10", + "expectedRunCount": 4, "expectedHumanMessageCount": 1, "minimumRunCount": 3, "turnTimeoutMs": 120000, diff --git a/tests/runner-e2e/chat-catalog-domains/external-secret-delivery.test.ts b/tests/runner-e2e/chat-catalog-domains/external-secret-delivery.test.ts index b7f97c376c9..4c3d9d2b6cf 100644 --- a/tests/runner-e2e/chat-catalog-domains/external-secret-delivery.test.ts +++ b/tests/runner-e2e/chat-catalog-domains/external-secret-delivery.test.ts @@ -131,8 +131,9 @@ function valid() { taskWorkspaceMatched: true, identityStatus: 200, catalogStatus: 200, - catalogKeys: ["deployment-api-key"], - secretRefs: ["bound"], + // The worker also holds its model-provider credential binding. + catalogKeys: ["openrouter_api_key", "deployment-api-key"], + secretRefs: ["provider", "bound"], companyKey: "unrelated-company-key", instanceKey: "custody_probe", companyDeniedStatus: 403, @@ -171,6 +172,25 @@ it("accepts any confirmation wording and a chat-created task that is not a child expect(gradeSecretDelivery(o).passed).toBe(true); } }); +it("accepts a second successful Morgan wake from a handoff comment on the created task", () => { + const o = valid(); + o.runs.splice(2, 0, { id: "worker-comment-wake", agentId: "morgan", status: "succeeded", runtimeMode: "native", contextSnapshot: { issueId: "task" } }); + expect(gradeSecretDelivery(o).passed).toBe(true); +}); +it("rejects a failed extra worker run, a check from no worker run, and a catalog listing a probed key", () => { + const failed = valid(); + failed.runs.splice(2, 0, { id: "worker-comment-wake", agentId: "morgan", status: "failed", runtimeMode: "native", contextSnapshot: { issueId: "task" } }); + expect(gradeSecretDelivery(failed).passed).toBe(false); + const noWorker = valid(); + noWorker.state.workerRunId = undefined as unknown as string; + expect(gradeSecretDelivery(noWorker).passed).toBe(false); + for (const key of ["custody_probe", "deployment-api-key"]) { + const o = valid(); + o.state.scopeReceipts[0]!.catalogKeys.push(key); + o.state.scopeReceipts[0]!.secretRefs.push("other"); + expect(gradeSecretDelivery(o).passed, key).toBe(false); + } +}); it("rejects wrong owner/run/reference/custody, overgrant, missing delivery and leaked or premature result", () => { for (const [index, mutate] of [ (o: ReturnType) => { 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 6f90d84da99..7b1e9b6ed48 100644 --- a/tests/runner-e2e/chat-catalog-domains/external-secret-delivery.ts +++ b/tests/runner-e2e/chat-catalog-domains/external-secret-delivery.ts @@ -103,10 +103,11 @@ export function gradeSecretDelivery(value: unknown) { task.assigneeAgentId === o.state.morganId && (task as { originIssueId?: string | null }).originIssueId === o.state.sourceId && task.status === "done" && - worker.length === 1 && - worker[0]?.id === o.state.workerRunId && - worker[0]?.status === "succeeded" && - worker[0]?.runtimeMode === "native" && + // A handoff comment after creation may wake Morgan again; every worker + // run must succeed natively, and the endpoint call must come from one. + worker.length >= 1 && + worker.every((run) => run.status === "succeeded" && run.runtimeMode === "native") && + worker.some((run) => run.id === o.state.workerRunId) && o.state.workerReceiptBound === true && o.activity.some( (row) => @@ -133,7 +134,7 @@ export function gradeSecretDelivery(value: unknown) { }) && o.state.authorizedChecks === 1 && o.state.unauthorizedChecks === 0, - "The host verifies the actual still-running worker JWT through production identity/catalog routes, observes only the bound secret and directly receives two403 denials for unrelated company and server-custody keys. JWT bytes are withheld.", + "When the bound credential arrives, the host identifies Morgan's one running run from saved server state, probes with that run's own agent authority through production identity/catalog routes, sees the bound secret but neither probed key, and receives two 403 denials for unrelated company and server-custody keys. Token bytes are withheld.", ); const access = o.state.boundAccess as SecretAccessEvent[] | undefined; c.add( @@ -267,8 +268,10 @@ export const secretDeliveryExternalCases = [ }, gradeSecretDelivery, { - oracleVersion: "paperclip.chat-catalog.external-least-scope-delivery.v9", authentication: "human-session", - expectedRunCount: 3, + oracleVersion: "paperclip.chat-catalog.external-least-scope-delivery.v10", 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, minimumRunCount: 3, expectedHumanMessageCount: 1, attemptTimeoutMs: 660000, @@ -398,33 +401,43 @@ export const secretDeliveryExternalCases = [ run.agentId === state.morganId && run.contextSnapshot?.issueId === task?.id, ); - state.workerRunId = worker.length === 1 ? worker[0]!.id : undefined; + // The worker run is the one the endpoint bound the credential to. + const receiptRunId = fixture.endpoint.scopeReceipts[0]?.runId; + state.workerRunId = worker.some((run) => run.id === receiptRunId) ? receiptRunId : undefined; state.workerReceiptBound = false; state.confirmationAfterWorker = false; - if (task && worker.length === 1) { + if (task && state.workerRunId) { + const workerIds = new Set(worker.map((run) => run.id)); const comments = await ctx.input.api.get( `/api/issues/${task.id}/comments?order=asc`, ); state.workerReceiptBound = comments.some( (row) => row.authorAgentId === state.morganId && - row.createdByRunId === state.workerRunId && + workerIds.has(String(row.createdByRunId)) && !!row.body?.trim(), ); - const run = await ctx.input.api.get<{ finishedAt?: string }>( - `/api/heartbeat-runs/${state.workerRunId}`, + const finished = await Promise.all( + worker.map((row) => + ctx.input.api.get<{ finishedAt?: string }>(`/api/heartbeat-runs/${row.id}`), + ), ); - const later = o.runs.filter( - (row) => - row.contextSnapshot?.issueId === state.sourceId && - row.agentId === o.leadId && - row.status === "succeeded" && - !!row.startedAt && - !!run.finishedAt && - Date.parse(row.startedAt) >= Date.parse(run.finishedAt!), - ); - if (later.length === 1) { - state.confirmationRunId = later[0]!.id; + const lastFinish = finished.every((run) => !!run.finishedAt) + ? Math.max(...finished.map((run) => Date.parse(run.finishedAt!))) + : NaN; + const later = o.runs + .filter( + (row) => + row.contextSnapshot?.issueId === state.sourceId && + row.agentId === o.leadId && + row.status === "succeeded" && + !!row.startedAt && + Number.isFinite(lastFinish) && + Date.parse(row.startedAt) >= lastFinish, + ) + .sort((a, b) => Date.parse(a.startedAt!) - Date.parse(b.startedAt!)); + if (later.length >= 1) { + state.confirmationRunId = later.at(-1)!.id; state.confirmationAfterWorker = true; } } @@ -496,10 +509,15 @@ export const secretDeliveryExternalCases = [ const comments = await ctx.input.api.get( `/api/issues/${task.id}/comments?order=asc`, ), + workerIds = new Set( + (await ctx.allRuns()) + .filter((run) => run.agentId === state.morganId && run.contextSnapshot?.issueId === task.id) + .map((run) => run.id), + ), report = comments.find( (row) => row.authorAgentId === state.morganId && - row.createdByRunId === state.workerRunId && + workerIds.has(String(row.createdByRunId)) && !!row.body?.trim(), ); if (!report) 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 dcd7ecdde37..61b20bb4def 100644 --- a/tests/runner-e2e/chat-catalog-domains/external-secret-endpoint.ts +++ b/tests/runner-e2e/chat-catalog-domains/external-secret-endpoint.ts @@ -3,6 +3,7 @@ import { randomBytes } from "node:crypto"; import { mkdir, realpath, writeFile, rm } from "node:fs/promises"; import path from "node:path"; import { resolveDefaultAgentWorkspaceDir } from "../../../server/src/home-paths.js"; +import { createLocalAgentJwt } from "../../../server/src/agent-auth-jwt.js"; import { listenOnFetchAllowedPort } from "../../e2e/fetch-allowed-port.js"; import type { ChatCatalogContext } from "../chat-catalog-contract.js"; import { @@ -52,12 +53,16 @@ export function validSecretScopeReceipt( receipt.tokenRunMatched === true && receipt.identityStatus === 200 && receipt.catalogStatus === 200 && + // The worker's catalog also lists its model-provider credential binding; + // the bound key must appear exactly once, by the bound secret, and neither + // probed key may appear. Array.isArray(receipt.catalogKeys) && - receipt.catalogKeys.length === 1 && - receipt.catalogKeys[0] === expected.boundKey && Array.isArray(receipt.secretRefs) && - receipt.secretRefs.length === 1 && - receipt.secretRefs[0] === expected.boundSecretId && + receipt.catalogKeys.length === receipt.secretRefs.length && + receipt.catalogKeys.filter(key => key === expected.boundKey).length === 1 && + receipt.secretRefs[receipt.catalogKeys.indexOf(expected.boundKey)] === expected.boundSecretId && + !receipt.catalogKeys.includes("unrelated-company-key") && + !receipt.catalogKeys.includes("custody_probe") && receipt.companyKey === "unrelated-company-key" && receipt.instanceKey === "custody_probe" && receipt.companyDeniedStatus === 403 && @@ -66,9 +71,24 @@ export function validSecretScopeReceipt( ); } -export async function readSecretEndpointTaskBinding(ctx: ChatCatalogContext, agentId: string, runId: string, taskId: string, cwd: string) { +/** Native runner shells carry no Paperclip run identity (PAPERCLIP_API_KEY, + * PAPERCLIP_AGENT_ID and PAPERCLIP_RUN_ID are reserved), so the consumer is + * identified from saved server state when the bound credential arrives: the + * agent's one running run, on a task it owns, created from this chat, with its + * 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 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 ?? ""))) }; +} + +export async function readSecretEndpointTaskBinding(ctx: ChatCatalogContext, agentId: string, runId: string, taskId: string) { const temporary = process.env.PAPERCLIP_RUNNER_E2E_TEMP_ROOT; - if (!temporary || !taskId || !cwd || !ctx.issue()?.id) throw new Error("Actual secret consumer task context unavailable"); + if (!temporary || !taskId || !ctx.issue()?.id) throw new Error("Actual secret consumer task context unavailable"); const run = await ctx.input.api.get<{ id: string; companyId: string; agentId: string; status: string; startedAt: string | null; contextSnapshot: { issueId?: string } | null }>(`/api/heartbeat-runs/${runId}`, { timeout: 4000 }); if (run.id !== runId || run.companyId !== ctx.input.fixtures.company.id || run.agentId !== agentId || run.status !== "running" || !run.startedAt || !Number.isFinite(Date.parse(run.startedAt)) || run.contextSnapshot?.issueId !== taskId) throw new Error("Secret consumer is not the actual active assigned run"); @@ -78,8 +98,8 @@ export async function readSecretEndpointTaskBinding(ctx: ChatCatalogContext, age ]); if (task.id !== taskId || task.companyId !== run.companyId || task.assigneeAgentId !== agentId || task.originIssueId !== ctx.issue()!.id || !task.executionWorkspaceId || view.workspace.id !== task.executionWorkspaceId || view.workspace.companyId !== run.companyId) throw new Error("Secret consumer task workspace binding mismatch"); - const [owned, actual, reported] = await Promise.all([realpath(temporary), realpath(view.workspace.cwd), realpath(cwd)]); - if (!actual.startsWith(owned + path.sep) || reported !== actual) throw new Error("Secret consumer is outside its actual owned task cwd"); + const [owned, actual] = await Promise.all([realpath(temporary), realpath(view.workspace.cwd)]); + if (!actual.startsWith(owned + path.sep)) throw new Error("Secret consumer task workspace is outside the attempt root"); return { taskId: task.id, workspaceId: view.workspace.id, taskWorkspaceMatched: true as const }; } @@ -148,28 +168,36 @@ export async function secretTestEndpoint( calls.push(record); let ok = authorized && - (options?.personalWorkerReceipt || (record.agentId === agentId && /^[a-f0-9-]{36}$/i.test(record.runId))) && + (options?.personalWorkerReceipt || options?.taskBound || (record.agentId === agentId && /^[a-f0-9-]{36}$/i.test(record.runId))) && record.method === "GET" && record.pathname === "/publishing-check"; if (options?.forbidAwsRuntimeAuthority) ok = ok && record.awsAuthorityAbsent === true; if (scopeProbe && ok) { - const token = String(request.headers["x-paperclip-runtime"] ?? ""); - // Register before parsing, requests, exceptions or any parent diagnostics. - if (token && token.length <= 8192) { - ctx.input.privateSecrets?.push(token); - privateRuntimeValues.add(token); - } try { - if (!token || token.length > 8192 || !ctx.input.privateSecrets) - throw new Error("Private run credential unavailable"); + if (!ctx.input.privateSecrets) throw new Error("Private run credential unavailable"); + // The consumer is the agent's actual running run, read from the server. + const consumer = await readSecretEndpointConsumerRun(ctx, agentId), + actor = await ctx.input.api.get<{ id: string; companyId: string; adapterType: string }>(`/api/agents/${agentId}`, { timeout: 4000 }); + record.runId = consumer.run.id; + // Probe with the same run-bound agent authority the native tool bridge + // mints for this run (createLocalAgentJwt), never a fixture identity. + const token = createLocalAgentJwt(agentId, ctx.input.fixtures.company.id, actor.adapterType, consumer.run.id, consumer.run.responsibleUserId ?? null) ?? ""; + // Register before parsing, requests, exceptions or any parent diagnostics. + if (token && token.length <= 8192) { + ctx.input.privateSecrets.push(token); + privateRuntimeValues.add(token); + } + if (!token || token.length > 8192) throw new Error("Private run credential unavailable"); const claims = JSON.parse( Buffer.from(token.split(".")[1] ?? "", "base64url").toString("utf8"), ) as { sub?: string; company_id?: string; run_id?: string }; const tokenRunMatched = + actor.id === agentId && + actor.companyId === ctx.input.fixtures.company.id && claims.sub === agentId && claims.company_id === ctx.input.fixtures.company.id && - claims.run_id === record.runId; + claims.run_id === consumer.run.id; if (!tokenRunMatched) throw new Error("Run credential identity mismatch"); const call = (pathname: string, method = "GET") => @@ -206,7 +234,7 @@ export async function secretTestEndpoint( listed.secrets.length > 20 ) throw new Error("Scoped run authority could not be verified"); - const taskBinding = options?.taskBound ? await readSecretEndpointTaskBinding(ctx, agentId, record.runId, String(request.headers["x-paperclip-task"] ?? ""), String(request.headers["x-paperclip-cwd"] ?? "")) : undefined; + const taskBinding = options?.taskBound ? { taskId: consumer.taskId, workspaceId: consumer.workspaceId, taskWorkspaceMatched: consumer.taskWorkspaceMatched } : undefined; const denied = await Promise.all( [scopeProbe.unrelatedCompanyKey, scopeProbe.instanceKey].map((key) => call( @@ -262,7 +290,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 ? "{}" : "{'x-paperclip-agent':process.env.PAPERCLIP_AGENT_ID??'','x-paperclip-run':process.env.PAPERCLIP_RUN_ID??''}"},...${options?.taskBound ? "{'x-paperclip-task':process.env.PAPERCLIP_TASK_ID??'','x-paperclip-cwd':process.cwd()}" : "{}"},...${scopeProbe ? "{'x-paperclip-runtime':process.env.PAPERCLIP_API_KEY??''}" : "{}"},...${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 ? "" : ",...{'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 6fb4561dbcb..e463497b3de 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 @@ -6,62 +6,80 @@ import type { ChatCatalogContext } from "../chat-catalog-contract.js"; const handlers = vi.hoisted(() => [] as Array<(request: unknown, response: unknown) => Promise>); vi.mock("node:http", () => ({ createServer: (handler: (request: unknown, response: unknown) => Promise) => { handlers.push(handler); return { close: (done: () => void) => done() }; } })); vi.mock("../../e2e/fetch-allowed-port.js", () => ({ listenOnFetchAllowedPort: async () => 40001 })); -import { readSecretEndpointTaskBinding, secretTestEndpoint } from "./external-secret-endpoint.js"; +import { readSecretEndpointConsumerRun, readSecretEndpointTaskBinding, secretTestEndpoint } from "./external-secret-endpoint.js"; const roots: string[] = []; afterEach(async () => { vi.unstubAllEnvs(); vi.unstubAllGlobals(); handlers.length = 0; for (const root of roots.splice(0)) await rm(root, { recursive: true, force: true }); }); async function fixture() { const root = await mkdtemp(path.join(tmpdir(), "secret-task-cwd-")); roots.push(root); vi.stubEnv("PAPERCLIP_RUNNER_E2E_TEMP_ROOT", root); + vi.stubEnv("PAPERCLIP_AGENT_JWT_SECRET", "synthetic-jwt-secret-for-unit-test"); vi.stubEnv("PAPERCLIP_INSTANCE_ID", "unit"); const cwd = path.join(await realpath(root), "task"); await mkdir(cwd); const runId = "11111111-1111-1111-1111-111111111111"; - const run = { id: runId, companyId: "company", agentId: "morgan", status: "running", startedAt: "2026-10-28T01:00:00Z", contextSnapshot: { issueId: "task" } }; + const run = { id: runId, companyId: "company", agentId: "morgan", status: "running", startedAt: "2026-10-28T01:00:00Z", responsibleUserId: "user", contextSnapshot: { issueId: "task" } }; + const runs = [{ id: runId, agentId: "morgan", status: "running" }, { id: "22222222-2222-2222-2222-222222222222", agentId: "morgan", status: "succeeded" }]; const task = { id: "task", companyId: "company", assigneeAgentId: "morgan", parentId: null as string | null, originIssueId: "source", executionWorkspaceId: "workspace" }; const workspace = { id: "workspace", companyId: "company", cwd }; + const agent = { id: "morgan", companyId: "company", adapterType: "paperclip_runner" }; const privateSecrets: string[] = []; - const ctx = { scenario: { id: "secrets.least-scope-delivery" }, input: { nonce: "owned-test", execution: { environment: { id: "local" } }, fixtures: { company: { id: "company" } }, privateSecrets, api: { baseURL: "http://fixture.invalid", get: async (url: string) => url.startsWith("/api/heartbeat-runs/") ? run : url.endsWith("/workspace") ? { workspace } : task } }, issue: () => ({ id: "source" }) } as unknown as ChatCatalogContext; - return { ctx, cwd, run, task, workspace, privateSecrets }; + const get = async (url: string) => url.startsWith("/api/companies/company/heartbeat-runs") ? runs : url.startsWith("/api/heartbeat-runs/") ? run : url === "/api/agents/morgan" ? agent : url.endsWith("/workspace") ? { workspace } : task; + const ctx = { scenario: { id: "secrets.least-scope-delivery" }, input: { nonce: "owned-test", execution: { environment: { id: "local" } }, fixtures: { company: { id: "company" } }, privateSecrets, api: { baseURL: "http://fixture.invalid", get } }, issue: () => ({ id: "source" }) } as unknown as ChatCatalogContext; + return { ctx, cwd, root, run, runs, task, workspace, privateSecrets }; } it("binds only the actual active Morgan run, delegated task and its persisted owned workspace", async () => { const f = await fixture(); - await expect(readSecretEndpointTaskBinding(f.ctx, "morgan", f.run.id, "task", f.cwd)).resolves.toEqual({ taskId: "task", workspaceId: "workspace", taskWorkspaceMatched: true }); + await expect(readSecretEndpointTaskBinding(f.ctx, "morgan", f.run.id, "task")).resolves.toEqual({ taskId: "task", workspaceId: "workspace", taskWorkspaceMatched: true }); + await expect(readSecretEndpointConsumerRun(f.ctx, "morgan")).resolves.toMatchObject({ run: { id: f.run.id }, taskId: "task", workspaceId: "workspace", taskWorkspaceMatched: true }); }); -it.each(["CEO", "wrong task", "wrong origin chat", "wrong cwd", "foreign company", "wrong workspace", "unstarted", "settled"])("rejects %s before endpoint success", async invalid => { - const f = await fixture(); let cwd = f.cwd; +it.each(["CEO", "wrong task", "wrong origin chat", "workspace outside attempt", "foreign company", "wrong workspace", "unstarted", "settled"])("rejects %s before endpoint success", async invalid => { + const f = await fixture(); if (invalid === "CEO") f.run.agentId = "ceo"; if (invalid === "wrong task") f.run.contextSnapshot.issueId = "other"; if (invalid === "wrong origin chat") f.task.originIssueId = "other"; - if (invalid === "wrong cwd") cwd = path.dirname(f.cwd); + if (invalid === "workspace outside attempt") f.workspace.cwd = tmpdir(); if (invalid === "foreign company") f.workspace.companyId = "foreign"; if (invalid === "wrong workspace") f.task.executionWorkspaceId = "other"; if (invalid === "unstarted") f.run.startedAt = "invalid"; if (invalid === "settled") f.run.status = "succeeded"; - await expect(readSecretEndpointTaskBinding(f.ctx, "morgan", f.run.id, "task", cwd)).rejects.toThrow(); + await expect(readSecretEndpointTaskBinding(f.ctx, "morgan", f.run.id, "task")).rejects.toThrow(); }); -it("actual inline helper sends only runtime identities and emits sanitized status, never supplied credential bytes", async () => { +it.each(["none", "two"])("rejects a consumer with %s running runs", async count => { + const f = await fixture(); + f.runs[1]!.status = count === "two" ? "running" : "succeeded"; + if (count === "none") f.runs[0]!.status = "succeeded"; + await expect(readSecretEndpointConsumerRun(f.ctx, "morgan")).rejects.toThrow(); +}); +it("actual inline helper needs only the bound credential and emits sanitized status, never credential bytes", async () => { const f = await fixture(), endpoint = await secretTestEndpoint(f.ctx, "morgan", "synthetic-deployment-value", "DEPLOYMENT_API_KEY", { boundSecretId: "bound", unrelatedCompanyKey: "unrelated-company-key", instanceKey: "custody_probe" }, { taskBound: true }); try { expect(endpoint.scriptPath).toBe(""); expect(endpoint.command).not.toContain(f.cwd); expect(endpoint.command).not.toContain("synthetic-deployment-value"); + expect(endpoint.command).not.toMatch(/PAPERCLIP_|x-paperclip/); const code = endpoint.command.slice("node --input-type=module -e '".length, -1).replace(/'\\''/g, "'"); const output: string[] = [], requests: Array<{ headers: Record }> = []; - const fakeProcess = { env: { DEPLOYMENT_API_KEY: "synthetic-deployment-value", PAPERCLIP_AGENT_ID: "morgan", PAPERCLIP_RUN_ID: f.run.id, PAPERCLIP_TASK_ID: "task", PAPERCLIP_API_KEY: "synthetic-runtime-token" }, cwd: () => f.cwd, stdout: { write: (text: string) => output.push(text) }, exitCode: 0 }; + // A native runner shell: only the configured credential, no Paperclip run identity. + const fakeProcess = { env: { DEPLOYMENT_API_KEY: "synthetic-deployment-value" }, cwd: () => f.cwd, stdout: { write: (text: string) => output.push(text) }, exitCode: 0 }; const execute = new (Object.getPrototypeOf(async () => {}).constructor)("process", "fetch", code); await execute(fakeProcess, async (_url: string, options: { headers: Record; redirect: string }) => { expect(options.redirect).toBe("error"); requests.push(options); return { ok: true, status: 200, json: async () => ({ ok: true, privateData: "synthetic-deployment-value" }) }; }); - expect(requests[0]!.headers).toMatchObject({ "x-paperclip-agent": "morgan", "x-paperclip-run": f.run.id, "x-paperclip-task": "task", "x-paperclip-cwd": f.cwd }); + expect(requests[0]!.headers).toEqual({ authorization: "Bearer synthetic-deployment-value" }); expect(output).toEqual([JSON.stringify({ ok: true, status: 200, check: "publishing endpoint" })]); expect(fakeProcess.exitCode).toBe(0); expect(JSON.stringify({ command: endpoint.command, calls: endpoint.calls, receipts: endpoint.scopeReceipts, output })).not.toContain("synthetic-deployment-value"); } finally { await endpoint.close(); } }); -it("actual endpoint rejects unauthorized scope and wrong cwd without emitting a successful receipt", async () => { +it("actual endpoint binds the server's running Morgan run, probes with its own authority and rejects unauthorized scope", async () => { const f = await fixture(), endpoint = await secretTestEndpoint(f.ctx, "morgan", "synthetic-deployment-value", "DEPLOYMENT_API_KEY", { boundSecretId: "bound", unrelatedCompanyKey: "unrelated-company-key", instanceKey: "custody_probe" }, { taskBound: true }); - const token = `synthetic.${Buffer.from(JSON.stringify({ sub: "morgan", company_id: "company", run_id: f.run.id })).toString("base64url")}.fixture`; - const statuses: number[] = [], output: string[] = []; + const statuses: number[] = [], output: string[] = [], bearers: string[] = []; let deniedStatus = 200; - vi.stubGlobal("fetch", vi.fn(async (url: URL) => new Response(JSON.stringify(url.pathname === "/api/agents/me" ? { id: "morgan", companyId: "company" } : url.pathname === "/api/agents/me/secrets" ? { secrets: [{ key: "deployment-api-key", secretRef: "bound" }] } : {}), { status: url.pathname.endsWith("/value") ? deniedStatus : 200 }))); - const invoke = (cwd: string) => handlers[0]!({ headers: { authorization: "Bearer synthetic-deployment-value", "x-paperclip-agent": "morgan", "x-paperclip-run": f.run.id, "x-paperclip-task": "task", "x-paperclip-cwd": cwd, "x-paperclip-runtime": token }, method: "GET", url: "/publishing-check" }, { writeHead: (status: number) => statuses.push(status), end: (text: string) => output.push(text) }); + vi.stubGlobal("fetch", vi.fn(async (url: URL, init: { headers: Record }) => { bearers.push(init.headers.authorization); return new Response(JSON.stringify(url.pathname === "/api/agents/me" ? { id: "morgan", companyId: "company" } : url.pathname === "/api/agents/me/secrets" ? { secrets: [{ key: "openrouter_api_key", secretRef: "provider" }, { key: "deployment-api-key", secretRef: "bound" }] } : {}), { status: url.pathname.endsWith("/value") ? deniedStatus : 200 }); })); + const invoke = (authorization: string) => handlers[0]!({ headers: { authorization }, method: "GET", url: "/publishing-check" }, { writeHead: (status: number) => statuses.push(status), end: (text: string) => output.push(text) }); try { - await invoke(f.cwd); expect(statuses.at(-1)).toBe(403); - deniedStatus = 403; await invoke(path.dirname(f.cwd)); expect(statuses.at(-1)).toBe(403); - await invoke(f.cwd); expect(statuses.at(-1)).toBe(200); - expect(endpoint.scopeReceipts.at(-1)).toMatchObject({ taskId: "task", workspaceId: "workspace", taskWorkspaceMatched: true, companyDeniedStatus: 403, instanceDeniedStatus: 403 }); + await invoke("Bearer synthetic-deployment-value"); expect(statuses.at(-1)).toBe(403); + deniedStatus = 403; + await invoke("Bearer wrong-value"); expect(statuses.at(-1)).toBe(403); + f.run.status = "succeeded"; await invoke("Bearer synthetic-deployment-value"); expect(statuses.at(-1)).toBe(403); f.run.status = "running"; + await invoke("Bearer synthetic-deployment-value"); expect(statuses.at(-1)).toBe(200); + expect(endpoint.scopeReceipts.at(-1)).toMatchObject({ runId: f.run.id, agentId: "morgan", tokenRunMatched: true, taskId: "task", workspaceId: "workspace", taskWorkspaceMatched: true, catalogKeys: ["openrouter_api_key", "deployment-api-key"], companyDeniedStatus: 403, instanceDeniedStatus: 403 }); + expect(endpoint.calls.filter(call => call.authorized)).toHaveLength(3); + const token = bearers.at(-1)!.slice("Bearer ".length); + const claims = JSON.parse(Buffer.from(token.split(".")[1]!, "base64url").toString("utf8")); + expect(claims).toMatchObject({ sub: "morgan", company_id: "company", run_id: f.run.id, adapter_type: "paperclip_runner", responsible_user_id: "user" }); expect(f.privateSecrets).toContain(token); expect(JSON.stringify({ calls: endpoint.calls, receipts: endpoint.scopeReceipts, output })).not.toContain(token); expect(JSON.stringify({ calls: endpoint.calls, receipts: endpoint.scopeReceipts, output })).not.toContain("synthetic-deployment-value");