mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-19 20:10:39 +09:00
## 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>
240 lines
7.5 KiB
TypeScript
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"],
|
|
},
|
|
});
|
|
});
|
|
});
|