diff --git a/doc/DATABASE.md b/doc/DATABASE.md index 2e0ad661..887f8d9d 100644 --- a/doc/DATABASE.md +++ b/doc/DATABASE.md @@ -165,6 +165,10 @@ Paperclip stores secret metadata and versions in: - `company_secrets` - `company_secret_versions` +- `company_secret_bindings` +- `secret_access_events` + +Secret-aware env bindings are supported by agents, projects, and routines. Routine env lives in `routines.env`, is captured in `routine_revisions.snapshot`, and routine dispatches store `routine_runs.routine_revision_id` so runtime secret resolution uses the env snapshot that existed when the run was created. Routine secret refs bind with `target_type = 'routine'`, `target_id = routines.id`, and `config_path` values under `env.*`. For local/default installs, the active provider is `local_encrypted`: diff --git a/doc/SPEC-implementation.md b/doc/SPEC-implementation.md index 0f7152f9..7e2be87a 100644 --- a/doc/SPEC-implementation.md +++ b/doc/SPEC-implementation.md @@ -207,6 +207,8 @@ Invariant: - project env is merged into run environment for issues in that project and overrides conflicting agent env keys before Paperclip runtime-owned keys are injected +Routine execution issues add a routine-scoped env overlay after project env and before Paperclip runtime-owned keys. Routine env uses the same secret-aware binding format, is stored on `routines.env`, is snapshotted in routine revisions, and resolves secret refs against the routine binding target so routine-owned secrets do not require direct bindings on the executing agent. + ## 7.6 `issues` (core task entity) - `id` uuid pk @@ -400,7 +402,7 @@ The current implementation includes additional V1-control-plane tables beyond th - Issue structure and review: `issue_relations` for blockers, `labels`/`issue_labels`, `issue_thread_interactions`, `issue_approvals`, `issue_execution_decisions`, `issue_work_products`, `issue_inbox_archives`, `issue_read_states`, and issue reference mention indexes. - Execution and workspace control: `execution_workspaces`, `project_workspaces`, `workspace_runtime_services`, `workspace_operations`, `environments`, `environment_leases`, `agent_task_sessions`, `agent_runtime_state`, `agent_wakeup_requests`, heartbeat events, and watchdog decision tables. -- Plugins and routines: `plugins`, plugin config/state/entities/jobs/logs/webhooks, plugin database namespaces/migrations, plugin company settings, and `routines`. +- Plugins and routines: `plugins`, plugin config/state/entities/jobs/logs/webhooks, plugin database namespaces/migrations, plugin company settings, `routines`, `routine_revisions`, `routine_triggers`, and `routine_runs`. - Access and operations: company memberships, instance roles, principal permission grants, invites, join requests, board API keys, CLI auth challenges, budget policies/incidents, feedback exports/votes, company skills, sidebar preferences, and company logos. ## 8. State Machines diff --git a/packages/db/src/migrations/0086_routine_env_runtime_contract.sql b/packages/db/src/migrations/0086_routine_env_runtime_contract.sql new file mode 100644 index 00000000..55228ccb --- /dev/null +++ b/packages/db/src/migrations/0086_routine_env_runtime_contract.sql @@ -0,0 +1,8 @@ +ALTER TABLE "routines" ADD COLUMN IF NOT EXISTS "env" jsonb;--> statement-breakpoint +ALTER TABLE "routine_runs" ADD COLUMN IF NOT EXISTS "routine_revision_id" uuid;--> statement-breakpoint +DO $$ BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'routine_runs_routine_revision_id_routine_revisions_id_fk') THEN + ALTER TABLE "routine_runs" ADD CONSTRAINT "routine_runs_routine_revision_id_routine_revisions_id_fk" FOREIGN KEY ("routine_revision_id") REFERENCES "public"."routine_revisions"("id") ON DELETE set null ON UPDATE no action; + END IF; +END $$;--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "routine_runs_revision_idx" ON "routine_runs" USING btree ("routine_revision_id"); diff --git a/packages/db/src/migrations/meta/_journal.json b/packages/db/src/migrations/meta/_journal.json index 953d0a09..a47bae7c 100644 --- a/packages/db/src/migrations/meta/_journal.json +++ b/packages/db/src/migrations/meta/_journal.json @@ -603,6 +603,13 @@ "when": 1778787362162, "tag": "0085_tranquil_the_executioner", "breakpoints": true + }, + { + "idx": 86, + "version": "7", + "when": 1778976000000, + "tag": "0086_routine_env_runtime_contract", + "breakpoints": true } ] } diff --git a/packages/db/src/schema/routines.ts b/packages/db/src/schema/routines.ts index dfc48143..684d12d6 100644 --- a/packages/db/src/schema/routines.ts +++ b/packages/db/src/schema/routines.ts @@ -17,7 +17,7 @@ import { issues } from "./issues.js"; import { projects } from "./projects.js"; import { goals } from "./goals.js"; import { heartbeatRuns } from "./heartbeat_runs.js"; -import type { RoutineRevisionSnapshotV1, RoutineVariable } from "@paperclipai/shared"; +import type { RoutineEnvConfig, RoutineRevisionSnapshotV1, RoutineVariable } from "@paperclipai/shared"; export const routines = pgTable( "routines", @@ -35,6 +35,7 @@ export const routines = pgTable( concurrencyPolicy: text("concurrency_policy").notNull().default("coalesce_if_active"), catchUpPolicy: text("catch_up_policy").notNull().default("skip_missed"), variables: jsonb("variables").$type().notNull().default([]), + env: jsonb("env").$type(), latestRevisionId: uuid("latest_revision_id"), latestRevisionNumber: integer("latest_revision_number").notNull().default(1), createdByAgentId: uuid("created_by_agent_id").references(() => agents.id, { onDelete: "set null" }), @@ -131,6 +132,7 @@ export const routineRuns = pgTable( source: text("source").notNull(), status: text("status").notNull().default("received"), triggeredAt: timestamp("triggered_at", { withTimezone: true }).notNull().defaultNow(), + routineRevisionId: uuid("routine_revision_id").references(() => routineRevisions.id, { onDelete: "set null" }), idempotencyKey: text("idempotency_key"), triggerPayload: jsonb("trigger_payload").$type>(), dispatchFingerprint: text("dispatch_fingerprint"), @@ -143,6 +145,7 @@ export const routineRuns = pgTable( }, (table) => ({ companyRoutineIdx: index("routine_runs_company_routine_idx").on(table.companyId, table.routineId, table.createdAt), + routineRevisionIdx: index("routine_runs_revision_idx").on(table.routineRevisionId), triggerIdx: index("routine_runs_trigger_idx").on(table.triggerId, table.createdAt), dispatchFingerprintIdx: index("routine_runs_dispatch_fingerprint_idx").on(table.routineId, table.dispatchFingerprint), linkedIssueIdx: index("routine_runs_linked_issue_idx").on(table.linkedIssueId), diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 8dd130c2..945dcd1c 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -550,6 +550,8 @@ export type { CompanyPortabilityImportResult, CompanyPortabilityExportRequest, EnvBinding, + EnvPlainBinding, + EnvSecretRefBinding, AgentEnvConfig, CompanySecret, CompanySecretProviderConfig, @@ -576,6 +578,7 @@ export type { SecretVersionSelector, SecretVersionStatus, Routine, + RoutineEnvConfig, RoutineManagedByPlugin, RoutineVariable, RoutineVariableDefaultValue, diff --git a/packages/shared/src/types/index.ts b/packages/shared/src/types/index.ts index 9cb46a2c..a2354757 100644 --- a/packages/shared/src/types/index.ts +++ b/packages/shared/src/types/index.ts @@ -279,6 +279,7 @@ export type { } from "./secrets.js"; export type { Routine, + RoutineEnvConfig, RoutineManagedByPlugin, RoutineVariable, RoutineVariableDefaultValue, diff --git a/packages/shared/src/types/routine.ts b/packages/shared/src/types/routine.ts index c6f866dd..3e877153 100644 --- a/packages/shared/src/types/routine.ts +++ b/packages/shared/src/types/routine.ts @@ -8,6 +8,7 @@ import type { RoutineTriggerSigningMode, RoutineVariableType, } from "../constants.js"; +import type { EnvBinding } from "./secrets.js"; export interface RoutineProjectSummary { id: string; @@ -45,6 +46,8 @@ export interface RoutineVariable { options: string[]; } +export type RoutineEnvConfig = Record; + export interface Routine { id: string; companyId: string; @@ -59,6 +62,7 @@ export interface Routine { concurrencyPolicy: string; catchUpPolicy: string; variables: RoutineVariable[]; + env?: RoutineEnvConfig | null; latestRevisionId: string | null; latestRevisionNumber: number; createdByAgentId: string | null; @@ -98,6 +102,7 @@ export interface RoutineRevisionSnapshotRoutineV1 { concurrencyPolicy: RoutineConcurrencyPolicy; catchUpPolicy: RoutineCatchUpPolicy; variables: RoutineVariable[]; + env: RoutineEnvConfig | null; } export interface RoutineRevisionSnapshotTriggerV1 { @@ -169,6 +174,7 @@ export interface RoutineRun { source: string; status: string; triggeredAt: Date; + routineRevisionId?: string | null; idempotencyKey: string | null; triggerPayload: Record | null; dispatchFingerprint: string | null; diff --git a/packages/shared/src/validators/routine.ts b/packages/shared/src/validators/routine.ts index c3ae1b8a..a903ceb2 100644 --- a/packages/shared/src/validators/routine.ts +++ b/packages/shared/src/validators/routine.ts @@ -12,6 +12,7 @@ import { ISSUE_EXECUTION_WORKSPACE_PREFERENCES, issueExecutionWorkspaceSettingsSchema, } from "./issue.js"; +import { envConfigSchema } from "./secret.js"; const routineVariableValueSchema = z.union([z.string(), z.number().finite(), z.boolean()]); @@ -60,6 +61,7 @@ export const createRoutineSchema = z.object({ concurrencyPolicy: z.enum(ROUTINE_CONCURRENCY_POLICIES).optional().default("coalesce_if_active"), catchUpPolicy: z.enum(ROUTINE_CATCH_UP_POLICIES).optional().default("skip_missed"), variables: z.array(routineVariableSchema).optional().default([]), + env: envConfigSchema.optional().nullable(), }); export type CreateRoutine = z.infer; @@ -83,6 +85,7 @@ export const routineRevisionSnapshotRoutineV1Schema = z.object({ concurrencyPolicy: z.enum(ROUTINE_CONCURRENCY_POLICIES), catchUpPolicy: z.enum(ROUTINE_CATCH_UP_POLICIES), variables: z.array(routineVariableSchema), + env: envConfigSchema.nullable().default(null), }).strict(); export const routineRevisionSnapshotTriggerV1Schema = z.object({ diff --git a/server/src/__tests__/heartbeat-project-env.test.ts b/server/src/__tests__/heartbeat-project-env.test.ts index 55653be3..7ab6ed24 100644 --- a/server/src/__tests__/heartbeat-project-env.test.ts +++ b/server/src/__tests__/heartbeat-project-env.test.ts @@ -7,7 +7,7 @@ import { } from "../services/heartbeat.ts"; describe("resolveExecutionRunAdapterConfig", () => { - it("overlays project env on top of agent env and unions secret keys", async () => { + it("overlays project and routine env on top of agent env and unions secret keys", async () => { const resolveAdapterConfigForRuntime = vi.fn().mockResolvedValue({ config: { env: { @@ -29,29 +29,51 @@ describe("resolveExecutionRunAdapterConfig", () => { }, ], }); - const resolveEnvBindings = vi.fn().mockResolvedValue({ - env: { - SHARED_KEY: "project", - PROJECT_ONLY: "project-only", - }, - secretKeys: new Set(["PROJECT_SECRET"]), - manifest: [ - { - configPath: "env.PROJECT_SECRET", - envKey: "PROJECT_SECRET", - secretId: "secret-project", - secretKey: "project-secret", - version: 1, - provider: "local_encrypted", - outcome: "success", + const resolveEnvBindings = vi + .fn() + .mockResolvedValueOnce({ + env: { + SHARED_KEY: "project", + PROJECT_ONLY: "project-only", }, - ], - }); + secretKeys: new Set(["PROJECT_SECRET"]), + manifest: [ + { + configPath: "env.PROJECT_SECRET", + envKey: "PROJECT_SECRET", + secretId: "secret-project", + secretKey: "project-secret", + version: 1, + provider: "local_encrypted", + outcome: "success", + }, + ], + }) + .mockResolvedValueOnce({ + env: { + SHARED_KEY: "routine", + ROUTINE_ONLY: "routine-only", + }, + secretKeys: new Set(["ROUTINE_SECRET"]), + manifest: [ + { + configPath: "env.ROUTINE_SECRET", + envKey: "ROUTINE_SECRET", + secretId: "secret-routine", + secretKey: "routine-secret", + version: 1, + provider: "local_encrypted", + outcome: "success", + }, + ], + }); const result = await resolveExecutionRunAdapterConfig({ companyId: "company-1", executionRunConfig: { env: { SHARED_KEY: "agent" } }, projectEnv: { SHARED_KEY: "project" }, + routineEnv: { SHARED_KEY: "routine" }, + routineId: "routine-1", secretsSvc: { resolveAdapterConfigForRuntime, resolveEnvBindings, @@ -61,18 +83,88 @@ describe("resolveExecutionRunAdapterConfig", () => { expect(result.resolvedConfig).toMatchObject({ other: "value", env: { - SHARED_KEY: "project", + SHARED_KEY: "routine", AGENT_ONLY: "agent-only", PROJECT_ONLY: "project-only", + ROUTINE_ONLY: "routine-only", }, }); - expect(Array.from(result.secretKeys).sort()).toEqual(["AGENT_SECRET", "PROJECT_SECRET"]); + expect(Array.from(result.secretKeys).sort()).toEqual(["AGENT_SECRET", "PROJECT_SECRET", "ROUTINE_SECRET"]); expect(result.secretManifest.map((entry) => entry.secretId).sort()).toEqual([ "secret-agent", "secret-project", + "secret-routine", ]); expect(JSON.stringify(result.secretManifest)).not.toContain("agent-only"); expect(JSON.stringify(result.secretManifest)).not.toContain("project-only"); + expect(JSON.stringify(result.secretManifest)).not.toContain("routine-only"); + expect(resolveEnvBindings.mock.calls[1]?.[2]).toMatchObject({ + consumerType: "routine", + consumerId: "routine-1", + }); + }); + + it("drops Paperclip runtime-owned env before resolving agent, project, and routine overlays", async () => { + const resolveAdapterConfigForRuntime = vi.fn(async (_companyId, config: Record) => ({ + config: { + ...config, + env: { ...(config.env as Record) }, + }, + secretKeys: new Set(), + manifest: [], + })); + const resolveEnvBindings = vi.fn(async (_companyId, env: Record) => ({ + env: Object.fromEntries( + Object.entries(env).filter((entry): entry is [string, string] => typeof entry[1] === "string"), + ), + secretKeys: new Set(), + manifest: [], + })); + + const result = await resolveExecutionRunAdapterConfig({ + companyId: "company-1", + agentId: "agent-1", + executionRunConfig: { + env: { + PAPERCLIP_API_KEY: { type: "secret_ref", secretId: "secret-api-key", version: "latest" }, + PAPERCLIP_AGENT_ID: "spoofed-agent", + AGENT_ONLY: "agent-only", + }, + }, + projectEnv: { + PAPERCLIP_API_KEY: "project-api-key", + PAPERCLIP_COMPANY_ID: "spoofed-company", + PROJECT_ONLY: "project-only", + }, + routineEnv: { + PAPERCLIP_API_KEY: "routine-api-key", + PAPERCLIP_RUN_ID: "spoofed-run", + ROUTINE_ONLY: "routine-only", + }, + routineId: "routine-1", + secretsSvc: { + resolveAdapterConfigForRuntime, + resolveEnvBindings, + } as any, + }); + + expect(resolveAdapterConfigForRuntime.mock.calls[0]?.[1]).toEqual({ + env: { + AGENT_ONLY: "agent-only", + }, + }); + expect(resolveEnvBindings.mock.calls[0]?.[1]).toEqual({ + PROJECT_ONLY: "project-only", + }); + expect(resolveEnvBindings.mock.calls[1]?.[1]).toEqual({ + ROUTINE_ONLY: "routine-only", + }); + expect(result.resolvedConfig.env).toEqual({ + AGENT_ONLY: "agent-only", + PROJECT_ONLY: "project-only", + ROUTINE_ONLY: "routine-only", + }); + expect(JSON.stringify(result.resolvedConfig.env)).not.toContain("PAPERCLIP_"); }); it("skips project env resolution when the project has no bindings", async () => { 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 00000000..43d7910a --- /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); + }); +}); diff --git a/server/src/__tests__/routines-service.test.ts b/server/src/__tests__/routines-service.test.ts index 70fe9d05..e449bd30 100644 --- a/server/src/__tests__/routines-service.test.ts +++ b/server/src/__tests__/routines-service.test.ts @@ -5,6 +5,7 @@ import { activityLog, agents, companies, + companySecretBindings, companySecrets, companySecretVersions, createDb, @@ -19,6 +20,7 @@ import { routineRuns, routines, routineTriggers, + secretAccessEvents, } from "@paperclipai/db"; import { getEmbeddedPostgresTestSupport, @@ -28,6 +30,7 @@ import { issueService } from "../services/issues.ts"; import { instanceSettingsService } from "../services/instance-settings.ts"; import * as providerRegistry from "../secrets/provider-registry.ts"; import { routineService } from "../services/routines.ts"; +import { secretService } from "../services/secrets.ts"; const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport(); const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip; @@ -57,6 +60,8 @@ describeEmbeddedPostgres("routine service live-execution coalescing", () => { await db.delete(activityLog); await db.delete(issueInboxArchives); await db.delete(issueReadStates); + await db.delete(secretAccessEvents); + await db.delete(companySecretBindings); await db.delete(routineRuns); await db.delete(routineTriggers); await db.delete(routines); @@ -331,6 +336,89 @@ describeEmbeddedPostgres("routine service live-execution coalescing", () => { expect(revisions[1]?.snapshot.routine.description).toBe("Run the frog routine"); }); + it("stores routine env in revisions, syncs routine secret bindings, and stamps runs with the dispatch revision", async () => { + const { agentId, companyId, projectId, svc } = await seedFixture(); + const secrets = secretService(db); + const secret = await secrets.create(companyId, { + name: `routine-api-${randomUUID()}`, + provider: "local_encrypted", + value: "secret-value", + }); + + const routine = await svc.create( + companyId, + { + projectId, + goalId: null, + parentIssueId: null, + title: "secret routine", + description: null, + assigneeAgentId: agentId, + priority: "medium", + status: "active", + concurrencyPolicy: "always_enqueue", + catchUpPolicy: "skip_missed", + env: { + ROUTINE_API_KEY: { type: "secret_ref", secretId: secret.id, version: "latest" }, + ROUTINE_PLAIN: { type: "plain", value: "plain-value" }, + }, + }, + {}, + ); + + const bindings = await db + .select() + .from(companySecretBindings) + .where(eq(companySecretBindings.targetId, routine.id)); + expect(bindings).toMatchObject([ + { + companyId, + secretId: secret.id, + targetType: "routine", + configPath: "env.ROUTINE_API_KEY", + }, + ]); + + const [initialRevision] = await svc.listRevisions(routine.id); + expect(initialRevision?.snapshot.routine.env).toEqual(routine.env); + + await db.delete(companySecretBindings).where(eq(companySecretBindings.targetId, routine.id)); + const repaired = await svc.update(routine.id, { env: routine.env }, {}); + expect(repaired).not.toBeNull(); + const repairedBindings = await db + .select() + .from(companySecretBindings) + .where(eq(companySecretBindings.targetId, routine.id)); + expect(repairedBindings).toMatchObject([ + { + companyId, + secretId: secret.id, + targetType: "routine", + configPath: "env.ROUTINE_API_KEY", + }, + ]); + + const currentRoutine = repaired ?? routine; + const runBefore = await svc.runRoutine(routine.id, { source: "manual" }); + expect(runBefore.routineRevisionId).toBe(currentRoutine.latestRevisionId); + + const updated = await svc.update( + routine.id, + { + env: { + ROUTINE_API_KEY: { type: "secret_ref", secretId: secret.id, version: "latest" }, + ROUTINE_PLAIN: { type: "plain", value: "changed" }, + }, + }, + {}, + ); + expect(updated?.latestRevisionNumber).toBe(currentRoutine.latestRevisionNumber + 1); + + const runAfter = await svc.runRoutine(routine.id, { source: "manual" }); + expect(runAfter.routineRevisionId).toBe(updated?.latestRevisionId); + expect(runAfter.dispatchFingerprint).not.toBe(runBefore.dispatchFingerprint); + }); + it("rejects stale routine baseRevisionId updates", async () => { const { routine, svc } = await seedFixture(); const updated = await svc.update(routine.id, { description: "new description" }, {}); diff --git a/server/src/__tests__/secrets-service.test.ts b/server/src/__tests__/secrets-service.test.ts index 01f13041..d01b6777 100644 --- a/server/src/__tests__/secrets-service.test.ts +++ b/server/src/__tests__/secrets-service.test.ts @@ -205,6 +205,116 @@ describeEmbeddedPostgres("secretService", () => { expect(JSON.stringify(events)).not.toContain("runtime-secret"); }); + it("resolves routine env secret refs through routine bindings and records value-free access metadata", async () => { + const companyId = await seedCompany(); + const svc = secretService(db); + const secret = await svc.create(companyId, { + name: `routine-secret-${randomUUID()}`, + provider: "local_encrypted", + value: "routine-super-secret", + }); + const env = { + ROUTINE_API_KEY: { type: "secret_ref" as const, secretId: secret.id, version: "latest" as const }, + }; + await svc.syncEnvBindingsForTarget(companyId, { targetType: "routine", targetId: "routine-1" }, env); + + const resolved = await svc.resolveEnvBindings(companyId, env, { + consumerType: "routine", + consumerId: "routine-1", + actorType: "agent", + actorId: "agent-1", + }); + + expect(resolved.env.ROUTINE_API_KEY).toBe("routine-super-secret"); + expect(resolved.manifest).toEqual([ + expect.objectContaining({ + configPath: "env.ROUTINE_API_KEY", + envKey: "ROUTINE_API_KEY", + secretId: secret.id, + outcome: "success", + }), + ]); + + const events = await svc.listAccessEvents(companyId, secret.id); + expect(events).toHaveLength(1); + expect(events[0]).toMatchObject({ + companyId, + secretId: secret.id, + consumerType: "routine", + consumerId: "routine-1", + configPath: "env.ROUTINE_API_KEY", + actorType: "agent", + actorId: "agent-1", + outcome: "success", + }); + expect(JSON.stringify(events)).not.toContain("routine-super-secret"); + }); + + it("records stable redacted failure codes for routine env secret resolution", async () => { + const companyId = await seedCompany(); + const svc = secretService(db); + const secret = await svc.create(companyId, { + name: `routine-failure-codes-${randomUUID()}`, + provider: "local_encrypted", + value: "routine-super-secret", + }); + const env = { + ROUTINE_API_KEY: { type: "secret_ref" as const, secretId: secret.id, version: "latest" as const }, + }; + const context = { + consumerType: "routine" as const, + consumerId: "routine-1", + actorType: "agent" as const, + actorId: "agent-1", + }; + await svc.syncEnvBindingsForTarget(companyId, { targetType: "routine", targetId: "routine-1" }, env); + + await expect( + svc.resolveEnvBindings(companyId, env, { ...context, consumerId: "routine-2" }), + ).rejects.toThrow(/not bound/i); + + await db.update(companySecrets).set({ status: "disabled" }).where(eq(companySecrets.id, secret.id)); + await expect(svc.resolveEnvBindings(companyId, env, context)).rejects.toThrow(/not active/i); + + await db.update(companySecrets).set({ status: "active" }).where(eq(companySecrets.id, secret.id)); + await expect( + svc.resolveSecretValue(companyId, secret.id, 999, { + ...context, + configPath: "env.ROUTINE_API_KEY", + }), + ).rejects.toThrow(/version not found/i); + + await db + .update(companySecretVersions) + .set({ status: "disabled" }) + .where(eq(companySecretVersions.secretId, secret.id)); + await expect(svc.resolveEnvBindings(companyId, env, context)).rejects.toThrow(/version is not active/i); + + await db + .update(companySecretVersions) + .set({ status: "current" }) + .where(eq(companySecretVersions.secretId, secret.id)); + vi.spyOn(localEncryptedProvider, "resolveVersion").mockRejectedValueOnce( + new Error("provider leaked value routine-super-secret"), + ); + await expect(svc.resolveEnvBindings(companyId, env, context)).rejects.toThrow(/provider leaked value/i); + + await db.update(companySecrets).set({ status: "deleted" }).where(eq(companySecrets.id, secret.id)); + await expect(svc.resolveEnvBindings(companyId, env, context)).rejects.toThrow(/not found/i); + + const events = await svc.listAccessEvents(companyId, secret.id); + expect(events.map((event) => event.errorCode).sort()).toEqual([ + "binding_missing", + "provider_error", + "secret_deleted", + "secret_inactive", + "version_inactive", + "version_missing", + ]); + expect(JSON.stringify(events)).not.toContain("routine-super-secret"); + expect(JSON.stringify(events)).not.toContain("provider leaked value"); + }); + it("scopes env binding sync deletes to the env path prefix", async () => { const companyId = await seedCompany(); const svc = secretService(db); diff --git a/server/src/services/heartbeat.ts b/server/src/services/heartbeat.ts index 248a51ed..06906e0f 100644 --- a/server/src/services/heartbeat.ts +++ b/server/src/services/heartbeat.ts @@ -18,6 +18,7 @@ import { type IssueExecutionMonitorPolicy, type IssueExecutionMonitorRecoveryPolicy, type ModelProfileKey, + type RoutineRevisionSnapshotV1, type RunLivenessState, } from "@paperclipai/shared"; import { @@ -40,6 +41,9 @@ import { issueWorkProducts, projects, projectWorkspaces, + routineRevisions, + routineRuns, + routines, workspaceOperations, } from "@paperclipai/db"; import { conflict, HttpError, notFound } from "../errors.js"; @@ -327,19 +331,44 @@ type RuntimeConfigSecretResolver = Pick< "resolveAdapterConfigForRuntime" | "resolveEnvBindings" >; +function isPaperclipRuntimeEnvKey(key: string) { + return key.startsWith("PAPERCLIP_"); +} + +function stripPaperclipRuntimeEnvBindings(envValue: unknown): Record | null { + const record = parseObject(envValue); + const filtered = Object.fromEntries( + Object.entries(record).filter(([key]) => !isPaperclipRuntimeEnvKey(key)), + ); + return Object.keys(filtered).length > 0 ? filtered : null; +} + +function stripPaperclipRuntimeEnvFromAdapterConfig(config: Record): Record { + if (!Object.prototype.hasOwnProperty.call(config, "env")) return config; + return { + ...config, + env: stripPaperclipRuntimeEnvBindings(config.env) ?? {}, + }; +} + export async function resolveExecutionRunAdapterConfig(input: { companyId: string; agentId?: string | null; issueId?: string | null; heartbeatRunId?: string | null; projectId?: string | null; + routineId?: string | null; executionRunConfig: Record; projectEnv: unknown; + routineEnv?: unknown; secretsSvc: RuntimeConfigSecretResolver; }) { + const executionRunConfig = stripPaperclipRuntimeEnvFromAdapterConfig(input.executionRunConfig); + const projectEnv = stripPaperclipRuntimeEnvBindings(input.projectEnv); + const routineEnv = stripPaperclipRuntimeEnvBindings(input.routineEnv); const { config: resolvedConfig, secretKeys, manifest } = await input.secretsSvc.resolveAdapterConfigForRuntime( input.companyId, - input.executionRunConfig, + executionRunConfig, input.agentId ? { consumerType: "agent", @@ -351,10 +380,10 @@ export async function resolveExecutionRunAdapterConfig(input: { } : undefined, ); - const projectEnvResolution = input.projectEnv + const projectEnvResolution = projectEnv ? await input.secretsSvc.resolveEnvBindings( input.companyId, - input.projectEnv, + projectEnv, input.projectId ? { consumerType: "project", @@ -376,10 +405,39 @@ export async function resolveExecutionRunAdapterConfig(input: { secretKeys.add(key); } } + const routineEnvResolution = routineEnv + ? await input.secretsSvc.resolveEnvBindings( + input.companyId, + routineEnv, + input.routineId + ? { + consumerType: "routine", + consumerId: input.routineId, + actorType: "agent", + actorId: input.agentId ?? null, + issueId: input.issueId ?? null, + heartbeatRunId: input.heartbeatRunId ?? null, + } + : undefined, + ) + : { env: {}, secretKeys: new Set(), manifest: [] }; + if (Object.keys(routineEnvResolution.env).length > 0) { + resolvedConfig.env = { + ...parseObject(resolvedConfig.env), + ...routineEnvResolution.env, + }; + for (const key of routineEnvResolution.secretKeys) { + secretKeys.add(key); + } + } return { resolvedConfig, secretKeys, - secretManifest: [...(manifest ?? []), ...(projectEnvResolution.manifest ?? [])], + secretManifest: [ + ...(manifest ?? []), + ...(projectEnvResolution.manifest ?? []), + ...(routineEnvResolution.manifest ?? []), + ], }; } @@ -2433,12 +2491,67 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) assigneeAgentId: issues.assigneeAgentId, assigneeAdapterOverrides: issues.assigneeAdapterOverrides, executionWorkspaceSettings: issues.executionWorkspaceSettings, + originKind: issues.originKind, + originId: issues.originId, + originRunId: issues.originRunId, }) .from(issues) .where(and(eq(issues.id, issueId), eq(issues.companyId, companyId))) .then((rows) => rows[0] ?? null); } + async function getRoutineEnvForExecutionIssue( + companyId: string, + issueContext: Awaited> | null, + ) { + if (!issueContext || issueContext.originKind !== "routine_execution" || !issueContext.originId) { + return { routineId: null, env: null }; + } + + const routineRun = issueContext.originRunId + ? await db + .select({ + routineRevisionId: routineRuns.routineRevisionId, + }) + .from(routineRuns) + .where( + and( + eq(routineRuns.id, issueContext.originRunId), + eq(routineRuns.companyId, companyId), + eq(routineRuns.routineId, issueContext.originId), + ), + ) + .then((rows) => rows[0] ?? null) + : null; + + if (routineRun?.routineRevisionId) { + const revision = await db + .select({ + snapshot: routineRevisions.snapshot, + }) + .from(routineRevisions) + .where( + and( + eq(routineRevisions.id, routineRun.routineRevisionId), + eq(routineRevisions.companyId, companyId), + eq(routineRevisions.routineId, issueContext.originId), + ), + ) + .then((rows) => rows[0] ?? null); + const snapshot = revision?.snapshot as RoutineRevisionSnapshotV1 | undefined; + if (snapshot?.version === 1) { + return { routineId: issueContext.originId, env: snapshot.routine.env ?? null }; + } + } + + const routine = await db + .select({ env: routines.env }) + .from(routines) + .where(and(eq(routines.id, issueContext.originId), eq(routines.companyId, companyId))) + .then((rows) => rows[0] ?? null); + return { routineId: issueContext.originId, env: routine?.env ?? null }; + } + async function getRuntimeState(agentId: string) { return db .select() @@ -6870,6 +6983,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) .where(and(eq(projects.id, executionProjectId), eq(projects.companyId, agent.companyId))) .then((rows) => rows[0] ?? null) : null; + const routineEnvContext = await getRoutineEnvForExecutionIssue(agent.companyId, issueContext); const projectExecutionWorkspacePolicy = gateProjectExecutionWorkspacePolicy( parseProjectExecutionWorkspacePolicy(projectContext?.executionWorkspacePolicy), isolatedWorkspacesEnabled, @@ -7074,8 +7188,10 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) issueId, heartbeatRunId: run.id, projectId: projectContext?.id ?? null, + routineId: routineEnvContext.routineId, executionRunConfig, projectEnv: projectContext?.env ?? null, + routineEnv: routineEnvContext.env, secretsSvc, }); if (secretManifest.length > 0) { diff --git a/server/src/services/routines.ts b/server/src/services/routines.ts index b280119a..80e22176 100644 --- a/server/src/services/routines.ts +++ b/server/src/services/routines.ts @@ -366,6 +366,8 @@ function createRoutineDispatchFingerprint(input: { payload: Record | null; projectId: string | null; assigneeAgentId: string | null; + routineRevisionId: string | null; + routineEnvFingerprint: string | null; executionWorkspaceId?: string | null; executionWorkspacePreference?: string | null; executionWorkspaceSettings?: Record | null; @@ -376,6 +378,11 @@ function createRoutineDispatchFingerprint(input: { return crypto.createHash("sha256").update(canonical).digest("hex"); } +function createRoutineEnvFingerprint(env: unknown) { + const canonical = JSON.stringify(normalizeRoutineDispatchFingerprintValue(env ?? null)); + return crypto.createHash("sha256").update(canonical).digest("hex"); +} + function readManagedRoutineIssueTemplate(defaultsJson: Record | null | undefined) { const value = defaultsJson?.issueTemplate; if (!isPlainRecord(value)) return null; @@ -406,6 +413,7 @@ function routineRevisionSnapshotRoutine(routine: RoutineRow): RoutineRevisionSna concurrencyPolicy: routine.concurrencyPolicy as RoutineRevisionSnapshotV1["routine"]["concurrencyPolicy"], catchUpPolicy: routine.catchUpPolicy as RoutineRevisionSnapshotV1["routine"]["catchUpPolicy"], variables: routine.variables ?? [], + env: routine.env ?? null, }; } @@ -686,6 +694,7 @@ export function routineService( idempotencyKey: routineRuns.idempotencyKey, triggerPayload: routineRuns.triggerPayload, dispatchFingerprint: routineRuns.dispatchFingerprint, + routineRevisionId: routineRuns.routineRevisionId, linkedIssueId: routineRuns.linkedIssueId, coalescedIntoRunId: routineRuns.coalescedIntoRunId, failureReason: routineRuns.failureReason, @@ -719,6 +728,7 @@ export function routineService( idempotencyKey: row.idempotencyKey, triggerPayload: row.triggerPayload as Record | null, dispatchFingerprint: row.dispatchFingerprint, + routineRevisionId: row.routineRevisionId, linkedIssueId: row.linkedIssueId, coalescedIntoRunId: row.coalescedIntoRunId, failureReason: row.failureReason, @@ -1138,6 +1148,8 @@ export function routineService( payload: triggerPayload, projectId, assigneeAgentId, + routineRevisionId: input.routine.latestRevisionId, + routineEnvFingerprint: createRoutineEnvFingerprint(input.routine.env), executionWorkspaceId: input.executionWorkspaceId ?? null, executionWorkspacePreference: input.executionWorkspacePreference ?? null, executionWorkspaceSettings: input.executionWorkspaceSettings ?? null, @@ -1183,6 +1195,7 @@ export function routineService( idempotencyKey: input.idempotencyKey ?? null, triggerPayload, dispatchFingerprint, + routineRevisionId: input.routine.latestRevisionId, }) .returning(); @@ -1430,6 +1443,7 @@ export function routineService( idempotencyKey: routineRuns.idempotencyKey, triggerPayload: routineRuns.triggerPayload, dispatchFingerprint: routineRuns.dispatchFingerprint, + routineRevisionId: routineRuns.routineRevisionId, linkedIssueId: routineRuns.linkedIssueId, coalescedIntoRunId: routineRuns.coalescedIntoRunId, failureReason: routineRuns.failureReason, @@ -1462,6 +1476,7 @@ export function routineService( idempotencyKey: run.idempotencyKey, triggerPayload: run.triggerPayload as Record | null, dispatchFingerprint: run.dispatchFingerprint, + routineRevisionId: run.routineRevisionId, linkedIssueId: run.linkedIssueId, coalescedIntoRunId: run.coalescedIntoRunId, failureReason: run.failureReason, @@ -1508,13 +1523,19 @@ export function routineService( await assertAssignableAgent(companyId, input.assigneeAgentId ?? null); if (input.goalId) await assertGoal(companyId, input.goalId); if (input.parentIssueId) await assertParentIssue(companyId, input.parentIssueId); + const env = input.env === undefined || input.env === null + ? null + : await secretsSvc.normalizeEnvBindingsForPersistence(companyId, input.env, { + strictMode: process.env.PAPERCLIP_SECRETS_STRICT_MODE === "true", + fieldPath: "env", + }); const variables = syncRoutineVariablesWithTemplate( [input.title, input.description], sanitizeRoutineVariableInputs(input.variables), ); assertRoutineVariableDefinitions(variables); const status = normalizeDraftRoutineStatus(input.status, input.assigneeAgentId); - return db.transaction(async (tx) => { + const createdRoutine = await db.transaction(async (tx) => { const txDb = tx as unknown as Db; const [created] = await txDb .insert(routines) @@ -1531,6 +1552,7 @@ export function routineService( concurrencyPolicy: input.concurrencyPolicy, catchUpPolicy: input.catchUpPolicy, variables, + env, createdByAgentId: actor.agentId ?? null, createdByUserId: actor.userId ?? null, updatedByAgentId: actor.agentId ?? null, @@ -1540,8 +1562,17 @@ export function routineService( const { routine } = await appendRoutineRevision(txDb, created, actor, { changeSummary: "Created routine", }); + if (env) { + await secretsSvc.syncEnvBindingsForTarget( + companyId, + { targetType: "routine", targetId: routine.id }, + env, + { db: tx }, + ); + } return routine; }); + return createdRoutine; }, update: async (id: string, patch: UpdateRoutine, actor: Actor): Promise => { @@ -1551,6 +1582,14 @@ export function routineService( const nextAssigneeAgentId = patch.assigneeAgentId === undefined ? existing.assigneeAgentId : patch.assigneeAgentId; const nextTitle = patch.title ?? existing.title; const nextDescription = patch.description === undefined ? existing.description : patch.description; + const nextEnv = patch.env === undefined + ? existing.env + : patch.env === null + ? null + : await secretsSvc.normalizeEnvBindingsForPersistence(existing.companyId, patch.env, { + strictMode: process.env.PAPERCLIP_SECRETS_STRICT_MODE === "true", + fieldPath: "env", + }); const requestedStatus = patch.status ?? existing.status; if (patch.status === "active") { assertRoutineCanEnable(patch.status, nextAssigneeAgentId); @@ -1582,7 +1621,7 @@ export function routineService( if (enabledScheduleTriggers) { assertScheduleCompatibleVariables(nextVariables); } - return db.transaction(async (tx) => { + const updatedRoutine = await db.transaction(async (tx) => { const txDb = tx as unknown as Db; await tx.execute(sql`select id from ${routines} where ${routines.id} = ${id} for update`); const locked = await txDb @@ -1611,6 +1650,7 @@ export function routineService( concurrencyPolicy: patch.concurrencyPolicy ?? locked.concurrencyPolicy, catchUpPolicy: patch.catchUpPolicy ?? locked.catchUpPolicy, variables: nextVariables, + env: nextEnv, updatedByAgentId: actor.agentId ?? null, updatedByUserId: actor.userId ?? null, }; @@ -1633,6 +1673,14 @@ export function routineService( ) .then((rows) => rows[0] ?? null); if (latestRevision && snapshotsMatch(nextSnapshot, latestRevision.snapshot as RoutineRevisionSnapshotV1)) { + if (patch.env !== undefined) { + await secretsSvc.syncEnvBindingsForTarget( + locked.companyId, + { targetType: "routine", targetId: locked.id }, + candidate.env, + { db: tx }, + ); + } return locked; } } @@ -1651,6 +1699,7 @@ export function routineService( concurrencyPolicy: candidate.concurrencyPolicy, catchUpPolicy: candidate.catchUpPolicy, variables: candidate.variables, + env: candidate.env, updatedByAgentId: actor.agentId ?? null, updatedByUserId: actor.userId ?? null, updatedAt: new Date(), @@ -1661,8 +1710,17 @@ export function routineService( const { routine } = await appendRoutineRevision(txDb, updated, actor, { changeSummary: "Updated routine", }); + if (patch.env !== undefined) { + await secretsSvc.syncEnvBindingsForTarget( + routine.companyId, + { targetType: "routine", targetId: routine.id }, + routine.env, + { db: tx }, + ); + } return routine; }); + return updatedRoutine; }, createTrigger: async ( @@ -1770,7 +1828,7 @@ export function routineService( } } - return db.transaction(async (tx) => { + const result = await db.transaction(async (tx) => { const txDb = tx as unknown as Db; await tx.execute(sql`select id from ${routines} where ${routines.id} = ${existing.routineId} for update`); const [updated] = await txDb @@ -1801,12 +1859,13 @@ export function routineService( }); return { trigger: updated as RoutineTrigger, revision: appended.revision }; }); + return result; }, deleteTrigger: async (id: string, actor: Actor = {}): Promise<{ deleted: boolean; revision: RoutineRevision | null }> => { const existing = await getTriggerById(id); if (!existing) return { deleted: false, revision: null }; - return db.transaction(async (tx) => { + const result = await db.transaction(async (tx) => { const txDb = tx as unknown as Db; await tx.execute(sql`select id from ${routines} where ${routines.id} = ${existing.routineId} for update`); await txDb.delete(routineTriggers).where(eq(routineTriggers.id, id)); @@ -1821,6 +1880,7 @@ export function routineService( }); return { deleted: true, revision: appended.revision }; }); + return result; }, rotateTriggerSecret: async ( @@ -1912,7 +1972,7 @@ export function routineService( const routineSnapshot = snapshot.routine; await assertRestorableAssignee(existingRoutine.companyId, routineSnapshot.assigneeAgentId, actor); - return db.transaction(async (tx) => { + const result = await db.transaction(async (tx) => { const txDb = tx as unknown as Db; await tx.execute(sql`select id from ${routines} where ${routines.id} = ${existingRoutine.id} for update`); const locked = await txDb @@ -1964,6 +2024,7 @@ export function routineService( concurrencyPolicy: routineSnapshot.concurrencyPolicy, catchUpPolicy: routineSnapshot.catchUpPolicy, variables: routineSnapshot.variables, + env: routineSnapshot.env, updatedByAgentId: actor.agentId ?? null, updatedByUserId: actor.userId ?? null, updatedAt: now, @@ -2033,6 +2094,12 @@ export function routineService( changeSummary: `Restored from revision ${targetRevision.revisionNumber}`, restoredFromRevisionId: targetRevision.id, }); + await secretsSvc.syncEnvBindingsForTarget( + locked.companyId, + { targetType: "routine", targetId: locked.id }, + routineSnapshot.env, + { db: tx }, + ); return { routine: appended.routine, revision: appended.revision, @@ -2041,6 +2108,7 @@ export function routineService( secretMaterials: [...recreatedWebhookSecrets.values()].map((entry) => entry.secretMaterial), }; }); + return result; }, runRoutine: async (id: string, input: RunRoutine, actor?: Actor) => { @@ -2172,6 +2240,7 @@ export function routineService( idempotencyKey: routineRuns.idempotencyKey, triggerPayload: routineRuns.triggerPayload, dispatchFingerprint: routineRuns.dispatchFingerprint, + routineRevisionId: routineRuns.routineRevisionId, linkedIssueId: routineRuns.linkedIssueId, coalescedIntoRunId: routineRuns.coalescedIntoRunId, failureReason: routineRuns.failureReason, @@ -2204,6 +2273,7 @@ export function routineService( idempotencyKey: row.idempotencyKey, triggerPayload: row.triggerPayload as Record | null, dispatchFingerprint: row.dispatchFingerprint, + routineRevisionId: row.routineRevisionId, linkedIssueId: row.linkedIssueId, coalescedIntoRunId: row.coalescedIntoRunId, failureReason: row.failureReason, diff --git a/server/src/services/secrets.ts b/server/src/services/secrets.ts index 17f13cef..ce9ee967 100644 --- a/server/src/services/secrets.ts +++ b/server/src/services/secrets.ts @@ -61,6 +61,8 @@ const COMING_SOON_SECRET_PROVIDERS: ReadonlySet = new Set([ "gcp_secret_manager", "vault", ]); +type DbTransaction = Parameters[0]>[0]; +type SecretBindingDb = Pick; function remoteProviderHttpError(error: unknown, context: { companyId: string; @@ -195,6 +197,14 @@ type RuntimeSecretResolution = { manifestEntry: RuntimeSecretManifestEntry; }; +type SecretResolutionErrorCode = + | "binding_missing" + | "secret_deleted" + | "secret_inactive" + | "version_missing" + | "version_inactive" + | "provider_error"; + function asRecord(value: unknown): Record | null { if (typeof value !== "object" || value === null || Array.isArray(value)) return null; return value as Record; @@ -238,6 +248,33 @@ function defaultProviderConfigStatus(provider: SecretProvider): SecretProviderCo return COMING_SOON_SECRET_PROVIDERS.has(provider) ? "coming_soon" : "ready"; } +function secretResolutionErrorCode(error: unknown): SecretResolutionErrorCode { + if (isSecretProviderClientError(error)) return "provider_error"; + if (error instanceof HttpError) { + const details = asRecord(error.details); + switch (details?.code) { + case "binding_missing": + case "secret_deleted": + case "secret_inactive": + case "version_missing": + case "version_inactive": + case "provider_error": + return details.code; + } + if (error.message === "Secret is not active") return "secret_inactive"; + if (error.message === "Secret version not found") return "version_missing"; + if (error.message === "Secret version is not active") return "version_inactive"; + if ( + error.message === "Secret resolution requires a binding config path" || + error.message.startsWith("Secret is not bound to ") + ) { + return "binding_missing"; + } + if (error.status >= 500) return "provider_error"; + } + return "provider_error"; +} + function assertSelectableProviderConfig(config: { provider: string; status: string; @@ -259,8 +296,8 @@ export function secretService(db: Db) { fieldPath?: string; }; - async function getById(id: string) { - return db + async function getById(id: string, source: Pick = db) { + return source .select() .from(companySecrets) .where(eq(companySecrets.id, id)) @@ -321,7 +358,7 @@ export function secretService(db: Db) { ) { if (!context) return; if (!context.configPath) { - throw unprocessable("Secret resolution requires a binding config path"); + throw unprocessable("Secret resolution requires a binding config path", { code: "binding_missing" }); } const binding = await getBinding({ companyId, @@ -333,6 +370,7 @@ export function secretService(db: Db) { if (!binding) { throw unprocessable( `Secret is not bound to ${context.consumerType}:${context.consumerId} at ${context.configPath}`, + { code: "binding_missing" }, ); } } @@ -365,8 +403,12 @@ export function secretService(db: Db) { }); } - async function assertSecretInCompany(companyId: string, secretId: string) { - const secret = await getById(secretId); + async function assertSecretInCompany( + companyId: string, + secretId: string, + source: Pick = db, + ) { + const secret = await getById(secretId, source); if (!secret) throw notFound("Secret not found"); if (secret.status === "deleted") throw notFound("Secret not found"); if (secret.companyId !== companyId) throw unprocessable("Secret must belong to same company"); @@ -495,19 +537,24 @@ export function secretService(db: Db) { version: number | "latest", context?: SecretConsumerContext, ): Promise { - const secret = await assertSecretInCompany(companyId, secretId); + const secret = await getById(secretId); + if (!secret) throw notFound("Secret not found"); + if (secret.companyId !== companyId) throw unprocessable("Secret must belong to same company"); const resolvedVersion = version === "latest" ? secret.latestVersion : version; const providerId = secret.provider as SecretProvider; const configPath = context?.configPath ?? null; try { + if (secret.status === "deleted") { + throw new HttpError(404, "Secret not found", { code: "secret_deleted" }); + } if (secret.status !== "active") { - throw unprocessable("Secret is not active"); + throw unprocessable("Secret is not active", { code: "secret_inactive" }); } await assertBindingContext(companyId, secret.id, context); const versionRow = await getSecretVersion(secret.id, resolvedVersion); - if (!versionRow) throw notFound("Secret version not found"); + if (!versionRow) throw new HttpError(404, "Secret version not found", { code: "version_missing" }); if (versionRow.status === "disabled" || versionRow.status === "destroyed" || versionRow.revokedAt) { - throw unprocessable("Secret version is not active"); + throw unprocessable("Secret version is not active", { code: "version_inactive" }); } const provider = getSecretProvider(providerId); const providerConfig = await getSelectableRuntimeProviderConfig({ @@ -555,7 +602,7 @@ export function secretService(db: Db) { }, }; } catch (err) { - const errorCode = err instanceof Error ? err.message.slice(0, 120) : "resolution_failed"; + const errorCode = secretResolutionErrorCode(err); await recordAccessEvent({ companyId, secretId: secret.id, @@ -1984,6 +2031,7 @@ export function secretService(db: Db) { companyId: string, target: { targetType: SecretBindingTargetType; targetId: string; pathPrefix?: string }, envValue: unknown, + options?: { db?: SecretBindingDb }, ) => { const record = asRecord(envValue) ?? {}; const refs: Array<{ @@ -1992,12 +2040,13 @@ export function secretService(db: Db) { versionSelector: SecretVersionSelector; }> = []; const pathPrefix = target.pathPrefix ?? "env"; + const bindingDb = options?.db ?? db; for (const [key, rawBinding] of Object.entries(record)) { const parsed = envBindingSchema.safeParse(rawBinding); if (!parsed.success) continue; const binding = canonicalizeBinding(parsed.data as EnvBinding); if (binding.type !== "secret_ref") continue; - await assertSecretInCompany(companyId, binding.secretId); + await assertSecretInCompany(companyId, binding.secretId, bindingDb); refs.push({ secretId: binding.secretId, configPath: `${pathPrefix}.${key}`, @@ -2005,8 +2054,8 @@ export function secretService(db: Db) { }); } - await db.transaction(async (tx) => { - await tx + const writeBindings = async (targetDb: SecretBindingDb) => { + await targetDb .delete(companySecretBindings) .where( and( @@ -2017,7 +2066,7 @@ export function secretService(db: Db) { ), ); if (refs.length === 0) return; - await tx.insert(companySecretBindings).values( + await targetDb.insert(companySecretBindings).values( refs.map((ref) => ({ companyId, secretId: ref.secretId, @@ -2028,7 +2077,13 @@ export function secretService(db: Db) { required: true, })), ); - }); + }; + + if (options?.db) { + await writeBindings(options.db); + } else { + await db.transaction(async (tx) => writeBindings(tx)); + } return refs; }, diff --git a/ui/src/components/RoutineHistoryTab.test.tsx b/ui/src/components/RoutineHistoryTab.test.tsx index 56dd9c80..e55fb3e7 100644 --- a/ui/src/components/RoutineHistoryTab.test.tsx +++ b/ui/src/components/RoutineHistoryTab.test.tsx @@ -5,7 +5,9 @@ import type { ComponentProps } from "react"; import { createRoot } from "react-dom/client"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import type { + CompanySecret, Routine, + RoutineEnvConfig, RoutineRevision, RoutineRevisionSnapshotV1, } from "@paperclipai/shared"; @@ -95,6 +97,7 @@ function snapshotV1(overrides?: Partial): concurrencyPolicy: "coalesce_if_active", catchUpPolicy: "skip_missed", variables: [], + env: null, ...overrides, }, triggers: [], @@ -321,6 +324,152 @@ describe("RoutineHistoryTab", () => { expect(successCall).toBeTruthy(); }); + it("shows env summary on the revision preview and routes counts into restore dialog", async () => { + const env: RoutineEnvConfig = { + GH_TOKEN: { type: "secret_ref", secretId: "secret-1", version: "latest" }, + LOG_LEVEL: { type: "plain", value: "debug" }, + }; + const current = createRevision({ + id: "revision-2", + revisionNumber: 2, + snapshot: snapshotV1({ env }), + }); + const old = createRevision({ + id: "revision-1", + revisionNumber: 1, + snapshot: snapshotV1({ + env: { GH_TOKEN: { type: "secret_ref", secretId: "secret-1", version: 3 } }, + }), + }); + mockRoutinesApi.listRevisions.mockResolvedValue([current, old]); + const secrets: CompanySecret[] = [ + { + id: "secret-1", + companyId: "company-1", + key: "gh_token", + name: "github-bot", + provider: "local_encrypted", + status: "active", + managedMode: "paperclip_managed", + externalRef: null, + providerConfigId: null, + providerMetadata: null, + latestVersion: 4, + description: null, + lastResolvedAt: null, + lastRotatedAt: null, + deletedAt: null, + createdByAgentId: null, + createdByUserId: null, + createdAt: new Date("2026-04-01T00:00:00.000Z"), + updatedAt: new Date("2026-04-01T00:00:00.000Z"), + }, + ]; + await render({ secrets }); + expect(container.textContent).toContain("Env"); + expect(container.textContent).toContain("2 keys (1 secret ref)"); + + const oldRow = container.querySelector( + "[data-testid='revision-row-1']", + ) as HTMLButtonElement | null; + await act(async () => { + oldRow?.click(); + }); + await flush(); + const restoreButtons = Array.from(container.querySelectorAll("button")).filter( + (button) => button.textContent === "Restore as new revision", + ); + expect(restoreButtons.length).toBeGreaterThan(0); + await act(async () => { + restoreButtons[0].click(); + }); + await flush(); + expect(container.textContent).toContain("Routine secrets will revert"); + expect(container.textContent).toContain("1 key removed"); + expect(container.textContent).toContain("1 key changed"); + }); + + it("labels secret-ref env diffs by changed secret instead of binding kind", async () => { + const current = createRevision({ + id: "revision-2", + revisionNumber: 2, + snapshot: snapshotV1({ + env: { GH_TOKEN: { type: "secret_ref", secretId: "secret-2", version: "latest" } }, + }), + }); + const old = createRevision({ + id: "revision-1", + revisionNumber: 1, + snapshot: snapshotV1({ + env: { GH_TOKEN: { type: "secret_ref", secretId: "secret-1", version: "latest" } }, + }), + }); + const secrets: CompanySecret[] = [ + { + id: "secret-1", + companyId: "company-1", + key: "old_token", + name: "old-token", + provider: "local_encrypted", + status: "active", + managedMode: "paperclip_managed", + externalRef: null, + providerConfigId: null, + providerMetadata: null, + latestVersion: 1, + description: null, + lastResolvedAt: null, + lastRotatedAt: null, + deletedAt: null, + createdByAgentId: null, + createdByUserId: null, + createdAt: new Date("2026-04-01T00:00:00.000Z"), + updatedAt: new Date("2026-04-01T00:00:00.000Z"), + }, + { + id: "secret-2", + companyId: "company-1", + key: "new_token", + name: "new-token", + provider: "local_encrypted", + status: "active", + managedMode: "paperclip_managed", + externalRef: null, + providerConfigId: null, + providerMetadata: null, + latestVersion: 1, + description: null, + lastResolvedAt: null, + lastRotatedAt: null, + deletedAt: null, + createdByAgentId: null, + createdByUserId: null, + createdAt: new Date("2026-04-01T00:00:00.000Z"), + updatedAt: new Date("2026-04-01T00:00:00.000Z"), + }, + ]; + mockRoutinesApi.listRevisions.mockResolvedValue([current, old]); + await render({ secrets }); + + const oldRow = container.querySelector( + "[data-testid='revision-row-1']", + ) as HTMLButtonElement | null; + await act(async () => { + oldRow?.click(); + }); + await flush(); + const compareButton = Array.from(container.querySelectorAll("button")).find( + (button) => button.textContent === "Compare with current", + ); + await act(async () => { + compareButton?.click(); + }); + await flush(); + + expect(container.textContent).toContain("Env GH_TOKEN secret"); + expect(container.textContent).not.toContain("Env GH_TOKEN binding kind"); + }); + it("invokes onRestored with the restore response so the editor can rehydrate (PAP-3588)", async () => { const current = createRevision({ id: "revision-2", revisionNumber: 2 }); const old = createRevision({ diff --git a/ui/src/components/RoutineHistoryTab.tsx b/ui/src/components/RoutineHistoryTab.tsx index 0bae2714..35bd4cfb 100644 --- a/ui/src/components/RoutineHistoryTab.tsx +++ b/ui/src/components/RoutineHistoryTab.tsx @@ -2,10 +2,15 @@ import { useEffect, useMemo, useState } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { History as HistoryIcon, RotateCcw, Search } from "lucide-react"; import type { + CompanySecret, + EnvBinding, + EnvSecretRefBinding, Routine, + RoutineEnvConfig, RoutineRevision, RoutineRevisionSnapshotTriggerV1, RoutineVariable, + SecretVersionSelector, } from "@paperclipai/shared"; import { routinesApi, @@ -33,6 +38,7 @@ import { MarkdownBody } from "./MarkdownBody"; type AgentLookup = Map; type ProjectLookup = Map; +type SecretLookup = Map; type DirtyFieldDescriptor = { key: string; @@ -47,6 +53,7 @@ type Props = { onSaveEdits: () => void; agents: AgentLookup; projects: ProjectLookup; + secrets?: CompanySecret[]; onRestoreSecretMaterials: (response: RestoreRoutineRevisionResponse) => void; onRestored?: (response: RestoreRoutineRevisionResponse) => void; }; @@ -59,9 +66,14 @@ export function RoutineHistoryTab({ onSaveEdits, agents, projects, + secrets, onRestoreSecretMaterials, onRestored, }: Props) { + const secretLookup = useMemo( + () => new Map((secrets ?? []).map((secret) => [secret.id, secret])), + [secrets], + ); const queryClient = useQueryClient(); const { pushToast } = useToastActions(); const [selectedRevisionId, setSelectedRevisionId] = useState(null); @@ -277,6 +289,10 @@ export function RoutineHistoryTab({ selectedRevision, currentRevision, )} + envDiffCounts={summarizeEnvDiffCounts( + currentRevision.snapshot.routine.env ?? null, + selectedRevision.snapshot.routine.env ?? null, + )} /> )} @@ -289,6 +305,7 @@ export function RoutineHistoryTab({ initialNewRevisionId={currentRevision.id} agents={agents} projects={projects} + secrets={secretLookup} onRestore={(rev) => { setSelectedRevisionId(rev.id); setDiffOpen(false); @@ -498,6 +515,10 @@ function RevisionPreview({ highlighted ? "border-emerald-500/40 bg-emerald-500/10" : "border-border" }`; + const envSummary = summarizeEnv(snapshot.env ?? null); + const envDiffers = !!currentSnapshot + && JSON.stringify(normalizeEnv(currentSnapshot.env ?? null)) + !== JSON.stringify(normalizeEnv(snapshot.env ?? null)); const fieldRows: Array<{ key: string; label: string; value: string; differs: boolean }> = [ { key: "title", @@ -541,6 +562,12 @@ function RevisionPreview({ value: snapshot.catchUpPolicy.replaceAll("_", " "), differs: !!currentSnapshot && currentSnapshot.catchUpPolicy !== snapshot.catchUpPolicy, }, + { + key: "env", + label: "Env", + value: envSummary, + differs: envDiffers, + }, ]; return ( @@ -670,6 +697,7 @@ function RestoreConfirmDialog({ onConfirm, pending, recreatedWebhookLabels, + envDiffCounts, }: { open: boolean; onOpenChange: (open: boolean) => void; @@ -680,6 +708,7 @@ function RestoreConfirmDialog({ onConfirm: () => void; pending: boolean; recreatedWebhookLabels: string[]; + envDiffCounts: EnvDiffCounts; }) { const newRevisionNumber = currentRevisionNumber + 1; return ( @@ -698,6 +727,12 @@ function RestoreConfirmDialog({ Routine field values, variables, and schedule cron will revert. + {envDiffCounts.total > 0 && ( +
  • + + Routine secrets will revert: {formatEnvDiffCounts(envDiffCounts)}. +
  • + )}
  • Previous run history is preserved. @@ -743,6 +778,7 @@ function RoutineRevisionDiffModal({ initialNewRevisionId, agents, projects, + secrets, onRestore, }: { open: boolean; @@ -752,6 +788,7 @@ function RoutineRevisionDiffModal({ initialNewRevisionId: string; agents: AgentLookup; projects: ProjectLookup; + secrets: SecretLookup; onRestore: (revision: RoutineRevision) => void; }) { const [leftId, setLeftId] = useState(initialOldRevisionId); @@ -767,8 +804,8 @@ function RoutineRevisionDiffModal({ const left = revisions.find((r) => r.id === leftId) ?? null; const right = revisions.find((r) => r.id === rightId) ?? null; const fieldChanges = useMemo( - () => (left && right ? computeFieldChanges(left, right, agents, projects) : []), - [left, right, agents, projects], + () => (left && right ? computeFieldChanges(left, right, agents, projects, secrets) : []), + [left, right, agents, projects, secrets], ); const descriptionDiff = useMemo( () => (left && right @@ -1003,6 +1040,7 @@ function computeFieldChanges( right: RoutineRevision, agents: AgentLookup, projects: ProjectLookup, + secrets: SecretLookup, ): Array<{ field: string; oldValue: string | null; newValue: string | null }> { const oldRoutine = left.snapshot.routine; const newRoutine = right.snapshot.routine; @@ -1042,10 +1080,170 @@ function computeFieldChanges( newValue: summarizeVariables(newRoutine.variables), }); } + compareEnv(oldRoutine.env ?? null, newRoutine.env ?? null, secrets, changes); compareTriggers(left.snapshot.triggers, right.snapshot.triggers, changes); return changes; } +function normalizeEnv(env: RoutineEnvConfig | null): Record { + if (!env) return {}; + return env; +} + +function envBindingKind(binding: EnvBinding): "plain" | "secret_ref" { + if (typeof binding === "string") return "plain"; + if (binding && typeof binding === "object" && "type" in binding && binding.type === "secret_ref") { + return "secret_ref"; + } + return "plain"; +} + +function asSecretRef(binding: EnvBinding): EnvSecretRefBinding | null { + if (typeof binding === "string") return null; + if (binding && typeof binding === "object" && "type" in binding && binding.type === "secret_ref") { + return binding; + } + return null; +} + +function formatVersionSelector(version: SecretVersionSelector | undefined): string { + if (version == null || version === "latest") return "latest"; + return `v${version}`; +} + +function describeSecretRef(ref: EnvSecretRefBinding, secrets: SecretLookup): string { + const secret = secrets.get(ref.secretId); + const name = secret?.name ?? ""; + return `${name} ${formatVersionSelector(ref.version)}`; +} + +function describeEnvBinding(binding: EnvBinding | undefined, secrets: SecretLookup): string { + if (binding === undefined) return "—"; + const ref = asSecretRef(binding); + if (ref) return `secret_ref → ${describeSecretRef(ref, secrets)}`; + return "plain (set)"; +} + +function summarizeEnv(env: RoutineEnvConfig | null): string { + const entries = Object.entries(normalizeEnv(env)); + if (entries.length === 0) return ""; + const secretCount = entries.filter(([, binding]) => envBindingKind(binding) === "secret_ref").length; + const keyLabel = entries.length === 1 ? "key" : "keys"; + if (secretCount === 0) return `${entries.length} ${keyLabel}`; + return `${entries.length} ${keyLabel} (${secretCount} secret ${secretCount === 1 ? "ref" : "refs"})`; +} + +type EnvDiffCounts = { + added: number; + removed: number; + changed: number; + total: number; +}; + +function summarizeEnvDiffCounts( + current: RoutineEnvConfig | null, + target: RoutineEnvConfig | null, +): EnvDiffCounts { + const currentRec = normalizeEnv(current); + const targetRec = normalizeEnv(target); + let added = 0; + let removed = 0; + let changed = 0; + const keys = new Set([...Object.keys(currentRec), ...Object.keys(targetRec)]); + for (const key of keys) { + const inCurrent = key in currentRec; + const inTarget = key in targetRec; + if (inTarget && !inCurrent) { + added += 1; + continue; + } + if (!inTarget && inCurrent) { + removed += 1; + continue; + } + if (JSON.stringify(currentRec[key]) !== JSON.stringify(targetRec[key])) { + changed += 1; + } + } + return { added, removed, changed, total: added + removed + changed }; +} + +function formatEnvDiffCounts(counts: EnvDiffCounts): string { + const parts: string[] = []; + if (counts.added > 0) parts.push(`${counts.added} ${counts.added === 1 ? "key" : "keys"} added`); + if (counts.removed > 0) parts.push(`${counts.removed} ${counts.removed === 1 ? "key" : "keys"} removed`); + if (counts.changed > 0) parts.push(`${counts.changed} ${counts.changed === 1 ? "key" : "keys"} changed`); + return parts.join(", "); +} + +function compareEnv( + oldEnv: RoutineEnvConfig | null, + newEnv: RoutineEnvConfig | null, + secrets: SecretLookup, + changes: Array<{ field: string; oldValue: string | null; newValue: string | null }>, +) { + const oldRec = normalizeEnv(oldEnv); + const newRec = normalizeEnv(newEnv); + const keys = new Set([...Object.keys(oldRec), ...Object.keys(newRec)]); + const sortedKeys = [...keys].sort(); + for (const key of sortedKeys) { + const oldBinding = oldRec[key]; + const newBinding = newRec[key]; + const inOld = key in oldRec; + const inNew = key in newRec; + if (inNew && !inOld) { + changes.push({ + field: `Env added (${key})`, + oldValue: "—", + newValue: describeEnvBinding(newBinding, secrets), + }); + continue; + } + if (!inNew && inOld) { + changes.push({ + field: `Env removed (${key})`, + oldValue: describeEnvBinding(oldBinding, secrets), + newValue: "—", + }); + continue; + } + if (JSON.stringify(oldBinding) === JSON.stringify(newBinding)) continue; + const oldKind = envBindingKind(oldBinding); + const newKind = envBindingKind(newBinding); + if (oldKind !== newKind) { + changes.push({ + field: `Env ${key} binding kind`, + oldValue: describeEnvBinding(oldBinding, secrets), + newValue: describeEnvBinding(newBinding, secrets), + }); + continue; + } + if (newKind === "secret_ref") { + const oldRef = asSecretRef(oldBinding)!; + const newRef = asSecretRef(newBinding)!; + if (oldRef.secretId !== newRef.secretId) { + changes.push({ + field: `Env ${key} secret`, + oldValue: describeEnvBinding(oldBinding, secrets), + newValue: describeEnvBinding(newBinding, secrets), + }); + continue; + } + changes.push({ + field: `Env ${key} version`, + oldValue: describeSecretRef(oldRef, secrets), + newValue: describeSecretRef(newRef, secrets), + }); + continue; + } + changes.push({ + field: `Env ${key} value`, + oldValue: "plain (set)", + newValue: "plain (changed)", + }); + } +} + function summarizeVariables(variables: RoutineVariable[]): string { if (variables.length === 0) return "(none)"; return variables diff --git a/ui/src/pages/RoutineDetail.tsx b/ui/src/pages/RoutineDetail.tsx index a8a0e9d0..a502feef 100644 --- a/ui/src/pages/RoutineDetail.tsx +++ b/ui/src/pages/RoutineDetail.tsx @@ -8,6 +8,7 @@ import { Clock3, Copy, History as HistoryIcon, + KeyRound, Play, RefreshCw, Repeat, @@ -18,6 +19,8 @@ import { } from "lucide-react"; import { ApiError } from "../api/client"; import { routinesApi, type RoutineTriggerResponse, type RotateRoutineTriggerResponse, type RestoreRoutineRevisionResponse } from "../api/routines"; +import { secretsApi } from "../api/secrets"; +import { EnvVarEditor } from "../components/EnvVarEditor"; import { RoutineHistoryTab, type RoutineHistoryDirtyFieldDescriptor, @@ -63,13 +66,19 @@ import { } from "@/components/ui/select"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Badge } from "@/components/ui/badge"; -import type { RoutineDetail as RoutineDetailType, RoutineTrigger, RoutineVariable } from "@paperclipai/shared"; +import type { + EnvBinding, + RoutineDetail as RoutineDetailType, + RoutineEnvConfig, + RoutineTrigger, + RoutineVariable, +} from "@paperclipai/shared"; const concurrencyPolicies = ["coalesce_if_active", "always_enqueue", "skip_if_active"]; const catchUpPolicies = ["skip_missed", "enqueue_missed_with_cap"]; const triggerKinds = ["schedule", "webhook"]; const signingModes = ["bearer", "hmac_sha256", "github_hmac", "none"]; -const routineTabs = ["triggers", "runs", "activity", "history"] as const; +const routineTabs = ["triggers", "runs", "activity", "secrets", "history"] as const; const concurrencyPolicyDescriptions: Record = { coalesce_if_active: "Keep one follow-up run queued while an active run is still working.", always_enqueue: "Queue every trigger occurrence, even if several runs stack up.", @@ -141,12 +150,14 @@ function buildRoutineMutationPayload(input: { concurrencyPolicy: string; catchUpPolicy: string; variables: RoutineVariable[]; + env: RoutineEnvConfig | null; }) { return { ...input, description: input.description.trim() || null, projectId: input.projectId || null, assigneeAgentId: input.assigneeAgentId || null, + env: input.env && Object.keys(input.env).length > 0 ? input.env : null, }; } @@ -304,6 +315,7 @@ export function RoutineDetail() { concurrencyPolicy: string; catchUpPolicy: string; variables: RoutineVariable[]; + env: RoutineEnvConfig | null; }>({ title: "", description: "", @@ -313,6 +325,7 @@ export function RoutineDetail() { concurrencyPolicy: "coalesce_if_active", catchUpPolicy: "skip_missed", variables: [], + env: null, }); const activeTab = useMemo(() => getRoutineTabFromSearch(location.search), [location.search]); @@ -366,6 +379,21 @@ export function RoutineDetail() { queryFn: () => accessApi.listUserDirectory(selectedCompanyId!), enabled: !!selectedCompanyId, }); + const { data: availableSecrets = [] } = useQuery({ + queryKey: selectedCompanyId ? queryKeys.secrets.list(selectedCompanyId) : ["secrets", "none"], + queryFn: () => secretsApi.list(selectedCompanyId!), + enabled: Boolean(selectedCompanyId), + }); + const createSecret = useMutation({ + mutationFn: (input: { name: string; value: string }) => { + if (!selectedCompanyId) throw new Error("Select a company to create secrets"); + return secretsApi.create(selectedCompanyId, input); + }, + onSuccess: () => { + if (!selectedCompanyId) return; + queryClient.invalidateQueries({ queryKey: queryKeys.secrets.list(selectedCompanyId) }); + }, + }); const routineDefaults = useMemo( () => @@ -379,6 +407,7 @@ export function RoutineDetail() { concurrencyPolicy: routine.concurrencyPolicy, catchUpPolicy: routine.catchUpPolicy, variables: routine.variables, + env: routine.env ?? null, } : null, [routine], @@ -408,6 +437,9 @@ export function RoutineDetail() { if (JSON.stringify(editDraft.variables) !== JSON.stringify(routineDefaults.variables)) { result.push({ key: "variables", label: "the variables" }); } + if (JSON.stringify(editDraft.env ?? null) !== JSON.stringify(routineDefaults.env ?? null)) { + result.push({ key: "env", label: "the secrets" }); + } return result; }, [editDraft, routineDefaults]); const isEditDirty = dirtyFields.length > 0; @@ -1082,6 +1114,10 @@ export function RoutineDetail() { Activity + + + Secrets + History @@ -1226,6 +1262,24 @@ export function RoutineDetail() { )} + +

    + Routine secrets apply to every issue this routine creates. They override matching keys in + project and agent env. PAPERCLIP_* variables are reserved. +

    + } + secrets={availableSecrets} + onCreateSecret={async (name, value) => { + const created = await createSecret.mutateAsync({ name, value }); + return created; + }} + onChange={(env) => + setEditDraft((current) => ({ ...current, env: env ?? null })) + } + /> +
    + { if (response.secretMaterials.length > 0) { setSecretMessage({ @@ -1277,6 +1332,7 @@ export function RoutineDetail() { concurrencyPolicy: response.routine.concurrencyPolicy, catchUpPolicy: response.routine.catchUpPolicy, variables: response.routine.variables, + env: response.routine.env ?? null, }); hydratedRoutineIdRef.current = response.routine.id; }} diff --git a/ui/storybook/stories/routine-secrets.stories.tsx b/ui/storybook/stories/routine-secrets.stories.tsx new file mode 100644 index 00000000..612145cb --- /dev/null +++ b/ui/storybook/stories/routine-secrets.stories.tsx @@ -0,0 +1,257 @@ +import { useEffect, useState, type ReactNode } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { useQueryClient } from "@tanstack/react-query"; +import { KeyRound } from "lucide-react"; +import type { + CompanySecret, + EnvBinding, + Routine, + RoutineEnvConfig, + RoutineRevision, + RoutineRevisionSnapshotV1, +} from "@paperclipai/shared"; +import { EnvVarEditor } from "@/components/EnvVarEditor"; +import { RoutineHistoryTab } from "@/components/RoutineHistoryTab"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { useCompany } from "@/context/CompanyContext"; +import { queryKeys } from "@/lib/queryKeys"; +import { storybookCompanies, storybookSecrets } from "../fixtures/paperclipData"; + +const COMPANY_ID = "company-storybook"; + +if (typeof window !== "undefined") { + window.localStorage.setItem("paperclip.selectedCompanyId", COMPANY_ID); +} + +function StorybookRoutineFixtures({ + revisions, + children, +}: { + revisions: RoutineRevision[]; + children: ReactNode; +}) { + const queryClient = useQueryClient(); + queryClient.setQueryData(queryKeys.companies.all, { companies: storybookCompanies, unauthorized: false }); + queryClient.setQueryData(queryKeys.secrets.list(COMPANY_ID), storybookSecrets); + queryClient.setQueryData(queryKeys.routines.revisions("routine-storybook"), revisions); + + const { selectedCompanyId, setSelectedCompanyId } = useCompany(); + useEffect(() => { + if (selectedCompanyId !== COMPANY_ID) { + setSelectedCompanyId(COMPANY_ID); + } + }, [selectedCompanyId, setSelectedCompanyId]); + + if (selectedCompanyId !== COMPANY_ID) return null; + return <>{children}; +} + +const meta: Meta = { + title: "Product/Routines · Secrets tab", + parameters: { + layout: "fullscreen", + a11y: { test: "off" }, + }, +}; + +export default meta; + +type Story = StoryObj; + +function SecretsTabSurface({ + initial, + title, +}: { + initial: RoutineEnvConfig | null; + title: string; +}) { + const [env, setEnv] = useState>(() => (initial ?? {}) as Record); + return ( + + + + + {title} + + + The Secrets tab on a routine reuses the env-var editor and adds a one-line precedence helper. + + + +

    + Routine secrets apply to every issue this routine creates. They override matching keys in + project and agent env. PAPERCLIP_* variables are reserved. +

    + ({ + ...storybookSecrets[0]!, + id: `secret-${Math.random().toString(36).slice(2, 8)}`, + name, + key: name.toLowerCase(), + description: `New routine secret ${name}`, + })} + onChange={(next) => setEnv((next ?? {}) as Record)} + /> +
    +
    + ); +} + +export const SecretsTabEmpty: Story = { + render: () => ( +
    + +
    + ), +}; + +export const SecretsTabConfigured: Story = { + render: () => ( +
    + +
    + ), +}; + +export const SecretsTabDisabledOrMissing: Story = { + render: () => ( +
    + +
    + ), +}; + +function makeSnapshot(env: RoutineEnvConfig | null): RoutineRevisionSnapshotV1 { + return { + version: 1, + routine: { + id: "routine-storybook", + companyId: COMPANY_ID, + projectId: null, + goalId: null, + parentIssueId: null, + title: "Nightly digest", + description: "Summarize agent activity each night.", + assigneeAgentId: null, + priority: "medium", + status: "active", + concurrencyPolicy: "coalesce_if_active", + catchUpPolicy: "skip_missed", + variables: [], + env, + }, + triggers: [], + }; +} + +function makeRoutine(latestRevisionId: string, latestRevisionNumber: number): Routine { + return { + id: "routine-storybook", + companyId: COMPANY_ID, + projectId: null, + goalId: null, + parentIssueId: null, + title: "Nightly digest", + description: "Summarize agent activity each night.", + assigneeAgentId: null, + priority: "medium", + status: "active", + concurrencyPolicy: "coalesce_if_active", + catchUpPolicy: "skip_missed", + variables: [], + env: makeSnapshot({ + OPENAI_API_KEY: { type: "secret_ref", secretId: "secret-openai", version: "latest" }, + STAGE: { type: "plain", value: "production" }, + }).routine.env, + latestRevisionId, + latestRevisionNumber, + createdByAgentId: null, + createdByUserId: "user-board", + updatedByAgentId: null, + updatedByUserId: "user-board", + lastTriggeredAt: null, + lastEnqueuedAt: null, + createdAt: new Date("2026-05-01T11:00:00.000Z"), + updatedAt: new Date("2026-05-04T12:00:00.000Z"), + }; +} + +export const HistoryDiffWithEnv: Story = { + name: "History diff — env keys added/removed/changed", + render: () => { + const revisions: RoutineRevision[] = [ + { + id: "rev-2", + companyId: COMPANY_ID, + routineId: "routine-storybook", + revisionNumber: 2, + title: "Nightly digest", + description: "Summarize agent activity each night.", + snapshot: makeSnapshot({ + OPENAI_API_KEY: { type: "secret_ref", secretId: "secret-openai", version: "latest" }, + STAGE: { type: "plain", value: "production" }, + }), + changeSummary: "Added STAGE plain value", + restoredFromRevisionId: null, + createdByAgentId: null, + createdByUserId: "user-board", + createdByRunId: null, + createdAt: new Date("2026-05-04T12:00:00.000Z"), + }, + { + id: "rev-1", + companyId: COMPANY_ID, + routineId: "routine-storybook", + revisionNumber: 1, + title: "Nightly digest", + description: "Summarize agent activity each night.", + snapshot: makeSnapshot({ + OPENAI_API_KEY: { type: "secret_ref", secretId: "secret-openai", version: 2 }, + GH_TOKEN: { type: "plain", value: "legacy" }, + }), + changeSummary: "Created routine", + restoredFromRevisionId: null, + createdByAgentId: null, + createdByUserId: "user-board", + createdByRunId: null, + createdAt: new Date("2026-05-01T11:00:00.000Z"), + }, + ]; + return ( + +
    + {}} + onSaveEdits={() => {}} + agents={new Map()} + projects={new Map()} + secrets={storybookSecrets as CompanySecret[]} + onRestoreSecretMaterials={() => {}} + /> +
    +
    + ); + }, +};