Files
PaperClipAI/server/src/__tests__/qa-routine-secrets-e2e.test.ts
T
ad961227f5 feat(secrets): add user-specific runtime secrets (#8825)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - Agent runs often need provider credentials, API tokens, and other
environment-bound secrets.
> - Company-level secrets work for shared credentials, but they do not
model values that should differ by human operator.
> - Without a user-scoped model, a run can dispatch without knowing
whether the responsible human has supplied the needed value.
> - Paperclip also needs run attribution to make those user-scoped
runtime checks deterministic and auditable.
> - This pull request adds user-specific secret definitions, per-user
values, environment bindings, responsible-user attribution, and runtime
resolution gates.
> - The benefit is that teams can define the secret once, let each user
provide their own value, and block runs before dispatch when required
user secrets or active definitions are unavailable.

## Linked Issues or Issue Description

Refs #224
Refs #6057

This PR implements user-specific secret support as a core
secret-management capability rather than a one-off adapter setting. It
is related to existing public work on company secrets UI and runtime
secret refs, but is distinct because the value is owned by the
responsible user and resolved at run dispatch time.

Related PR search before opening found existing secrets work such as
#1550, #8256, #8614, #8634, and #8647; none of those add the full
user-secret definition/value/runtime gate covered here.

## What Changed

- Added user-secret definitions and per-user "My secrets" values,
keeping stored values out of access metadata.
- Added `user_secret_ref` environment bindings and UI affordances to
pick them alongside existing secret refs.
- Added responsible-user runtime resolution so user-secret refs resolve
against the human responsible for the run.
- Added pre-dispatch missing-secret gates so runs fail before adapter
dispatch when required user values are absent or definitions are
inactive.
- Added low-trust allowlist hardening for user-secret runtime access.
- Added issue, routine, run, and agent API key responsible-user
attribution and fail-closed dispatch behavior when attribution cannot be
resolved.
- Added denial-copy mapping so responsible-user authorization failures
surface as actionable run outcomes instead of opaque setup failures.
- Added OpenAPI documentation for the user-secret routes.
- Rebases cleanly on current `master`; migrations were renumbered
incrementally as `0128_user_specific_secrets`,
`0129_agent_api_key_responsible_user`, and
`0130_run_responsible_user_invariant` after upstream `0126`/`0127`
migrations.
- Removed previously committed local design screenshots so the PR
contains code/docs/tests only.

## Verification

- PASS: PR head `2527febd106bcf3ca264ca0da7fca491084192d6` is based on
`paperclipai/paperclip:master`.
- PASS: `git diff --check`
- PASS: `git diff --name-only public/master...HEAD | rg
'^(pnpm-lock\\.yaml|\\.github/workflows/|screenshots/)' || true`
produced no files.
- PASS: migration journal audit confirmed unique indexes through `130`
with tail entries `0126_issue_comment_derived_attribution`,
`0127_environment_custom_images_instance_scoped`,
`0128_user_specific_secrets`, `0129_agent_api_key_responsible_user`, and
`0130_run_responsible_user_invariant`.
- PASS: `pnpm --filter @paperclipai/ui typecheck`
- PASS: `pnpm --filter @paperclipai/server typecheck`
- PASS: `pnpm --filter @paperclipai/server exec vitest run
src/__tests__/heartbeat-responsible-user-invariant.test.ts`
- PASS: `pnpm --filter @paperclipai/server exec vitest run
src/__tests__/heartbeat-active-run-output-watchdog.test.ts
src/__tests__/heartbeat-stale-queue-invalidation.test.ts
src/__tests__/heartbeat-workspace-finalize-branch.test.ts
src/__tests__/issue-monitor-scheduler.test.ts`
- PASS: `pnpm --filter @paperclipai/server exec vitest run
src/__tests__/heartbeat-comment-wake-batching.test.ts
src/__tests__/heartbeat-retry-scheduling.test.ts
src/__tests__/heartbeat-accepted-plan-workspace-refresh.test.ts
src/__tests__/heartbeat-plugin-environment.test.ts`
- PASS: `pnpm --filter @paperclipai/server exec vitest run
src/__tests__/low-trust-red-team-routes.test.ts`
- PASS: `pnpm --filter @paperclipai/server exec vitest run
src/__tests__/secrets-service.test.ts` (55 tests)
- PASS: `pnpm vitest run server/src/__tests__/secrets-routes.test.ts
server/src/__tests__/secrets-service.test.ts` (89 tests after final
Greptile cleanup fixes)
- PASS: `pnpm --filter @paperclipai/server exec vitest run
src/__tests__/heartbeat-issue-liveness-escalation.test.ts` (17 tests
after the final rebase CI fix)
- PASS: focused server Vitest batches covering heartbeat recovery,
project env, plugin env, routines, low-trust, pipelines, monitors,
watchdog, and stale queue paths.
- PASS: GitHub checks are green on
`2527febd106bcf3ca264ca0da7fca491084192d6`, including Typecheck +
Release Registry, Build, General tests, serialized server suites, e2e,
Canary Dry Run, verify, security checks, and Greptile Review.
- PASS: Greptile Review completed successfully on
`2527febd106bcf3ca264ca0da7fca491084192d6` with Confidence Score 5/5,
and GraphQL review-thread audit returned zero unresolved non-outdated
threads.

## Risks

- Runtime behavior now depends on a run having a correct responsible
user; missing or incorrect responsibility assignment can block runs
before adapter dispatch.
- `user_secret_ref` bindings intentionally expose metadata without
values, but UI/API callers may need to handle the new binding kind
explicitly.
- External secret providers and IAM policies are not automatically
provisioned by this PR; operators still need to configure provider-side
access for non-local vaults.
- The PR is broad across db/shared/server/UI/runtime paths, so release
validation should include both API and UI secret workflows before merge.
- The migration renumbering is intentionally incremental after upstream
migrations; the branch migrations use guarded
column/table/index/constraint creation so users who tested the older
draft numbering should not hit duplicate DDL for the existing objects.

> 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, GPT-5-based coding agent (`gpt-5`), Codex local adapter
with shell/tool use and code execution. Context window and internal
reasoning mode are not exposed by the runtime.

## 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 searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 05:58:20 -05:00

466 lines
16 KiB
TypeScript

// 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,
documentRevisions,
documents,
projects,
routineDocuments,
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<typeof createDb>;
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | 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(routineDocuments);
await db.delete(routines);
await db.delete(documents);
await db.delete(documentRevisions);
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,
defaultResponsibleUserId: "responsible-user",
});
// 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);
});
});