diff --git a/server/src/__tests__/heartbeat-local-environment.test.ts b/server/src/__tests__/heartbeat-local-environment.test.ts index cef995d59e8..c45dd159fae 100644 --- a/server/src/__tests__/heartbeat-local-environment.test.ts +++ b/server/src/__tests__/heartbeat-local-environment.test.ts @@ -1,15 +1,16 @@ -import { randomUUID } from "node:crypto"; -import { mkdtemp, readFile } from "node:fs/promises"; +import { createHash, randomUUID } from "node:crypto"; +import { access, mkdtemp, readFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { and, eq, sql } from "drizzle-orm"; -import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest"; +import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest"; import { agents, companies, createDb, environmentLeases, environments, + heartbeatRuns, } from "@paperclipai/db"; import { getEmbeddedPostgresTestSupport, @@ -17,6 +18,32 @@ import { } from "./helpers/embedded-postgres.js"; import { heartbeatService } from "../services/heartbeat.ts"; +// D8.1 legacy heartbeat coverage. `forceRunBearerRegistrationFailure` lets one +// test make the real redaction registry's `register()` reject, so the legacy +// mint site (server/src/services/heartbeat.ts, guarded by +// `nativeRuntimeResolution.kind === "legacy" && adapter.supportsLocalAgentJwt`) +// can be proven to abort dispatch before the adapter process launches. Every +// other test in this file keeps the real registry. +const forceRunBearerRegistrationFailure = vi.hoisted(() => ({ current: false })); +vi.mock("../services/run-secret-redaction.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + createRunSecretRedactionRegistry: (db: Parameters[0]) => { + const real = actual.createRunSecretRedactionRegistry(db); + return { + ...real, + register: async (...args: Parameters) => { + if (forceRunBearerRegistrationFailure.current) { + throw new Error("Heartbeat run redaction registration failed (forced for test)"); + } + return real.register(...args); + }, + }; + }, + }; +}); + const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport(); const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip; @@ -75,6 +102,7 @@ describeEmbeddedPostgres("heartbeat local environment lifecycle", () => { }, 20_000); afterEach(async () => { + forceRunBearerRegistrationFailure.current = false; // A run reaches its terminal status before finalizeRun finishes writing // its trailing lifecycle events and side effects (see the comment on // drainActiveRunExecutions in heartbeat.ts). Drain those in-flight writes @@ -224,4 +252,112 @@ describeEmbeddedPostgres("heartbeat local environment lifecycle", () => { }); expect(captured.apiUrl).toEqual(expect.stringMatching(/^https?:\/\//)); }); + + // D8.1 — mint and register at the legacy adapter mint site + // (server/src/services/heartbeat.ts, guarded by + // `nativeRuntimeResolution.kind === "legacy" && adapter.supportsLocalAgentJwt`). + it("registers the minted run bearer against the correct company and run for the legacy adapter path", async () => { + const companyId = randomUUID(); + const agentId = randomUUID(); + const issuePrefix = `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`; + const tempDir = await mkdtemp(join(tmpdir(), "paperclip-process-register-")); + const tokenPath = join(tempDir, "token.txt"); + + await db.insert(companies).values({ + id: companyId, + name: "Paperclip", + issuePrefix, + requireBoardApprovalForNewAgents: false, + defaultResponsibleUserId: "responsible-user", + }); + + await db.insert(agents).values({ + id: agentId, + companyId, + name: "ProcessAgent", + role: "engineer", + status: "idle", + adapterType: "process", + adapterConfig: { + command: process.execPath, + args: [ + "-e", + [ + "const fs = require('node:fs');", + `fs.writeFileSync(${JSON.stringify(tokenPath)}, process.env.PAPERCLIP_API_KEY ?? "");`, + ].join(" "), + ], + }, + runtimeConfig: {}, + permissions: {}, + }); + + const queued = await heartbeat.invoke(agentId, "on_demand", {}, "manual"); + expect(queued).not.toBeNull(); + + const finished = await waitForRunToFinish(heartbeat, queued!.id); + expect(finished?.status).toBe("succeeded"); + + const injectedToken = await readFile(tokenPath, "utf8"); + expect(injectedToken.length).toBeGreaterThan(0); + + const [row] = await db + .select({ contextSnapshot: heartbeatRuns.contextSnapshot }) + .from(heartbeatRuns) + .where(and(eq(heartbeatRuns.id, queued!.id), eq(heartbeatRuns.companyId, companyId))); + const entries = (row?.contextSnapshot as { paperclipSecretRedactions?: Array<{ fingerprintSha256: string }> } | undefined) + ?.paperclipSecretRedactions ?? []; + expect(entries.map((entry) => entry.fingerprintSha256)) + .toContain(createHash("sha256").update(injectedToken).digest("hex")); + }); + + it("aborts before the adapter process launches when registration fails for the legacy adapter path", async () => { + const companyId = randomUUID(); + const agentId = randomUUID(); + const issuePrefix = `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`; + const tempDir = await mkdtemp(join(tmpdir(), "paperclip-process-abort-")); + const markerPath = join(tempDir, "adapter-launched.marker"); + + await db.insert(companies).values({ + id: companyId, + name: "Paperclip", + issuePrefix, + requireBoardApprovalForNewAgents: false, + defaultResponsibleUserId: "responsible-user", + }); + + await db.insert(agents).values({ + id: agentId, + companyId, + name: "ProcessAgent", + role: "engineer", + status: "idle", + adapterType: "process", + adapterConfig: { + command: process.execPath, + // A registration failure must abort before this process ever spawns. + // If it did spawn, this line alone would create the marker file. + args: ["-e", `require('node:fs').writeFileSync(${JSON.stringify(markerPath)}, "launched")`], + }, + runtimeConfig: {}, + permissions: {}, + }); + + forceRunBearerRegistrationFailure.current = true; + const queued = await heartbeat.invoke(agentId, "on_demand", {}, "manual"); + expect(queued).not.toBeNull(); + + const finished = await waitForRunToFinish(heartbeat, queued!.id); + expect(finished?.status).not.toBe("succeeded"); + + await expect(access(markerPath)).rejects.toThrow(); + + const [row] = await db + .select({ contextSnapshot: heartbeatRuns.contextSnapshot }) + .from(heartbeatRuns) + .where(and(eq(heartbeatRuns.id, queued!.id), eq(heartbeatRuns.companyId, companyId))); + const entries = (row?.contextSnapshot as { paperclipSecretRedactions?: unknown[] } | undefined) + ?.paperclipSecretRedactions ?? []; + expect(entries).toEqual([]); + }); }); diff --git a/server/src/__tests__/run-bearer-mint-site-pinning.test.ts b/server/src/__tests__/run-bearer-mint-site-pinning.test.ts new file mode 100644 index 00000000000..4fa8305465a --- /dev/null +++ b/server/src/__tests__/run-bearer-mint-site-pinning.test.ts @@ -0,0 +1,127 @@ +import { mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { describe, expect, it } from "vitest"; + +// `createLocalAgentJwt` (server/src/agent-auth-jwt.ts) mints a run bearer but +// does not register it with the redaction registry. A direct call site mints +// a bearer that no later read path can mask. `mintAndRegisterRunBearer` +// (server/src/services/run-bearer.ts) is the only call path that mints and +// registers together, so every production caller must go through it. +// +// This scan reads the repository source and reports each production line +// that calls `createLocalAgentJwt` outside the wrapper file. It follows the +// source-scanning style of `cli-invocation-safety.test.ts`. + +const here = path.dirname(fileURLToPath(import.meta.url)); +const srcRoot = path.resolve(here, ".."); + +const DEFINITION_FILE = "agent-auth-jwt.ts"; +const WRAPPER_FILE = path.join("services", "run-bearer.ts"); + +const CALL_PATTERN = /\bcreateLocalAgentJwt\s*\(/; +const DECLARATION_PATTERN = /\bfunction\s+createLocalAgentJwt\s*\(/; + +function isTestFile(relPath: string): boolean { + return relPath.includes("__tests__/") || /\.(test|spec)\.[tj]sx?$/.test(relPath); +} + +function listSourceFiles(rootDir: string): string[] { + const found: string[] = []; + + function walk(absDir: string, relDir: string): void { + for (const entry of readdirSync(absDir, { withFileTypes: true })) { + if (entry.isSymbolicLink()) continue; + const relPath = relDir ? `${relDir}/${entry.name}` : entry.name; + if (entry.isDirectory()) { + if (entry.name === "node_modules" || entry.name === "dist") continue; + walk(path.join(absDir, entry.name), relPath); + continue; + } + if (!/\.(ts|tsx)$/.test(entry.name)) continue; + found.push(relPath); + } + } + + walk(rootDir, ""); + return found; +} + +// Return one `path:line` entry for each production line that calls +// `createLocalAgentJwt` outside the wrapper file. The function definition +// line and every unit test file are exempt. +function findMintSiteOffenders(rootDir: string): string[] { + const offenders: string[] = []; + for (const relPath of listSourceFiles(rootDir)) { + const normalized = relPath.split(path.sep).join("/"); + if (normalized === DEFINITION_FILE) continue; + if (normalized === WRAPPER_FILE.split(path.sep).join("/")) continue; + if (isTestFile(normalized)) continue; + const text = readFileSync(path.join(rootDir, relPath), "utf8"); + text.split("\n").forEach((line, index) => { + if (CALL_PATTERN.test(line) && !DECLARATION_PATTERN.test(line)) { + offenders.push(`${normalized}:${index + 1}`); + } + }); + } + return offenders; +} + +describe("createLocalAgentJwt mint-site pinning", () => { + it("has no caller in production source outside the register-first wrapper", () => { + const offenders = findMintSiteOffenders(srcRoot); + expect( + offenders, + "createLocalAgentJwt must be called only from server/src/services/run-bearer.ts. " + + `A direct caller mints a bearer that the redaction registry never learns about:\n${offenders.join("\n")}`, + ).toEqual([]); + }); + + it("fails on a tree that still holds a direct call outside the wrapper", () => { + // Prove the scanner can fail. Build a fixture tree with a production file + // that calls createLocalAgentJwt directly, mirroring the old direct-call + // shape this task replaced at all three mint sites. + const root = mkdtempSync(path.join(os.tmpdir(), "run-bearer-pinning-")); + try { + const offenderDir = path.join(root, "services", "native-runtime"); + mkdirSync(offenderDir, { recursive: true }); + writeFileSync( + path.join(offenderDir, "paperclip-runner-tool-authority.ts"), + [ + 'import { createLocalAgentJwt } from "../../agent-auth-jwt.js";', + "", + "const token = createLocalAgentJwt(agentId, companyId, adapterType, runId, responsibleUserId);", + "", + ].join("\n"), + ); + const offenders = findMintSiteOffenders(root); + expect(offenders).toEqual(["services/native-runtime/paperclip-runner-tool-authority.ts:3"]); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + it("exempts the wrapper file and unit tests from the scan", () => { + const root = mkdtempSync(path.join(os.tmpdir(), "run-bearer-pinning-allow-")); + try { + mkdirSync(path.join(root, "services"), { recursive: true }); + writeFileSync( + path.join(root, "services", "run-bearer.ts"), + 'const token = createLocalAgentJwt(agentId, companyId, adapterType, runId, responsibleUserId, keyScope);\n', + ); + mkdirSync(path.join(root, "__tests__"), { recursive: true }); + writeFileSync( + path.join(root, "__tests__", "run-bearer.test.ts"), + 'const token = createLocalAgentJwt("agent-1", "company-1", "claude_local", "run-1");\n', + ); + writeFileSync( + path.join(root, "agent-auth-jwt.ts"), + "export function createLocalAgentJwt(agentId, companyId, adapterType, runId) {}\n", + ); + expect(findMintSiteOffenders(root)).toEqual([]); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); +}); diff --git a/server/src/__tests__/run-secret-redaction.test.ts b/server/src/__tests__/run-secret-redaction.test.ts index a4f73bf15ca..97aaff09318 100644 --- a/server/src/__tests__/run-secret-redaction.test.ts +++ b/server/src/__tests__/run-secret-redaction.test.ts @@ -1,8 +1,10 @@ +import { createHash } from "node:crypto"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { REDACTED_EVENT_VALUE } from "../redaction.js"; import type { Db } from "@paperclipai/db"; import { PgDialect } from "drizzle-orm/pg-core"; import { createRunSecretRedactionRegistry, redactRegisteredSecretValues } from "../services/run-secret-redaction.js"; +import { mintAndRegisterRunBearer } from "../services/run-bearer.js"; const secret = "q2a-exact-secret-value"; @@ -79,8 +81,18 @@ describe("registered run secret redaction", () => { }); }); -const { resolveVersion } = vi.hoisted(() => ({ resolveVersion: vi.fn(async ({ material }) => material.value as string) })); -vi.mock("../secrets/provider-registry.js", () => ({ getSecretProvider: () => ({ resolveVersion }) })); +const { resolveVersion, createSecret } = vi.hoisted(() => ({ + resolveVersion: vi.fn(async ({ material }) => material.value as string), + createSecret: vi.fn(async ({ value }: { value: string }) => ({ + material: { value }, + valueSha256: createHash("sha256").update(value).digest("hex"), + externalRef: null, + })), +})); +vi.mock("../secrets/provider-registry.js", () => ({ getSecretProvider: () => ({ resolveVersion, createSecret }) })); + +const { mintLocalAgentJwt } = vi.hoisted(() => ({ mintLocalAgentJwt: vi.fn() })); +vi.mock("../agent-auth-jwt.js", () => ({ createLocalAgentJwt: mintLocalAgentJwt })); describe("batched run secret redaction", () => { beforeEach(() => { resolveVersion.mockClear(); }); @@ -125,3 +137,99 @@ describe("batched run secret redaction", () => { await expect(registry.redactForRuns("company", [{ id: "a", text: secret }])).rejects.toThrow("unavailable"); }); }); + +// D8.1 — mint and register. `mintAndRegisterRunBearer` (server/src/services/run-bearer.js) +// is the wrapper every mint site must call instead of `createLocalAgentJwt` +// directly, so a minted bearer always reaches the redaction registry before a +// caller can use it. +describe("mintAndRegisterRunBearer", () => { + const companyId = "company-1"; + const runId = "run-1"; + + beforeEach(() => { + mintLocalAgentJwt.mockReset(); + createSecret.mockClear(); + }); + + // A fake `Db` that only implements the `transaction` shape `register()` + // needs: a locked select of the run row, then an update of its + // `contextSnapshot`. `row` is the current row seen inside the transaction, + // or `null` to exercise the missing-run failure path. + function fakeDb(row: { contextSnapshot: unknown } | null) { + const selectWhere = vi.fn(() => ({ for: () => Promise.resolve(row ? [row] : []) })); + const updateWhere = vi.fn(async () => {}); + const tx = { + select: () => ({ from: () => ({ where: selectWhere }) }), + update: () => ({ set: (values: unknown) => ({ where: (predicate: unknown) => updateWhere(values, predicate) }) }), + }; + const db = { transaction: async (callback: (tx: unknown) => Promise) => callback(tx) }; + return { db: db as unknown as Db, selectWhere, updateWhere }; + } + + it("registers the minted bearer against the given company and run, then returns it", async () => { + const token = "minted-run-bearer-token"; + mintLocalAgentJwt.mockReturnValue(token); + const { db, selectWhere, updateWhere } = fakeDb({ contextSnapshot: {} }); + + const result = await mintAndRegisterRunBearer(db, "agent-1", companyId, "claude_local", runId, "user-1"); + + expect(result).toBe(token); + expect(selectWhere).toHaveBeenCalledTimes(1); + const dialect = new PgDialect(); + const selectPredicate = dialect.sqlToQuery(selectWhere.mock.calls[0][0] as Parameters[0]); + expect(selectPredicate.params).toContain(companyId); + expect(selectPredicate.params).toContain(runId); + + expect(updateWhere).toHaveBeenCalledTimes(1); + const [values] = updateWhere.mock.calls[0] as [{ contextSnapshot: { paperclipSecretRedactions: Array<{ fingerprintSha256: string }> } }, unknown]; + expect(values.contextSnapshot.paperclipSecretRedactions).toHaveLength(1); + expect(values.contextSnapshot.paperclipSecretRedactions[0].fingerprintSha256) + .toBe(createHash("sha256").update(token).digest("hex")); + expect(createSecret).toHaveBeenCalledWith({ value: token }); + }); + + it("passes the sixth scope argument through to the mint call", async () => { + mintLocalAgentJwt.mockReturnValue("token-with-scope"); + const { db } = fakeDb({ contextSnapshot: {} }); + const scope = { kind: "skill_test" as const, issueId: "issue-1" }; + + await mintAndRegisterRunBearer(db, "agent-1", companyId, "claude_local", runId, "user-1", scope); + + expect(mintLocalAgentJwt).toHaveBeenCalledWith("agent-1", companyId, "claude_local", runId, "user-1", scope); + }); + + it("returns null and registers nothing on the null-mint configuration path", async () => { + mintLocalAgentJwt.mockReturnValue(null); + const { db, selectWhere, updateWhere } = fakeDb({ contextSnapshot: {} }); + + const result = await mintAndRegisterRunBearer(db, "agent-1", companyId, "claude_local", runId, "user-1"); + + expect(result).toBeNull(); + expect(selectWhere).not.toHaveBeenCalled(); + expect(updateWhere).not.toHaveBeenCalled(); + }); + + it("propagates a registration failure and returns no token", async () => { + mintLocalAgentJwt.mockReturnValue("token-that-cannot-register"); + // `row: null` reproduces the registry's own missing-run failure + // (server/src/services/run-secret-redaction.ts:103). + const { db, updateWhere } = fakeDb(null); + + await expect( + mintAndRegisterRunBearer(db, "agent-1", companyId, "claude_local", runId, "user-1"), + ).rejects.toThrow("Heartbeat run redaction registration failed"); + expect(updateWhere).not.toHaveBeenCalled(); + }); + + it("never surfaces the minted token text in the registration failure", async () => { + mintLocalAgentJwt.mockReturnValue("must-not-leak-in-error-token"); + const { db } = fakeDb(null); + + await expect( + mintAndRegisterRunBearer(db, "agent-1", companyId, "claude_local", runId, "user-1"), + ).rejects.toSatisfy((error: unknown) => { + const message = error instanceof Error ? error.message : String(error); + return !message.includes("must-not-leak-in-error-token"); + }); + }); +}); diff --git a/server/src/services/heartbeat.ts b/server/src/services/heartbeat.ts index 3efdbbf5cd8..c7180d8a2d4 100644 --- a/server/src/services/heartbeat.ts +++ b/server/src/services/heartbeat.ts @@ -261,7 +261,7 @@ import type { AdapterSessionCodec, UsageSummary, } from "../adapters/index.js"; -import { createLocalAgentJwt } from "../agent-auth-jwt.js"; +import { mintAndRegisterRunBearer } from "./run-bearer.js"; import { createRuntimeToolsToken } from "../runtime-tools-token.js"; import { parseObject, @@ -23479,7 +23479,8 @@ export function heartbeatService( const authToken = nativeRuntimeResolution.kind === "legacy" && adapter.supportsLocalAgentJwt - ? createLocalAgentJwt( + ? await mintAndRegisterRunBearer( + db, agent.id, agent.companyId, agent.adapterType, diff --git a/server/src/services/native-runtime/paperclip-runner-tool-authority.test.ts b/server/src/services/native-runtime/paperclip-runner-tool-authority.test.ts index ea778e8ea9a..9f2423a3ec3 100644 --- a/server/src/services/native-runtime/paperclip-runner-tool-authority.test.ts +++ b/server/src/services/native-runtime/paperclip-runner-tool-authority.test.ts @@ -1,5 +1,5 @@ -import { afterAll, beforeAll, describe, expect, it } from "vitest"; -import { randomUUID } from "node:crypto"; +import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest"; +import { createHash, randomUUID } from "node:crypto"; import { eq } from "drizzle-orm"; import { activityLog, @@ -22,6 +22,50 @@ import { PaperclipRunnerToolAuthority } from "./paperclip-runner-tool-authority. import { READ_CURRENT_WAKE_COMMENTS_TOOL_NAME } from "./current-wake-comments.js"; import { CAPABILITY_SEMANTIC_TOOL_CATALOG } from "../../vendor/paperclip-runner/index.js"; +// D8.1 fixtures for the two native-runtime mint sites (create_project and +// call_api). `forceRunBearerRegistrationFailure` lets a single test make the +// real registry's `register()` reject, without disturbing the real +// implementation the other tests in this file rely on. +const forceRunBearerRegistrationFailure = vi.hoisted(() => ({ current: false })); +vi.mock("../run-secret-redaction.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + createRunSecretRedactionRegistry: (db: Parameters[0]) => { + const real = actual.createRunSecretRedactionRegistry(db); + return { + ...real, + register: async (...args: Parameters) => { + if (forceRunBearerRegistrationFailure.current) { + throw new Error("Heartbeat run redaction registration failed (forced for test)"); + } + return real.register(...args); + }, + }; + }, + }; +}); + +const capturedProjectToolCalls = vi.hoisted(() => [] as Array<{ token: string; apiUrl: string }>); +vi.mock("../project-tools.js", () => ({ + callProjectTool: vi.fn(async (call: { token: string; apiUrl: string }) => { + capturedProjectToolCalls.push({ token: call.token, apiUrl: call.apiUrl }); + return { tool: "project_tool_stub_result" }; + }), +})); + +const capturedRunnerApiCalls = vi.hoisted(() => [] as Array<{ token: string; apiUrl: string }>); +vi.mock("./runner-api-client.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + executeRunnerApi: vi.fn(async (_input: unknown, _context: unknown, io: { token: string; apiUrl: string }) => { + capturedRunnerApiCalls.push({ token: io.token, apiUrl: io.apiUrl }); + return { status: 200, data: {}, headers: {} }; + }), + }; +}); + describe("PaperclipRunnerToolAuthority", () => { let temporary: Awaited< ReturnType @@ -31,8 +75,10 @@ describe("PaperclipRunnerToolAuthority", () => { const agentId = "00000000-0000-4000-8000-000000000102"; const issueId = "00000000-0000-4000-8000-000000000103"; const runId = "00000000-0000-4000-8000-000000000104"; + const previousAgentJwtSecret = process.env.PAPERCLIP_AGENT_JWT_SECRET; beforeAll(async () => { + process.env.PAPERCLIP_AGENT_JWT_SECRET = "paperclip-runner-tools-test-secret"; temporary = await startEmbeddedPostgresTestDatabase( "paperclip-runner-tools-", ); @@ -81,6 +127,8 @@ describe("PaperclipRunnerToolAuthority", () => { afterAll(async () => { await temporary?.cleanup(); + if (previousAgentJwtSecret === undefined) delete process.env.PAPERCLIP_AGENT_JWT_SECRET; + else process.env.PAPERCLIP_AGENT_JWT_SECRET = previousAgentJwtSecret; }); it("advertises only real bindings and reads the bound task", async () => { @@ -1074,6 +1122,122 @@ describe("PaperclipRunnerToolAuthority", () => { expect(interactions.find((row) => row.title === "Approve continuation")?.sourceIdentityContextId).toBe(pending!.id); }); + // D8.1 — mint and register at the two native-runtime mint sites. Both + // `create_project` and `call_api` must call `mintAndRegisterRunBearer` + // (server/src/services/run-bearer.ts) instead of `createLocalAgentJwt` + // directly, so the minted bearer reaches the redaction registry before the + // adapter-facing tool call dispatches, and never dispatches at all when + // registration fails. + describe("mint-and-register wrapper at the native-runtime mint sites", () => { + const previousRunnerApiToolsEnabled = process.env.PAPERCLIP_RUNNER_API_TOOLS_ENABLED; + + beforeAll(() => { + // call_api is advertised only under this operator rollout flag. + process.env.PAPERCLIP_RUNNER_API_TOOLS_ENABLED = "true"; + }); + + afterAll(() => { + if (previousRunnerApiToolsEnabled === undefined) delete process.env.PAPERCLIP_RUNNER_API_TOOLS_ENABLED; + else process.env.PAPERCLIP_RUNNER_API_TOOLS_ENABLED = previousRunnerApiToolsEnabled; + }); + + afterEach(() => { + forceRunBearerRegistrationFailure.current = false; + }); + + async function freshRunFixture(label: string) { + const localIssueId = randomUUID(); + const localRunId = randomUUID(); + await db.insert(issues).values({ + id: localIssueId, companyId, title: `Mint site fixture: ${label}`, + status: "in_progress", workMode: "standard", assigneeAgentId: agentId, + }); + await db.insert(heartbeatRuns).values({ + id: localRunId, companyId, agentId, status: "running", runtimeMode: "native", + nativeIssueId: localIssueId, invocationSource: "assignment", triggerDetail: "system", + contextSnapshot: { issueId: localIssueId }, + }); + await db.update(issues).set({ executionRunId: localRunId }).where(eq(issues.id, localIssueId)); + return { issueId: localIssueId, runId: localRunId }; + } + + async function registeredFingerprints(forRunId: string): Promise { + const [row] = await db.select({ contextSnapshot: heartbeatRuns.contextSnapshot }) + .from(heartbeatRuns).where(eq(heartbeatRuns.id, forRunId)); + const entries = (row?.contextSnapshot as { paperclipSecretRedactions?: Array<{ fingerprintSha256: string }> } | undefined) + ?.paperclipSecretRedactions ?? []; + return entries.map((entry) => entry.fingerprintSha256); + } + + it("registers the minted bearer against the correct company and run at the create_project mint site", async () => { + const fixture = await freshRunFixture("create_project-registers"); + const authority = new PaperclipRunnerToolAuthority(db, { + companyId, agentId, issueId: fixture.issueId, runId: fixture.runId, apiUrl: "http://127.0.0.1:1", + }); + const before = capturedProjectToolCalls.length; + + await authority.execute({ tool: "list_projects", callId: "mint-create-project", arguments: {} }); + + expect(capturedProjectToolCalls).toHaveLength(before + 1); + const { token } = capturedProjectToolCalls[before]; + const fingerprint = createHash("sha256").update(token).digest("hex"); + expect(await registeredFingerprints(fixture.runId)).toContain(fingerprint); + }); + + it("aborts before the project tool dispatches when registration fails at the create_project mint site", async () => { + const fixture = await freshRunFixture("create_project-aborts"); + const authority = new PaperclipRunnerToolAuthority(db, { + companyId, agentId, issueId: fixture.issueId, runId: fixture.runId, apiUrl: "http://127.0.0.1:1", + }); + const before = capturedProjectToolCalls.length; + forceRunBearerRegistrationFailure.current = true; + + await expect( + authority.execute({ tool: "list_projects", callId: "mint-create-project-fail", arguments: {} }), + ).rejects.toThrow("Heartbeat run redaction registration failed (forced for test)"); + + expect(capturedProjectToolCalls).toHaveLength(before); + expect(await registeredFingerprints(fixture.runId)).toEqual([]); + }); + + it("registers the minted bearer against the correct company and run at the call_api mint site", async () => { + const fixture = await freshRunFixture("call_api-registers"); + const authority = new PaperclipRunnerToolAuthority(db, { + companyId, agentId, issueId: fixture.issueId, runId: fixture.runId, apiUrl: "http://127.0.0.1:1", + }); + const before = capturedRunnerApiCalls.length; + + await authority.execute({ + tool: "call_api", callId: "mint-call-api", + arguments: { operationId: "GET /api/companies/{companyId}/projects" }, + }); + + expect(capturedRunnerApiCalls).toHaveLength(before + 1); + const { token } = capturedRunnerApiCalls[before]; + const fingerprint = createHash("sha256").update(token).digest("hex"); + expect(await registeredFingerprints(fixture.runId)).toContain(fingerprint); + }); + + it("aborts before the API call dispatches when registration fails at the call_api mint site", async () => { + const fixture = await freshRunFixture("call_api-aborts"); + const authority = new PaperclipRunnerToolAuthority(db, { + companyId, agentId, issueId: fixture.issueId, runId: fixture.runId, apiUrl: "http://127.0.0.1:1", + }); + const before = capturedRunnerApiCalls.length; + forceRunBearerRegistrationFailure.current = true; + + await expect( + authority.execute({ + tool: "call_api", callId: "mint-call-api-fail", + arguments: { operationId: "GET /api/companies/{companyId}/projects" }, + }), + ).rejects.toThrow("Heartbeat run redaction registration failed (forced for test)"); + + expect(capturedRunnerApiCalls).toHaveLength(before); + expect(await registeredFingerprints(fixture.runId)).toEqual([]); + }); + }); + it("fails closed once the run is no longer active", async () => { await db .update(heartbeatRuns) diff --git a/server/src/services/native-runtime/paperclip-runner-tool-authority.ts b/server/src/services/native-runtime/paperclip-runner-tool-authority.ts index cce1e6b1fa9..bb3b42a8a83 100644 --- a/server/src/services/native-runtime/paperclip-runner-tool-authority.ts +++ b/server/src/services/native-runtime/paperclip-runner-tool-authority.ts @@ -13,7 +13,7 @@ import { import { runnerApiToolsEnabled } from "./runner-api-rollout.js"; import { openRunnerApiWorkspaceFile } from "./runner-api-files.js"; import { basename } from "node:path"; -import { createLocalAgentJwt } from "../../agent-auth-jwt.js"; +import { mintAndRegisterRunBearer } from "../run-bearer.js"; import { getStorageService } from "../../storage/index.js"; import type { StorageService } from "../../storage/types.js"; import { assetService } from "../assets.js"; @@ -313,7 +313,7 @@ export class PaperclipRunnerToolAuthority { case "list_project_repositories": case "list_projects": { const apiUrl = this.binding.apiUrl ?? process.env.PAPERCLIP_API_URL; - const token = createLocalAgentJwt(this.binding.agentId, this.binding.companyId, context.actor.adapterType, this.binding.runId, context.run.responsibleUserId); + const token = await mintAndRegisterRunBearer(this.db, this.binding.agentId, this.binding.companyId, context.actor.adapterType, this.binding.runId, context.run.responsibleUserId); if (!apiUrl || !token) throw new Error("Project tool authentication is unavailable"); return callProjectTool({ name: call.tool, arguments: input, apiUrl, token, companyId: this.binding.companyId, issueId: this.binding.issueId, agentId: this.binding.agentId, @@ -417,7 +417,7 @@ export class PaperclipRunnerToolAuthority { const { input, operation } = validateRunnerApiCall(value, context); const apiUrl = this.binding.apiUrl ?? process.env.PAPERCLIP_API_URL; if (!apiUrl) throw new Error("Paperclip API origin is unavailable"); - const token = createLocalAgentJwt(this.binding.agentId, this.binding.companyId, bound.actor.adapterType, this.binding.runId, bound.run.responsibleUserId); + const token = await mintAndRegisterRunBearer(this.db, this.binding.agentId, this.binding.companyId, bound.actor.adapterType, this.binding.runId, bound.run.responsibleUserId); if (!token) throw new Error("Paperclip run authentication is unavailable"); const execute = async () => { const current = await this.#boundContext(); diff --git a/server/src/services/run-bearer.ts b/server/src/services/run-bearer.ts new file mode 100644 index 00000000000..f0255e2cd0c --- /dev/null +++ b/server/src/services/run-bearer.ts @@ -0,0 +1,33 @@ +import type { Db } from "@paperclipai/db"; +import type { AgentApiKeyScope } from "@paperclipai/shared"; +import { createLocalAgentJwt } from "../agent-auth-jwt.js"; +import { createRunSecretRedactionRegistry } from "./run-secret-redaction.js"; + +/** + * Mint the run bearer and register it before any caller can use it. + * + * This is the only allowed call path to `createLocalAgentJwt`. A caller must + * not call `createLocalAgentJwt` directly, because a minted bearer that is + * not registered cannot be masked on any later read of run or issue text. + * + * When the mint returns a token, registration must complete before this + * function returns one. A registration failure throws and returns no token, + * so a caller must not launch a process or inject a bearer on that failure. + * + * When the mint returns `null` (no JWT secret configured), this function + * returns `null` and registers nothing. That is not a failure. + */ +export async function mintAndRegisterRunBearer( + db: Db, + agentId: string, + companyId: string, + adapterType: string, + runId: string, + responsibleUserId?: string | null, + keyScope?: AgentApiKeyScope, +): Promise { + const token = createLocalAgentJwt(agentId, companyId, adapterType, runId, responsibleUserId, keyScope); + if (!token) return null; + await createRunSecretRedactionRegistry(db).register(companyId, runId, token); + return token; +}