mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-22 16:51:28 +02:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Runtime preparation depends on access and credential state as well as tool definitions. > - Grant, membership, secret, and endpoint changes need committed invalidation. > - Their writers must acquire locks before journal fanout can lock agent rows. > - This pull request adds access hooks with the corresponding transaction guards. > - Plugins can refresh preparation immediately while task-time authorization remains authoritative. ## Linked Issues or Issue Description **What happened?** Grant changes, credential rotation and revocation, company access changes, and connector endpoint configuration do not request runtime synchronization. Journal fanout exposes lock inversions between credential persistence, access cleanup, and connection assignment writers. **Expected behavior** Committed access changes request runtime preparation without polling or credential values in the journal. Agent-specific secret bindings and new personal delegations affect their target agent. Company access changes remain conservative. Provider bookkeeping does not create preparation work. **Steps to reproduce** 1. Change an audience, delegation, membership permission, referenced credential, vault configuration, or endpoint instruction. 2. Expect a committed runtime event in the correct company and scope. 3. Run reconnect, credential refresh, SSH setup, or membership cleanup concurrently with related writes. 4. Expect both operations to preserve access checks and finish without a lock-order cycle. **Additional context** Final feature extraction from #16503. The earlier slices, #16604, and the separate delivery-test correction #16871 are merged. This PR is now the active change against master. The rebase preserves the merged assignment and definition recipient rules, Unicode selector semantics, and profile/reference transaction guards. ## What Changed - Add migration 0356 for grants, audiences, delegation, membership/permissions, secret bindings, referenced credentials and vaults, and chat/email endpoint configuration. - Keep events content-free. Ignore health, delivery, publication, Vercel verification, and GitHub webhook bookkeeping while retaining credential and repository-access changes. - Target agent secret bindings and all personal delegation changes to their old and new target agents. Coalesce delegation changes per transaction. - Lock existing grants before connections and credential persistence in OAuth and cleanup writers. - Reject superseded refresh leases before rotating stored credentials. Ignore lease acquisition and release when access metadata is unchanged. - Serialize audience replacement, validate membership, and preserve unchanged audiences. - Lock membership before permission-only saves and grant before Railway SSH setup. Individual delegation revocation locks its grant and target. Whole-grant and membership cleanup lock company recipients before deleting scoped delegations. - Lock grants and connection inputs before static-key reconnect writes. Lock company agents before cascading secret deletion; use a nonblocking secret lock and bounded retries to avoid a new cycle with credential writers. - Add PostgreSQL concurrency coverage for access writers and endpoint publication/configuration behavior. - Preserve the owner lock for plugin instance-secret removal; these secrets cannot use ordinary bindings. - Align Slack search refresh, callbacks, and disconnect with grant-before-connection locking. Recheck newly discovered grants without waiting before provider work. - Retry complete membership transactions when a credential deletion candidate is busy. Cover all four cleanup entry points and process company access in ID order. - Document final invalidation coverage and task-time authorization boundaries. ## Verification - Current head: `78aace55bef7f52bc5742b131e0b3961eb5c0f11`. All 1,061 tests across 23 affected suites pass. The named Slack authorization integration scenario also passes; it exercises authorization, refresh, disconnect, and rejection of a late callback. Server/database typechecks, migration safety and numbering, five-snapshot retention, feature maps, and diff checks pass. The complete PR diff was reviewed for secrets and personal data before push. Final acceptance: [CI run 39308303837, attempt 2](https://github.com/paperclipai/paperclip/actions/runs/39308303837) is green, with 55 successful checks and two optional Storybook skips. The current-head Greptile reassessment returned **Apex 5/5** at 19:16 UTC on 2026-10-19. Its full summary reports no actionable issues. Security passed, all threads are resolved, and GitHub reports no merge conflicts. The reassessment was requested as a standard review after the fixes; Greptile retained the Apex tier. - On this head, [CI run 39308303837](https://github.com/paperclipai/paperclip/actions/runs/39308303837) failed the unchanged adapter-utils HTTP/2 fixture with `EADDRINUSE` before READY. Its closed probe left a port handoff race. That test and its gateway are byte-identical on master. No upstream fix was found. The deterministic fixture correction is isolated in #16888; no adapter test changes are included here. The failed jobs recovered on retry without a feature source change. The original failure remains documented here, and #16888 carries the fixture correction separately. That retry demonstrates recovery; the held-probe negative control establishes the port handoff race. - Rebased onto master `194fc11406`, including merged #16604 and #16871. Replayed only the access-hook commit. Resolved OAuth, test, and documentation conflicts while retaining both sets of tests. Migration 0356 preserves the merged assignment/definition recipient rules, Unicode selector semantics, and profile/reference guards. - Apex returned 3/5 on `0fb65bc27`. Negative controls reproduce its Slack delegation and member-cleanup deadlocks (`40P01`) and unwanted Vercel/GitHub bookkeeping events. Slack grant writers now take their locks before the connection. A separate late-grant control reproduces `40P01` without the post-lock recheck; the complete guard passes. All four membership cleanup commands retry from a fresh transaction when the credential is busy. Tests verify successful cleanup and no journal rows from failed attempts. Credential IDs and repository-access changes still notify runtimes. - Earlier review corrections remain covered: static-key reconnect and cascading secret removal each reproduce `40P01` without their guards. Grant lease acquisition/release is ignored. Secret removal releases agent locks when a credential writer is busy. Instance-secret removal retains the owner lock and passes its public plugin API contention regression. Delegation updates and revocations target old/new agents; unrelated-peer, coalescing, unchanged-update, and rollback cases pass. Three negative controls verify the required whole-grant and membership cleanup guards after narrowing delegation scope. - Preserve prior acceptance failures: the first rebased 20-suite run passed 1,025/1,026; its only failure was a draft uninstall fixture blocking a random peer. Blocking the assigned agent corrected that assumption, and all 44 access/assignment tests passed. Later, all 1,044 tests across 21 suites, [CI run 39302185527](https://github.com/paperclipai/paperclip/actions/runs/39302185527), standard Greptile 5/5, and security passed on `0fb65bc27`. That did not clear the later Apex findings described above. Draft run 38975428081 failed a server shard; its cause was not re-investigated. A later green run does not establish that earlier cause. - Local fixture corrections are recorded separately from product failures: an assignment filename filter initially matched no file; the actual suite was included in full runs. A delegation fixture omitted its required creator, and a guard run omitted a query import. The new global-access cleanup fixture now uses its own user to avoid earlier fixtures' memberships. All were corrected before publishing their final iterations. - Local checks target affected services and real PostgreSQL boundaries. A full local build and repository-wide test run were not performed. Hosted CI supplies broader verification. Provider responses in tests are controlled; live provider behavior was not qualified. ## Risks Migration 0356 adds triggers without changing table schema. Apply it through the normal migration flow. Most access changes retain synchronous company-wide invalidation. Company fanout takes O(eligible company agents) locks and journal rows, without a fanout ceiling or mutation quota. Agent secret bindings and individual delegation changes retain narrower recipients. Events contain no credentials. Physical removal of non-instance secrets takes ordered locks on all company agents before cascading into bindings. This costs O(company agents) locks even for an otherwise unreferenced secret. Instance secrets cannot use ordinary bindings and retain their owner lock. A busy secret lock releases the transaction guards and retries up to three times. Continued contention returns a retryable conflict. Provider deletion stays outside that transaction; a retry resumes removal of an already disabled secret. Membership cleanup uses nonblocking credential guards and retries the full membership transaction. Slack search adds two grant queries, scoped to the subject and connection. Its retries occur before provider work or credential persistence. Each retry loop is limited to three attempts. Invalidation requests preparation; task-time authorization and provider session activation remain authoritative. Provider calls use fixtures; no live provider qualification is claimed. ## Model Used OpenAI Codex, GPT-6, with reasoning, repository inspection, code execution, and testing. The exact deployment model ID 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>
279 lines
20 KiB
TypeScript
279 lines
20 KiB
TypeScript
import { randomBytes, randomUUID } from "node:crypto";
|
|
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
|
|
import { eq, sql } from "drizzle-orm";
|
|
import { agents, companies, companySecrets, companySecretVersions, activityLog, plugins, toolApplications, toolConnections, 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, agentId = randomUUID()) {
|
|
const operationId = randomUUID();
|
|
const owner = randomUUID();
|
|
const now = new Date();
|
|
const [agent] = await db.insert(agents).values({ id: agentId, 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("removes a plugin instance secret while a company connection save waits for its owner", async () => {
|
|
const [lower, higher] = [randomUUID(), randomUUID()].sort();
|
|
await db.insert(agents).values({ id: lower, companyId, name: "Unrelated lower agent" });
|
|
const f = await fixture(pluginId, higher);
|
|
await f.service.apply(f.request, pluginId, { runtime });
|
|
const [application] = await db.insert(toolApplications).values({ companyId, name: "Instance removal fixture", type: "mcp_http" }).returning();
|
|
const [connection] = await db.insert(toolConnections).values({ companyId, applicationId: application.id, name: "Instance removal fixture", uid: randomUUID(), transport: "mcp_remote", authKind: "none", status: "active" }).returning();
|
|
let paused!: () => void; let resume!: () => void; let started!: (pid: number) => void;
|
|
const held = new Promise<void>(resolve => { paused = resolve; });
|
|
const mayRemove = new Promise<void>(resolve => { resume = resolve; });
|
|
const settingsStarted = new Promise<number>(resolve => { started = resolve; });
|
|
const removeProvider = localEncryptedProvider.deleteOrArchive.bind(localEncryptedProvider);
|
|
const providerSpy = vi.spyOn(localEncryptedProvider, "deleteOrArchive").mockImplementation(async input => {
|
|
paused(); await mayRemove;
|
|
return removeProvider(input);
|
|
});
|
|
const removal = f.service.apply(f.request, pluginId, { runtime: null });
|
|
await held;
|
|
const settings = db.transaction(async tx => {
|
|
await tx.execute(sql`SET LOCAL lock_timeout = '8s'`);
|
|
const [row] = await tx.execute(sql`SELECT pg_backend_pid() AS pid`);
|
|
started(Number(row.pid));
|
|
await tx.update(toolConnections).set({ name: "Saved during instance removal" }).where(eq(toolConnections.id, connection.id));
|
|
});
|
|
const completed = Promise.allSettled([removal, settings]);
|
|
try {
|
|
const pid = await settingsStarted;
|
|
await vi.waitFor(async () => {
|
|
const [row] = await db.execute(sql`SELECT wait_event_type FROM pg_stat_activity WHERE pid = ${pid}`);
|
|
expect(row.wait_event_type).toBe("Lock");
|
|
}, { timeout: 5000 });
|
|
} finally { resume(); await completed; providerSpy.mockRestore(); }
|
|
const results = await completed;
|
|
for (const result of results) if (result.status === "rejected") throw result.reason;
|
|
expect(await f.service.read(f.scope)).toEqual({});
|
|
expect((await db.select().from(toolConnections).where(eq(toolConnections.id, connection.id)))[0].name).toBe("Saved during instance removal");
|
|
}, 15000);
|
|
|
|
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 });
|
|
});
|
|
});
|