mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-15 03:51:22 +02:00
## Thinking Path > - Paperclip coordinates agents and controls when they can run. > - Lifecycle plugins prepare and remove resources for each agent. > - Those resources can require private connection credentials. > - The current SDK can resolve configured secrets but cannot save these credentials securely. > - Ordinary company secrets can be bound to agents and do not provide the required server-only boundary. > - This change saves agent-owned instance secrets through the existing local lifecycle result and native secret service. > - The tenant can retain runtime credentials through restarts without a separate encryption system or remote API. ## Linked Issues or Issue Description **Subsystem affected** Agent lifecycle, plugin contract, and native secret storage. **Problem or motivation** Lifecycle plugins need to save private runtime connection details for the owning agent. Worker memory loses these details on restart. Plugin state and company secrets do not provide a private runtime-only boundary. **Proposed solution** Add optional instance-secret changes to lifecycle results. Save them through the existing secret service and local encrypted provider before accepting the result. Require company, agent, plugin, phase, operation, revision, and current unexpired attempt ownership. Supply saved values through the existing local preparation/resume request. **Alternatives considered** A separate encrypted store duplicates the native secret system. Generic plugin company-secret writes proposed by #5007 do not supply agent ownership or a server-only secret scope. This change does not add that general write API. **Roadmap alignment** Extends the existing lifecycle contract from #15631. It does not add a resource provider or select an execution backend. ## What Changed - Add an instance scope to native secrets with mandatory agent/plugin ownership, a company-qualified agent foreign key, and key uniqueness per owner. Existing company and user scopes retain their constraints. - Reuse native creation, rotation, deletion, secret versions, access audit, and the local encrypted provider. Keep values out of company catalogs, normal bindings, agent access, and public agent records. - Accept local secret patches only after locking and checking the current lifecycle operation and required plugin selection. Storage failure or a stale reply cannot complete setup. Expired or replaced attempt owners cannot overwrite a successor's credentials. - Keep vault-import conflicts company-scoped. Protect instance-owned account-home paths during failed device-login cleanup with a private, fail-closed scan. - Skip decryption on stop and cleanup so a lost key cannot block removal. Cascade secret records when their agent or plugin is deleted. - Log fixed provider diagnostics without raw errors that can contain credentials. Document the local contract, runtime resolver, and access-map exclusions. Retain the five latest migration snapshots through the normal generator pruning. ## Verification - 239 focused tests passed across agent-instance-secrets, lifecycle, secrets-service, secrets-routes, plugin-secrets-handler, and agent-device-login-routes. Used disposable PostgreSQL 18 clusters through the existing test constructor seam because the bundled binary cannot start on this macOS host. The temporary harness is outside git. - Regression tests reproduced the vault-import conflict and missed cleanup claimant before the fixes; both pass after the fixes. - New coverage includes encrypted native versions, rotation, restart recovery, company/agent/plugin isolation, ordinary-read and binding denial, stale replies, storage failure, safe logging, cleanup without the master key, and same-name secret isolation, native account-home mutation guards, lease takeover/expiry, and live agent/plugin deletion cascades. - Direct server TypeScript check, DB/shared typechecks, migration numbering/safety checks, module boundaries, and git diff check passed. - Full workspace typecheck and build were attempted; both stop at the missing local cargo toolchain. The full test command was started and stopped after failures in unchanged native-runtime/workspace suites plus bundled-PostgreSQL skips; it did not complete. CI must verify the full toolchain paths. - Current head `c0f8bfd12477ad509252ecd40e168a6c84dbeb36`: all 55 CI checks passed, including the previously canceled e2e shard. Greptile Apex reviewed this exact head at 5/5 with no unresolved threads. GitHub reports the branch mergeable and clean. - No live provider probe, production data access, or deployment. Reviewed the diff for credentials, private references, and run artifacts. ## Risks Apply migration 0339_luxuriant_iron_monger.sql before deploying tenant code. It adds nullable ownership columns, a partial unique index, foreign keys, and a stricter scope constraint. Existing rows remain valid. Keep the schema on application rollback. Preserve the existing secrets master key through deployments and backup restoration. The lifecycle request/result now has a sensitive, server-only field. Plugins must never log it or return it to an agent or browser. Values stay in the tenant database through the existing encryption provider. Trusted runtime consumers must apply normal execution admission before resolving them. Older hosts omit instanceSecrets; plugins that require durable storage must reject those hosts. ## Model Used OpenAI GPT-6 via Codex, with reasoning, repository inspection, code editing, and local test execution. The exact serving model identifier and context window are not exposed in this session. ## 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>
241 lines
17 KiB
TypeScript
241 lines
17 KiB
TypeScript
import { randomBytes, randomUUID } from "node:crypto";
|
|
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
|
|
import { eq } from "drizzle-orm";
|
|
import { agents, companies, companySecrets, companySecretVersions, activityLog, plugins, createDb, type Db } from "@paperclipai/db";
|
|
import type { AgentLifecycleRequest } from "@paperclipai/shared";
|
|
import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js";
|
|
import { agentInstanceSecretService } from "../services/agent-instance-secrets.js";
|
|
import { secretService } from "../services/secrets.js";
|
|
import { localEncryptedProvider } from "../secrets/local-encrypted-provider.js";
|
|
import { createLifecycleDriver } from "../services/agent-lifecycle-driver.js";
|
|
import { createLifecycleStore } from "../modules/agent-lifecycle/adapters/postgres.js";
|
|
import { logger } from "../middleware/logger.js";
|
|
import { resolveCodexAuthCacheDir } from "@paperclipai/adapter-codex-local/server";
|
|
import path from "node:path";
|
|
import { mkdir, rm } from "node:fs/promises";
|
|
import { awsSecretsManagerProvider } from "../secrets/aws-secrets-manager-provider.js";
|
|
|
|
const support = await getEmbeddedPostgresTestSupport();
|
|
(support.supported ? describe : describe.skip)("agent instance secrets use the tenant secret system", () => {
|
|
let database: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>>;
|
|
let db: Db;
|
|
let companyId: string;
|
|
let pluginId: string;
|
|
const runtime = JSON.stringify({ url: "https://runtime.example.test", token: "e".repeat(64) });
|
|
beforeAll(async () => {
|
|
vi.stubEnv("PAPERCLIP_SECRETS_MASTER_KEY", randomBytes(32).toString("base64"));
|
|
database = await startEmbeddedPostgresTestDatabase("paperclip-instance-secrets-");
|
|
db = createDb(database.connectionString);
|
|
companyId = randomUUID(); pluginId = randomUUID();
|
|
await db.insert(companies).values({ id: companyId, name: "Secret fixture", issuePrefix: "SCR" });
|
|
await db.insert(plugins).values({ id: pluginId, pluginKey: pluginId, packageName: "fixture-lifecycle", version: "1.0.0", status: "ready",
|
|
manifestJson: { agentLifecycle: true, capabilities: ["agents.lifecycle.manage"] } as never });
|
|
}, 90_000);
|
|
afterAll(async () => { await database?.cleanup(); vi.unstubAllEnvs(); });
|
|
async function fixture(ownerPluginId = pluginId) {
|
|
const operationId = randomUUID();
|
|
const owner = randomUUID();
|
|
const now = new Date();
|
|
const [agent] = await db.insert(agents).values({ companyId, name: "Secret owner", lifecycleState: "preparing", lifecycleVersion: 1,
|
|
lifecycleRequiredPluginIds: [ownerPluginId], lifecycleOperation: { id: operationId, hostComplete: true, completedPluginIds: [], attempts: 0,
|
|
leaseOwner: owner, leaseUntil: new Date(now.getTime() + 120_000).toISOString() } }).returning();
|
|
const request: AgentLifecycleRequest = { companyId, agentId: agent.id, operationId, version: 1, phase: "preparing" };
|
|
const scope = { companyId, agentId: agent.id, pluginId: ownerPluginId };
|
|
const native = agentInstanceSecretService(db);
|
|
const service = { read: native.read, apply: (input: AgentLifecycleRequest, id: string, changes: unknown, attemptOwner = owner) => native.apply(input, id, changes, attemptOwner) };
|
|
return { agent, request, scope, service, owner };
|
|
}
|
|
|
|
it("persists and rotates native secret versions, survives restart, and isolates agent, company and plugin", async () => {
|
|
const f = await fixture();
|
|
await f.service.apply(f.request, pluginId, { runtime });
|
|
const [stored] = await db.select().from(companySecrets).where(eq(companySecrets.ownerAgentId, f.agent.id));
|
|
expect(stored).toMatchObject({ scope: "instance", ownerPluginId: pluginId, provider: "local_encrypted", latestVersion: 1 });
|
|
const versions = await db.select().from(companySecretVersions).where(eq(companySecretVersions.secretId, stored.id));
|
|
expect(versions).toHaveLength(1);
|
|
expect(JSON.stringify(versions)).not.toContain("e".repeat(64));
|
|
expect(JSON.stringify(versions)).not.toContain("runtime.example.test");
|
|
expect(await agentInstanceSecretService(createDb(database.connectionString)).read(f.scope)).toEqual({ runtime });
|
|
for (const scope of [{ ...f.scope, companyId: randomUUID() }, { ...f.scope, agentId: randomUUID() }, { ...f.scope, pluginId: randomUUID() }]) {
|
|
expect(await f.service.read(scope)).toEqual({});
|
|
}
|
|
const replacement = runtime.replace("e".repeat(64), "f".repeat(64));
|
|
await f.service.apply(f.request, pluginId, { runtime: replacement });
|
|
expect(await f.service.read(f.scope)).toEqual({ runtime: replacement });
|
|
expect((await secretService(db).getById(stored.id))!.latestVersion).toBe(2);
|
|
expect(JSON.stringify(await db.select().from(activityLog).where(eq(activityLog.entityId, f.agent.id)))).not.toContain(replacement);
|
|
});
|
|
|
|
it("excludes instance secrets from company catalogs, ordinary resolution, bindings and agent access", async () => {
|
|
const f = await fixture(); await f.service.apply(f.request, pluginId, { runtime });
|
|
const [stored] = await db.select().from(companySecrets).where(eq(companySecrets.ownerAgentId, f.agent.id));
|
|
const secrets = secretService(db);
|
|
expect((await secrets.list(companyId)).some(secret => secret.id === stored.id)).toBe(false);
|
|
expect(await secrets.getByName(companyId, "runtime")).toBeNull();
|
|
expect(await secrets.getByKey(companyId, "runtime")).toBeNull();
|
|
await expect(secrets.resolveSecretValue(companyId, stored.id, "latest")).rejects.toThrow("secret declarations");
|
|
await expect(secrets.createBinding({ companyId, secretId: stored.id, targetType: "agent", targetId: f.agent.id, configPath: "env.RUNTIME" })).rejects.toThrow("company-scoped");
|
|
});
|
|
|
|
it("fences stale writes and removals, and cascades credentials when the owning agent is deleted", async () => {
|
|
const f = await fixture(); await f.service.apply(f.request, pluginId, { runtime });
|
|
for (const patch of [{ version: 2 }, { operationId: randomUUID() }, { phase: "cleaning_up" }, { companyId: randomUUID() }, { agentId: randomUUID() }]) {
|
|
await expect(f.service.apply({ ...f.request, ...patch } as AgentLifecycleRequest, pluginId, { runtime: null })).rejects.toThrow("stale or outside scope");
|
|
}
|
|
await expect(f.service.apply(f.request, randomUUID(), { runtime })).rejects.toThrow("stale or outside scope");
|
|
await db.update(agents).set({ lifecycleState: "cleaning_up", lifecycleVersion: 2 }).where(eq(agents.id, f.agent.id));
|
|
await expect(f.service.apply(f.request, pluginId, { runtime })).rejects.toThrow("stale or outside scope");
|
|
const cleanup = { ...f.request, version: 2, phase: "cleaning_up" as const };
|
|
await expect(f.service.apply(cleanup, pluginId, { runtime })).rejects.toThrow("preparation or resume");
|
|
await f.service.apply(cleanup, pluginId, { runtime: null });
|
|
expect(await f.service.read(f.scope)).toEqual({});
|
|
await db.delete(agents).where(eq(agents.id, f.agent.id));
|
|
expect(await db.select().from(companySecrets).where(eq(companySecrets.ownerAgentId, f.agent.id))).toEqual([]);
|
|
});
|
|
|
|
it("fails without acknowledging storage errors and validates changes before writes", async () => {
|
|
const f = await fixture();
|
|
const spy = vi.spyOn(localEncryptedProvider, "createSecret").mockRejectedValueOnce(new Error(runtime));
|
|
const warnings = vi.spyOn(logger, "warn").mockImplementation(() => undefined);
|
|
await expect(f.service.apply(f.request, pluginId, { runtime })).rejects.toThrow("storage unavailable");
|
|
expect(JSON.stringify(warnings.mock.calls)).not.toContain("e".repeat(64));
|
|
warnings.mockRestore();
|
|
spy.mockRestore();
|
|
expect(await f.service.read(f.scope)).toEqual({});
|
|
for (const changes of [null, [], { invalid: 1 }, { runtime: "" }, { "bad/key": runtime }, { runtime: "x".repeat(65_537) }]) {
|
|
await expect(f.service.apply(f.request, pluginId, changes)).rejects.toThrow("Invalid agent instance secret changes");
|
|
}
|
|
});
|
|
|
|
it("saves the existing local lifecycle reply before completion and supplies secrets on the next invocation", async () => {
|
|
const f = await fixture();
|
|
const call = vi.fn(async (_id, _method, input) => ({ operationId: input.operationId, version: input.version, status: "complete", instanceSecrets: { runtime } }));
|
|
const driver = createLifecycleDriver(db, { call } as never);
|
|
const store = createLifecycleStore(db, {} as never);
|
|
const snapshot = (await store.get(f.agent.id))!;
|
|
expect(await driver.runPlugin(snapshot, pluginId)).toBe("complete");
|
|
expect(await f.service.read(f.scope)).toEqual({ runtime });
|
|
expect(call.mock.calls[0][2].instanceSecrets).toEqual({});
|
|
await driver.runPlugin(snapshot, pluginId);
|
|
expect(call.mock.calls[1][2].instanceSecrets).toEqual({ runtime });
|
|
call.mockImplementationOnce(async (_id, _method, input) => {
|
|
await db.update(agents).set({ lifecycleVersion: 2, lifecycleState: "terminating" }).where(eq(agents.id, f.agent.id));
|
|
return { operationId: input.operationId, version: input.version, status: "complete", instanceSecrets: { runtime: "stale credentials" } };
|
|
});
|
|
await expect(driver.runPlugin(snapshot, pluginId)).rejects.toThrow("stale or outside scope");
|
|
expect(await f.service.read(f.scope)).toEqual({ runtime });
|
|
});
|
|
|
|
it("preserves the native guard against missing account-home directories", async () => {
|
|
const f = await fixture();
|
|
const missing = path.join(resolveCodexAuthCacheDir(undefined, companyId), "missing-instance-secret-fixture");
|
|
await expect(f.service.apply(f.request, pluginId, { runtime: missing })).rejects.toThrow("storage unavailable");
|
|
expect(await f.service.read(f.scope)).toEqual({});
|
|
});
|
|
|
|
it("rejects a previous attempt after lease takeover and rejects an expired owner's writes", async () => {
|
|
const f = await fixture(); await f.service.apply(f.request, pluginId, { runtime });
|
|
const snapshot = (await createLifecycleStore(db, {} as never).get(f.agent.id))!;
|
|
const successor = randomUUID();
|
|
await db.update(agents).set({ lifecycleOperation: { ...f.agent.lifecycleOperation!, leaseOwner: successor } }).where(eq(agents.id, f.agent.id));
|
|
await f.service.apply(f.request, pluginId, { runtime: "successor credentials" }, successor);
|
|
await expect(f.service.apply(f.request, pluginId, { runtime })).rejects.toThrow("stale or outside scope");
|
|
await expect(f.service.apply(f.request, pluginId, { runtime: null })).rejects.toThrow("stale or outside scope");
|
|
const call = vi.fn(async (_id, _method, input) => ({ operationId: input.operationId, version: input.version, status: "complete", instanceSecrets: { runtime } }));
|
|
await expect(createLifecycleDriver(db, { call } as never).runPlugin(snapshot, pluginId)).rejects.toThrow("stale or outside scope");
|
|
expect(await f.service.read(f.scope)).toEqual({ runtime: "successor credentials" });
|
|
await db.update(agents).set({ lifecycleOperation: { ...f.agent.lifecycleOperation!, leaseOwner: successor, leaseUntil: new Date(0).toISOString() } }).where(eq(agents.id, f.agent.id));
|
|
await expect(f.service.apply(f.request, pluginId, { runtime }, successor)).rejects.toThrow("stale or outside scope");
|
|
expect(await f.service.read(f.scope)).toEqual({ runtime: "successor credentials" });
|
|
});
|
|
|
|
it("deletes live secret metadata and versions with the owning agent or plugin", async () => {
|
|
for (const deleting of ["agent", "plugin"] as const) {
|
|
const ownerPluginId = randomUUID();
|
|
await db.insert(plugins).values({ id: ownerPluginId, pluginKey: ownerPluginId, packageName: "fixture-cascade", version: "1.0.0", status: "ready",
|
|
manifestJson: { agentLifecycle: true, capabilities: ["agents.lifecycle.manage"] } as never });
|
|
const f = await fixture(ownerPluginId);
|
|
await f.service.apply(f.request, ownerPluginId, { runtime });
|
|
const [stored] = await db.select().from(companySecrets).where(eq(companySecrets.ownerAgentId, f.agent.id));
|
|
expect(await db.select().from(companySecretVersions).where(eq(companySecretVersions.secretId, stored.id))).toHaveLength(1);
|
|
if (deleting === "agent") await db.delete(agents).where(eq(agents.id, f.agent.id));
|
|
else await db.delete(plugins).where(eq(plugins.id, ownerPluginId));
|
|
expect(await secretService(db).getById(stored.id)).toBeNull();
|
|
expect(await db.select().from(companySecretVersions).where(eq(companySecretVersions.secretId, stored.id))).toEqual([]);
|
|
}
|
|
});
|
|
|
|
it("can clean up after a lost master key without decrypting credentials", async () => {
|
|
const f = await fixture(); await f.service.apply(f.request, pluginId, { runtime });
|
|
const masterKey = process.env.PAPERCLIP_SECRETS_MASTER_KEY!;
|
|
try {
|
|
vi.stubEnv("PAPERCLIP_SECRETS_MASTER_KEY", randomBytes(32).toString("base64"));
|
|
await expect(f.service.read(f.scope)).rejects.toThrow("Agent instance secret unavailable");
|
|
await db.update(agents).set({ lifecycleState: "cleaning_up", lifecycleVersion: 2 }).where(eq(agents.id, f.agent.id));
|
|
const call = vi.fn(async (_id, _method, input) => ({ operationId: input.operationId, version: input.version, status: "complete", instanceSecrets: { runtime: null } }));
|
|
const driver = createLifecycleDriver(db, { call } as never);
|
|
const snapshot = (await createLifecycleStore(db, {} as never).get(f.agent.id))!;
|
|
expect(await driver.runPlugin(snapshot, pluginId)).toBe("complete");
|
|
expect(call.mock.calls[0][2].instanceSecrets).toEqual({});
|
|
expect(await f.service.read(f.scope)).toEqual({});
|
|
} finally { vi.stubEnv("PAPERCLIP_SECRETS_MASTER_KEY", masterKey); }
|
|
});
|
|
|
|
it("allows company vault imports with the same name and key as an instance secret", async () => {
|
|
const f = await fixture();
|
|
await f.service.apply(f.request, pluginId, { runtime });
|
|
const secrets = secretService(db);
|
|
const vault = await secrets.createProviderConfig(companyId, {
|
|
provider: "aws_secrets_manager", displayName: "Import fixture", config: { region: "us-east-1" },
|
|
});
|
|
const externalRef = "arn:aws:secretsmanager:us-east-1:123456789012:secret:runtime";
|
|
const list = vi.spyOn(awsSecretsManagerProvider, "listRemoteSecrets").mockResolvedValue({
|
|
secrets: [{ externalRef, name: "runtime", providerVersionRef: null, metadata: {} }],
|
|
});
|
|
try {
|
|
const preview = await secrets.previewRemoteImport(companyId, { providerConfigId: vault.id });
|
|
expect(preview.candidates[0]).toMatchObject({ status: "ready", importable: true, conflicts: [] });
|
|
const result = await secrets.importRemoteSecrets(companyId, {
|
|
providerConfigId: vault.id, secrets: [{ externalRef, name: "runtime", key: "runtime" }],
|
|
});
|
|
expect(result).toMatchObject({ importedCount: 1, skippedCount: 0, errorCount: 0 });
|
|
expect(await f.service.read(f.scope)).toEqual({ runtime });
|
|
const imported = await secrets.getByName(companyId, "runtime");
|
|
expect(imported).toMatchObject({ scope: "company", externalRef });
|
|
await secrets.remove(imported!.id);
|
|
} finally { list.mockRestore(); }
|
|
});
|
|
|
|
it("protects instance-owned account homes without exposing their values in the company catalog", async () => {
|
|
const f = await fixture();
|
|
const accountHome = path.join(resolveCodexAuthCacheDir(undefined, companyId), randomUUID());
|
|
await mkdir(accountHome, { recursive: true });
|
|
const secrets = secretService(db);
|
|
try {
|
|
await f.service.apply(f.request, pluginId, { account_home: accountHome });
|
|
expect(await secrets.hasInstanceAccountHomeClaim(companyId, accountHome)).toBe(true);
|
|
expect(await secrets.hasInstanceAccountHomeClaim(randomUUID(), accountHome)).toBe(false);
|
|
expect(await secrets.hasInstanceAccountHomeClaim(companyId, accountHome + "-other")).toBe(false);
|
|
expect((await secrets.list(companyId)).some(secret => secret.ownerAgentId === f.agent.id)).toBe(false);
|
|
const resolve = vi.spyOn(localEncryptedProvider, "resolveVersion").mockRejectedValueOnce(new Error("unavailable"));
|
|
try { expect(await secrets.hasInstanceAccountHomeClaim(companyId, accountHome)).toBe(true); }
|
|
finally { resolve.mockRestore(); }
|
|
await f.service.apply(f.request, pluginId, { account_home: null });
|
|
expect(await secrets.hasInstanceAccountHomeClaim(companyId, accountHome)).toBe(false);
|
|
} finally { await rm(accountHome, { recursive: true, force: true }); }
|
|
});
|
|
|
|
it("keeps same-named company and other-agent secrets independent", async () => {
|
|
const f = await fixture(); const other = await fixture();
|
|
const companySecret = await secretService(db).create(companyId, { name: "runtime", provider: "local_encrypted", value: "ordinary company secret" });
|
|
await f.service.apply(f.request, pluginId, { runtime });
|
|
await other.service.apply(other.request, pluginId, { runtime: "other agent connection" });
|
|
expect(await f.service.read(f.scope)).toEqual({ runtime });
|
|
expect(await other.service.read(other.scope)).toEqual({ runtime: "other agent connection" });
|
|
expect(await secretService(db).resolveSecretValue(companyId, companySecret.id, "latest")).toBe("ordinary company secret");
|
|
await f.service.apply(f.request, pluginId, { runtime: null });
|
|
expect(await other.service.read(other.scope)).toEqual({ runtime: "other agent connection" });
|
|
expect(await secretService(db).getByName(companyId, "runtime")).toMatchObject({ id: companySecret.id });
|
|
});
|
|
});
|