// 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); }); });