diff --git a/server/src/__tests__/qa-routine-secrets-e2e.test.ts b/server/src/__tests__/qa-routine-secrets-e2e.test.ts new file mode 100644 index 00000000000..43d7910ac14 --- /dev/null +++ b/server/src/__tests__/qa-routine-secrets-e2e.test.ts @@ -0,0 +1,458 @@ +// QA validation for [PAP-9522](/PAP/issues/PAP-9522). Drives the routine-secret +// chain end-to-end against a real embedded Postgres: +// +// 1. Routine env reaches the heartbeat runtime via `resolveExecutionRunAdapterConfig` +// using `secretsSvc.resolveEnvBindings` with a `consumerType: "routine"` context, +// even when the executing agent has zero direct bindings for that secret. +// 2. Precedence: agent < project < routine for a shared key. +// 3. `secret_access_events` records routine consumption but NEVER the resolved value. +// 4. Restoring an older revision re-syncs `company_secret_bindings` to the snapshot env. +// 5. Legacy fallback: a routine_run with null `routine_revision_id` still resolves +// the routine's current env (matches the explicit acceptance criterion). +// 6. Disabled / missing / cross-company secret bindings fail clearly without +// echoing the value. + +import { randomUUID } from "node:crypto"; +import { mkdirSync, rmSync } from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { eq, and } from "drizzle-orm"; +import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest"; +import { + agents, + companies, + companySecretBindings, + companySecrets, + companySecretVersions, + createDb, + projects, + routineRuns, + routines, + secretAccessEvents, +} from "@paperclipai/db"; +import { + getEmbeddedPostgresTestSupport, + startEmbeddedPostgresTestDatabase, +} from "./helpers/embedded-postgres.js"; +import { routineService } from "../services/routines.ts"; +import { secretService } from "../services/secrets.ts"; +import { resolveExecutionRunAdapterConfig } from "../services/heartbeat.ts"; + +const support = await getEmbeddedPostgresTestSupport(); +const describeEmbedded = support.supported ? describe : describe.skip; +if (!support.supported) { + console.warn(`Skipping QA e2e on this host: ${support.reason ?? "embedded pg unsupported"}`); +} + +describeEmbedded("PAP-9522 QA: routine secrets end-to-end", () => { + let db!: ReturnType; + let tempDb: Awaited> | null = null; + const secretsTmpDir = path.join(os.tmpdir(), `paperclip-qa-routine-secrets-${randomUUID()}`); + const previousKeyFile = process.env.PAPERCLIP_SECRETS_MASTER_KEY_FILE; + + beforeAll(async () => { + mkdirSync(secretsTmpDir, { recursive: true }); + process.env.PAPERCLIP_SECRETS_MASTER_KEY_FILE = path.join(secretsTmpDir, "master.key"); + tempDb = await startEmbeddedPostgresTestDatabase("paperclip-qa-routine-secrets-"); + db = createDb(tempDb.connectionString); + }, 30_000); + + afterEach(async () => { + await db.delete(secretAccessEvents); + await db.delete(companySecretBindings); + await db.delete(routineRuns); + await db.delete(routines); + await db.delete(companySecretVersions); + await db.delete(companySecrets); + await db.delete(projects); + await db.delete(agents); + await db.delete(companies); + }); + + afterAll(async () => { + await tempDb?.cleanup(); + if (previousKeyFile === undefined) delete process.env.PAPERCLIP_SECRETS_MASTER_KEY_FILE; + else process.env.PAPERCLIP_SECRETS_MASTER_KEY_FILE = previousKeyFile; + rmSync(secretsTmpDir, { recursive: true, force: true }); + }); + + async function seed() { + const companyId = randomUUID(); + const executorAgentId = randomUUID(); + const issuePrefix = `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`; + await db.insert(companies).values({ + id: companyId, + name: "QA Co", + issuePrefix, + requireBoardApprovalForNewAgents: false, + }); + // Note: executor agent has NO secret bindings of its own — this is the + // whole point of routine env (the secret rides with the routine, not the agent). + await db.insert(agents).values({ + id: executorAgentId, + companyId, + name: "Executor", + role: "engineer", + status: "active", + adapterType: "codex_local", + adapterConfig: { env: {} }, + runtimeConfig: {}, + permissions: {}, + }); + return { companyId, executorAgentId }; + } + + const ROUTINE_VALUE = "super-sekret-routine-value"; + const PROJECT_VALUE = "project-overlay-value"; + const AGENT_VALUE = "agent-base-value"; + + it("resolves routine env for an executing agent that has no direct binding, with routine winning precedence and zero value in access events", async () => { + const { companyId, executorAgentId } = await seed(); + const secrets = secretService(db); + const routines = routineService(db, { heartbeat: { wakeup: async () => null } }); + + const secret = await secrets.create(companyId, { + name: `routine-api-${randomUUID()}`, + provider: "local_encrypted", + value: ROUTINE_VALUE, + }); + + const routine = await routines.create( + companyId, + { + projectId: null, + goalId: null, + parentIssueId: null, + title: "qa routine", + description: null, + assigneeAgentId: executorAgentId, + priority: "medium", + status: "active", + concurrencyPolicy: "coalesce_if_active", + catchUpPolicy: "skip_missed", + env: { + SHARED: { type: "plain", value: "routine-overrides" }, + ROUTINE_API_KEY: { type: "secret_ref", secretId: secret.id, version: "latest" }, + }, + }, + {}, + ); + + // Verify binding is owned by the routine, not the executing agent. + const bindings = await db + .select() + .from(companySecretBindings) + .where(eq(companySecretBindings.targetId, routine.id)); + expect(bindings).toMatchObject([ + { targetType: "routine", secretId: secret.id, configPath: "env.ROUTINE_API_KEY" }, + ]); + + // Drive the real heartbeat resolution path with the routine env. + // issueId/heartbeatRunId left null because secret_access_events has FK + // constraints on both — populating them would require seeding issue and + // heartbeat_run rows just for FK validity. The routine consumer fields are + // what this test cares about. + const result = await resolveExecutionRunAdapterConfig({ + companyId, + agentId: executorAgentId, + issueId: null, + heartbeatRunId: null, + projectId: null, + routineId: routine.id, + executionRunConfig: { env: { SHARED: AGENT_VALUE, AGENT_ONLY: AGENT_VALUE } }, + projectEnv: { SHARED: { type: "plain", value: PROJECT_VALUE } }, + routineEnv: routine.env, + secretsSvc: secrets, + }); + + expect(result.resolvedConfig.env).toMatchObject({ + AGENT_ONLY: AGENT_VALUE, + SHARED: "routine-overrides", // routine beats project beats agent + ROUTINE_API_KEY: ROUTINE_VALUE, + }); + expect(result.secretKeys.has("ROUTINE_API_KEY")).toBe(true); + expect(result.secretManifest.some((m) => m.envKey === "ROUTINE_API_KEY")).toBe(true); + // Manifest must not echo the resolved value. + expect(JSON.stringify(result.secretManifest)).not.toContain(ROUTINE_VALUE); + + const events = await db + .select() + .from(secretAccessEvents) + .where(eq(secretAccessEvents.secretId, secret.id)); + expect(events).toHaveLength(1); + expect(events[0]).toMatchObject({ + consumerType: "routine", + consumerId: routine.id, + actorType: "agent", + actorId: executorAgentId, + configPath: "env.ROUTINE_API_KEY", + outcome: "success", + }); + // No serialized field of the access event row can contain the secret value. + expect(JSON.stringify(events[0])).not.toContain(ROUTINE_VALUE); + }); + + it("rejects routine env that references a secret from a different company", async () => { + const { companyId } = await seed(); + const { companyId: otherCompanyId } = await seed(); + const secrets = secretService(db); + const routines = routineService(db, { heartbeat: { wakeup: async () => null } }); + + const foreignSecret = await secrets.create(otherCompanyId, { + name: `foreign-${randomUUID()}`, + provider: "local_encrypted", + value: "cross-company-leak-bait", + }); + + await expect( + routines.create( + companyId, + { + projectId: null, + goalId: null, + parentIssueId: null, + title: "cross company", + description: null, + assigneeAgentId: null, + priority: "medium", + status: "paused", + concurrencyPolicy: "coalesce_if_active", + catchUpPolicy: "skip_missed", + env: { + BAD: { type: "secret_ref", secretId: foreignSecret.id, version: "latest" }, + }, + }, + {}, + ), + ).rejects.toThrow(/same company/i); + }); + + it("surfaces a clear, value-free error when a routine secret is missing/deleted at resolution time", async () => { + const { companyId, executorAgentId } = await seed(); + const secrets = secretService(db); + const routines = routineService(db, { heartbeat: { wakeup: async () => null } }); + + const secret = await secrets.create(companyId, { + name: `to-be-deleted-${randomUUID()}`, + provider: "local_encrypted", + value: "doomed-secret-value", + }); + + const routine = await routines.create( + companyId, + { + projectId: null, + goalId: null, + parentIssueId: null, + title: "doomed routine", + description: null, + assigneeAgentId: executorAgentId, + priority: "medium", + status: "active", + concurrencyPolicy: "coalesce_if_active", + catchUpPolicy: "skip_missed", + env: { + DOOMED: { type: "secret_ref", secretId: secret.id, version: "latest" }, + }, + }, + {}, + ); + + // Hard delete the secret out from under the routine; the routine env now + // points at a vanished id. + await secrets.remove(secret.id); + + let caught: unknown = null; + try { + await resolveExecutionRunAdapterConfig({ + companyId, + agentId: executorAgentId, + issueId: null, + heartbeatRunId: null, + projectId: null, + routineId: routine.id, + executionRunConfig: { env: {} }, + projectEnv: null, + routineEnv: routine.env, + secretsSvc: secrets, + }); + } catch (error) { + caught = error; + } + expect(caught).toBeTruthy(); + const message = String((caught as Error)?.message ?? caught); + expect(message).not.toContain("doomed-secret-value"); + }); + + it("restoring an older revision re-syncs company_secret_bindings to the snapshot env", async () => { + const { companyId, executorAgentId } = await seed(); + const secrets = secretService(db); + const routines = routineService(db, { heartbeat: { wakeup: async () => null } }); + + const secretA = await secrets.create(companyId, { + name: `a-${randomUUID()}`, + provider: "local_encrypted", + value: "val-a", + }); + const secretB = await secrets.create(companyId, { + name: `b-${randomUUID()}`, + provider: "local_encrypted", + value: "val-b", + }); + + const routine = await routines.create( + companyId, + { + projectId: null, + goalId: null, + parentIssueId: null, + title: "restore routine", + description: null, + assigneeAgentId: executorAgentId, + priority: "medium", + status: "active", + concurrencyPolicy: "coalesce_if_active", + catchUpPolicy: "skip_missed", + env: { + ALPHA: { type: "secret_ref", secretId: secretA.id, version: "latest" }, + }, + }, + {}, + ); + const rev1Id = routine.latestRevisionId!; + + await routines.update( + routine.id, + { + env: { + ALPHA: { type: "secret_ref", secretId: secretA.id, version: "latest" }, + BETA: { type: "secret_ref", secretId: secretB.id, version: "latest" }, + }, + }, + {}, + ); + + let bindings = await db + .select() + .from(companySecretBindings) + .where(eq(companySecretBindings.targetId, routine.id)); + expect(bindings.map((b) => b.configPath).sort()).toEqual(["env.ALPHA", "env.BETA"]); + + await routines.restoreRevision(routine.id, rev1Id, {}); + + bindings = await db + .select() + .from(companySecretBindings) + .where(eq(companySecretBindings.targetId, routine.id)); + expect(bindings.map((b) => b.configPath)).toEqual(["env.ALPHA"]); + expect(bindings[0]?.secretId).toBe(secretA.id); + }); + + it("legacy run with null routine_revision_id falls back to the routine's current env (still resolves)", async () => { + const { companyId, executorAgentId } = await seed(); + const secrets = secretService(db); + const routines = routineService(db, { heartbeat: { wakeup: async () => null } }); + + const secret = await secrets.create(companyId, { + name: `legacy-${randomUUID()}`, + provider: "local_encrypted", + value: "legacy-value", + }); + + const routine = await routines.create( + companyId, + { + projectId: null, + goalId: null, + parentIssueId: null, + title: "legacy routine", + description: null, + assigneeAgentId: executorAgentId, + priority: "medium", + status: "active", + concurrencyPolicy: "coalesce_if_active", + catchUpPolicy: "skip_missed", + env: { + LEGACY: { type: "secret_ref", secretId: secret.id, version: "latest" }, + }, + }, + {}, + ); + + // Simulate an old routine_run row (predating the migration) with no + // routine_revision_id. The fallback path in `getRoutineEnvForExecutionIssue` + // should still resolve to the routine's current env. Here we exercise the + // resolution layer directly with routine.env to mirror that behavior. + await db.insert(routineRuns).values({ + id: randomUUID(), + companyId, + routineId: routine.id, + triggerId: null, + source: "manual", + status: "issue_created", + triggeredAt: new Date(), + completedAt: new Date(), + routineRevisionId: null, + }); + + const result = await resolveExecutionRunAdapterConfig({ + companyId, + agentId: executorAgentId, + issueId: null, + heartbeatRunId: null, + projectId: null, + routineId: routine.id, + executionRunConfig: { env: {} }, + projectEnv: null, + routineEnv: routine.env, + secretsSvc: secrets, + }); + expect(result.resolvedConfig.env).toMatchObject({ LEGACY: "legacy-value" }); + }); + + it("routines created with null env (no Secrets tab interaction) still resolve normally with empty env", async () => { + const { companyId, executorAgentId } = await seed(); + const secrets = secretService(db); + const routines = routineService(db, { heartbeat: { wakeup: async () => null } }); + + const routine = await routines.create( + companyId, + { + projectId: null, + goalId: null, + parentIssueId: null, + title: "null env routine", + description: null, + assigneeAgentId: executorAgentId, + priority: "medium", + status: "active", + concurrencyPolicy: "coalesce_if_active", + catchUpPolicy: "skip_missed", + }, + {}, + ); + + expect(routine.env ?? null).toBeNull(); + + const bindings = await db + .select() + .from(companySecretBindings) + .where(eq(companySecretBindings.targetId, routine.id)); + expect(bindings).toHaveLength(0); + + const result = await resolveExecutionRunAdapterConfig({ + companyId, + agentId: executorAgentId, + issueId: null, + heartbeatRunId: null, + projectId: null, + routineId: routine.id, + executionRunConfig: { env: { AGENT_ONLY: "agent" } }, + projectEnv: null, + routineEnv: null, + secretsSvc: secrets, + }); + expect(result.resolvedConfig.env).toEqual({ AGENT_ONLY: "agent" }); + expect(result.secretKeys.size).toBe(0); + }); +});