[codex] Add routine env secrets support (#6212)

## Thinking Path

> - Paperclip orchestrates AI agents for zero-human companies.
> - Scheduled routines are the control-plane path for recurring agent
work.
> - Routines already had dispatch/history, but their runtime environment
did not carry routine-owned secret bindings through execution.
> - Operators need routine-specific secrets that can override
project/agent env without exposing secret values in history, logs, or
access events.
> - This pull request adds the routine env runtime contract, wires it
into execution, and makes the routine UI/history surfaces show safe
secret metadata.
> - The benefit is that routine executions can use scoped secret refs
predictably while preserving company boundaries and auditability.

## What Changed

- Added routine env persistence/runtime support, including
`routines.env`, `routine_runs.routine_revision_id`, revision snapshots,
and idempotent migration `0086_routine_env_runtime_contract`.
- Resolved routine env during heartbeat adapter config assembly with
precedence `agent < project < routine` and secret access events recorded
against the routine consumer.
- Added secret binding synchronization for routine create/update/restore
flows and guarded cross-company, missing, disabled, and deleted secret
cases.
- Added a Secrets tab to routine detail, env/secret history diff
rendering, and Storybook coverage for the new UI states.
- Added server/UI regression tests, including an embedded-Postgres QA
path for routine secret execution and restore behavior.
- Updated implementation/database docs for routine env and
secret-binding behavior.

## Verification

- `pnpm install --frozen-lockfile` after rebasing onto
`public-gh/master` to refresh workspace links for the newly-added
upstream Grok adapter package.
- `pnpm exec vitest run
server/src/__tests__/heartbeat-project-env.test.ts
server/src/__tests__/routines-service.test.ts
server/src/__tests__/secrets-service.test.ts
server/src/__tests__/qa-routine-secrets-e2e.test.ts
ui/src/components/RoutineHistoryTab.test.tsx` passed: 5 files, 92 tests.
- `pnpm -r typecheck` passed across the workspace.
- `pnpm build` passed. Vite emitted the existing
large-chunk/dynamic-import warnings.
- UI screenshots were captured locally during QA in
`artifacts/pap-9521/` and `artifacts/pap-9522/`; generated screenshots
are not committed to avoid adding binary artifacts to the repo.

## Risks

- Migration risk is limited by `IF NOT EXISTS` guards for the new
columns, FK, and index, and the migration is ordered as `0086`
immediately after upstream `0085`.
- Runtime behavior changes env precedence for routine executions by
adding routine env as the highest-precedence layer; tests cover
agent/project/routine precedence.
- Secret handling is security-sensitive; tests cover value-free
manifests/events/errors, disabled/missing/deleted secrets, and
cross-company rejection.
- UI history now renders routine env/secret diffs; tests and Storybook
stories cover the main rendering paths.

> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and
discuss it in `#dev` before opening the PR. Feature PRs that overlap
with planned core work may need to be redirected — check the roadmap
first. See `CONTRIBUTING.md`.

## Model Used

- OpenAI Codex coding agent based on GPT-5, with shell/tool use and
medium reasoning effort.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] If this change affects the UI, I have included before/after
screenshots
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Dotta
2026-05-17 16:30:34 -05:00
committed by GitHub
parent 3e6610fb93
commit 705c1b8d81
20 changed files with 1736 additions and 50 deletions
@@ -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");
@@ -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
}
]
}
+4 -1
View File
@@ -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<RoutineVariable[]>().notNull().default([]),
env: jsonb("env").$type<RoutineEnvConfig>(),
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<Record<string, unknown>>(),
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),
+3
View File
@@ -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,
+1
View File
@@ -279,6 +279,7 @@ export type {
} from "./secrets.js";
export type {
Routine,
RoutineEnvConfig,
RoutineManagedByPlugin,
RoutineVariable,
RoutineVariableDefaultValue,
+6
View File
@@ -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<string, EnvBinding>;
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<string, unknown> | null;
dispatchFingerprint: string | null;
@@ -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<typeof createRoutineSchema>;
@@ -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({