paperclip/server/src/__tests__/heartbeat-project-env.test.ts
Dotta 705c1b8d81
[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>
2026-05-17 16:30:34 -05:00

240 lines
7.5 KiB
TypeScript

import { describe, expect, it, vi } from "vitest";
import { buildSkillMentionHref } from "@paperclipai/shared";
import {
applyRunScopedMentionedSkillKeys,
extractMentionedSkillIdsFromSources,
resolveExecutionRunAdapterConfig,
} from "../services/heartbeat.ts";
describe("resolveExecutionRunAdapterConfig", () => {
it("overlays project and routine env on top of agent env and unions secret keys", async () => {
const resolveAdapterConfigForRuntime = vi.fn().mockResolvedValue({
config: {
env: {
SHARED_KEY: "agent",
AGENT_ONLY: "agent-only",
},
other: "value",
},
secretKeys: new Set(["AGENT_SECRET"]),
manifest: [
{
configPath: "env.AGENT_SECRET",
envKey: "AGENT_SECRET",
secretId: "secret-agent",
secretKey: "agent-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,
} as any,
});
expect(result.resolvedConfig).toMatchObject({
other: "value",
env: {
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", "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<string, unknown>) => ({
config: {
...config,
env: { ...(config.env as Record<string, unknown>) },
},
secretKeys: new Set<string>(),
manifest: [],
}));
const resolveEnvBindings = vi.fn(async (_companyId, env: Record<string, unknown>) => ({
env: Object.fromEntries(
Object.entries(env).filter((entry): entry is [string, string] => typeof entry[1] === "string"),
),
secretKeys: new Set<string>(),
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 () => {
const resolveAdapterConfigForRuntime = vi.fn().mockResolvedValue({
config: { env: { AGENT_ONLY: "agent-only" } },
secretKeys: new Set<string>(),
manifest: [],
});
const resolveEnvBindings = vi.fn();
const result = await resolveExecutionRunAdapterConfig({
companyId: "company-1",
executionRunConfig: { env: { AGENT_ONLY: "agent-only" } },
projectEnv: null,
secretsSvc: {
resolveAdapterConfigForRuntime,
resolveEnvBindings,
} as any,
});
expect(result.resolvedConfig.env).toEqual({ AGENT_ONLY: "agent-only" });
expect(result.secretManifest).toEqual([]);
expect(resolveEnvBindings).not.toHaveBeenCalled();
});
});
describe("extractMentionedSkillIdsFromSources", () => {
it("collects explicit skill mention ids across issue sources", () => {
const releaseHref = buildSkillMentionHref("skill-1", "release-changelog");
const browserHref = buildSkillMentionHref("skill-2", "agent-browser");
expect(
extractMentionedSkillIdsFromSources([
`Please use [/release-changelog](${releaseHref})`,
`And also [/agent-browser](${browserHref})`,
`Duplicate mention [/release-changelog](${releaseHref})`,
]),
).toEqual(["skill-1", "skill-2"]);
});
});
describe("applyRunScopedMentionedSkillKeys", () => {
it("adds mentioned skills without mutating the original config", () => {
const originalConfig = {
command: "codex",
paperclipSkillSync: {
desiredSkills: ["paperclipai/paperclip/paperclip"],
},
};
const updatedConfig = applyRunScopedMentionedSkillKeys(originalConfig, [
"company/company-1/release-changelog",
"paperclipai/paperclip/paperclip",
"company/company-1/release-changelog",
]);
expect(updatedConfig).toEqual({
command: "codex",
paperclipSkillSync: {
desiredSkills: [
"paperclipai/paperclip/paperclip",
"company/company-1/release-changelog",
],
},
});
expect(originalConfig).toEqual({
command: "codex",
paperclipSkillSync: {
desiredSkills: ["paperclipai/paperclip/paperclip"],
},
});
});
});