diff --git a/server/src/__tests__/agent-instance-secrets.test.ts b/server/src/__tests__/agent-instance-secrets.test.ts index 2ccadbac7d2..03d2a181d4e 100644 --- a/server/src/__tests__/agent-instance-secrets.test.ts +++ b/server/src/__tests__/agent-instance-secrets.test.ts @@ -1,7 +1,7 @@ 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 { afterAll, beforeAll, describe, expect, it, onTestFinished, vi } from "vitest"; +import { and, eq, sql } from "drizzle-orm"; +import { agents, companies, companySecrets, companySecretVersions, activityLog, plugins, secretAccessEvents, toolApplications, toolConnections, createDb, type Db } from "@paperclipai/db"; import type { AgentLifecycleRequest } from "@paperclipai/shared"; import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js"; import { pluginAgentRuntimeIdentity } from "../services/plugin-agent-runtime-state.js"; @@ -118,6 +118,29 @@ const support = await getEmbeddedPostgresTestSupport(); await expect(secrets.createBinding({ companyId, secretId: stored.id, targetType: "agent", targetId: f.agent.id, configPath: "env.RUNTIME" })).rejects.toThrow("company-scoped"); }); + it("reads each secret with its latest version in one statement, auditing every read and keeping version failures", async () => { + const f = await fixture(); onTestFinished(async () => { await db.delete(agents).where(eq(agents.id, f.agent.id)); }); + await f.service.apply(f.request, pluginId, { runtime, launcher: "launcher value" }); + await f.service.apply(f.request, pluginId, { launcher: "rotated launcher" }); + const [stored] = await db.select().from(companySecrets).where(and(eq(companySecrets.ownerAgentId, f.agent.id), eq(companySecrets.name, "launcher"))); + expect(stored.latestVersion).toBe(2); + const audit = () => db.select().from(secretAccessEvents).where(eq(secretAccessEvents.secretId, stored.id)).orderBy(secretAccessEvents.createdAt); + const selects = vi.spyOn(db, "select"); + expect(await f.service.read(f.scope)).toEqual({ runtime, launcher: "rotated launcher" }); + expect(selects).toHaveBeenCalledTimes(1); + selects.mockRestore(); + expect((await audit()).at(-1)).toMatchObject({ outcome: "success", version: 2, consumerType: "plugin_worker", consumerId: pluginId, pluginId, actorType: "plugin" }); + const audited = (await audit()).length; + expect(await f.service.read(f.scope)).toEqual({ runtime, launcher: "rotated launcher" }); + expect(await audit()).toHaveLength(audited + 1); + await db.update(companySecretVersions).set({ revokedAt: new Date() }).where(and(eq(companySecretVersions.secretId, stored.id), eq(companySecretVersions.version, 2))); + await expect(f.service.read(f.scope)).rejects.toThrow("Agent instance secret unavailable"); + expect((await audit()).at(-1)).toMatchObject({ outcome: "failure", errorCode: "version_inactive", version: 2 }); + await db.delete(companySecretVersions).where(and(eq(companySecretVersions.secretId, stored.id), eq(companySecretVersions.version, 2))); + await expect(f.service.read(f.scope)).rejects.toThrow("Agent instance secret unavailable"); + expect((await audit()).at(-1)).toMatchObject({ outcome: "failure", errorCode: "version_missing", version: 2 }); + }); + 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() }]) { diff --git a/server/src/__tests__/agent-runtime-hooks.test.ts b/server/src/__tests__/agent-runtime-hooks.test.ts index 9ed76d192ab..0ec53c4f061 100644 --- a/server/src/__tests__/agent-runtime-hooks.test.ts +++ b/server/src/__tests__/agent-runtime-hooks.test.ts @@ -1,12 +1,12 @@ import * as databaseModule from "@paperclipai/db"; -import { randomUUID } from "node:crypto"; +import { randomBytes, randomUUID } from "node:crypto"; import { createServer, connect, type Socket } from "node:net"; import { EventEmitter } from "node:events"; import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { and, eq, sql } from "drizzle-orm"; -import { activityLog, agents, companies, applyPendingMigrations, closeFileStorageDb, createDb, pluginCompanySettings, pluginConfig, plugins, resourceLifecycleEvents, type Db } from "@paperclipai/db"; +import { activityLog, agents, companies, applyPendingMigrations, closeFileStorageDb, createDb, pluginCompanySettings, pluginConfig, plugins, resourceLifecycleEvents, secretAccessEvents, type Db } from "@paperclipai/db"; import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js"; import * as instructionModule from "../services/agent-instructions.js"; @@ -18,6 +18,7 @@ import { idleWorkSnapshot } from "../services/task-admission.js"; import { resolveManagedInstructionsRoot } from "../services/agent-instructions.js"; import { agentFileStore } from "../services/agent-file-store.js"; import { agentInstructionRevisionService } from "../services/agent-instruction-revisions.js"; +import { secretService } from "../services/secrets.js"; const externalUrl = process.env.PAPERCLIP_AGENT_RUNTIME_HOOKS_TEST_DATABASE_URL; const expectedDatabase = process.env.PAPERCLIP_AGENT_RUNTIME_HOOKS_EXPECTED_DATABASE; @@ -221,6 +222,82 @@ describeDatabase("agent runtime synchronization hooks", () => { await expect(pluginAgentRuntimeIdentity(db, pluginId, companyId, agentId)).rejects.toMatchObject({ status: 403 }); }); + it("returns the same identity, revision, secrets and failures as the full runtime read", async () => { + vi.stubEnv("PAPERCLIP_SECRETS_MASTER_KEY", randomBytes(32).toString("base64")); + const scope = { companyId, agentId, pluginId }; + const outcome = (read: Promise) => read.then(value => value, (error: { status?: number }) => ({ status: error.status })); + const same = async (company = companyId, agent = agentId, plugin = pluginId) => { + const state = await outcome(pluginAgentRuntimeState(db, plugin, company, agent)) as Awaited> | { status: number }; + const identity = await outcome(pluginAgentRuntimeIdentity(db, plugin, company, agent)); + expect(identity).toEqual(state && "revision" in state ? { revision: state.revision, instanceSecrets: state.instanceSecrets, agent: { + id: state.agent.id, companyId: state.agent.companyId, status: state.agent.status, lifecycleState: state.agent.lifecycleState, lifecycleVersion: state.agent.lifecycleVersion } } : state); + return identity as Awaited>; + }; + await secretService(db).agentInstance.set(scope, "launcher", "first launcher"); + const first = await same(); + expect(first).toEqual({ revision: String((await events()).at(-1)!.id), instanceSecrets: { launcher: "first launcher" }, + agent: { id: agentId, companyId, status: "idle", lifecycleState: "ready", lifecycleVersion: 0 } }); + await db.insert(resourceLifecycleEvents).values([{ companyId, resourceType: "agent", resourceId: agentId, action: "pause" }, + { companyId, resourceType: "agent_runtime", resourceId: randomUUID(), action: "update" }]); + expect((await same())!.revision).toBe(first!.revision); + const [latest] = await db.insert(resourceLifecycleEvents).values({ companyId, resourceType: "agent_runtime", resourceId: agentId, action: "update" }).returning(); + expect((await same())!.revision).toBe(String(latest.id)); + await db.update(agents).set({ lifecycleState: "preparing", lifecycleVersion: 3 }).where(eq(agents.id, agentId)); + expect((await same())!.agent).toMatchObject({ lifecycleState: "preparing", lifecycleVersion: 3 }); + await secretService(db).agentInstance.set(scope, "launcher", "second launcher"); + expect((await same())!.instanceSecrets).toEqual({ launcher: "second launcher" }); + await secretService(db).agentInstance.set(scope, "launcher", null); + expect((await same())!.instanceSecrets).toEqual({}); + await secretService(db).agentInstance.set(scope, "launcher", "third launcher"); + const accessEvents = async () => (await db.select().from(secretAccessEvents).where(eq(secretAccessEvents.companyId, companyId))).length; + const audited = await accessEvents(); + expect(await same(randomUUID())).toBeNull(); + expect(await same(companyId, randomUUID())).toBeNull(); + expect(await same(companyId, agentId, randomUUID())).toEqual({ status: 403 }); + const manifest = { resourceLifecycle: ["agent_runtime"], capabilities: ["events.subscribe", "agents.lifecycle.manage"] }; + const denials: Array<[() => Promise, () => Promise]> = [ + [() => db.update(plugins).set({ status: "error" }).where(eq(plugins.id, pluginId)), () => db.update(plugins).set({ status: "ready" }).where(eq(plugins.id, pluginId))], + [() => db.update(plugins).set({ manifestJson: { ...manifest, capabilities: ["events.subscribe"] } as never }).where(eq(plugins.id, pluginId)), + () => db.update(plugins).set({ manifestJson: manifest as never }).where(eq(plugins.id, pluginId))], + [() => db.update(plugins).set({ manifestJson: { ...manifest, resourceLifecycle: [] } as never }).where(eq(plugins.id, pluginId)), + () => db.update(plugins).set({ manifestJson: manifest as never }).where(eq(plugins.id, pluginId))], + [() => db.insert(pluginCompanySettings).values({ companyId, pluginId, enabled: false }), () => db.delete(pluginCompanySettings).where(eq(pluginCompanySettings.pluginId, pluginId))], + [() => db.update(agents).set({ lifecycleRequiredPluginIds: [randomUUID()] }).where(eq(agents.id, agentId)), + () => db.update(agents).set({ lifecycleRequiredPluginIds: [pluginId] }).where(eq(agents.id, agentId))], + [() => db.update(agents).set({ lifecycleRequiredPluginIds: null }).where(eq(agents.id, agentId)), + () => db.update(agents).set({ lifecycleRequiredPluginIds: [pluginId] }).where(eq(agents.id, agentId))], + ]; + for (const [deny, restore] of denials) { + await deny(); + expect(await same()).toEqual({ status: 403 }); + await restore(); + } + expect(await accessEvents()).toBe(audited); + await db.insert(pluginCompanySettings).values({ companyId, pluginId, enabled: true }); + expect((await same())!.instanceSecrets).toEqual({ launcher: "third launcher" }); + }); + + it("reads runtime identity from a snapshot without waiting for an agent row lock", async () => { + let locked!: () => void; + let release!: () => void; + const held = new Promise(resolve => { locked = resolve; }); + const writer = db.transaction(async tx => { + await tx.select().from(agents).where(eq(agents.id, agentId)).for("update"); + await tx.update(agents).set({ lifecycleState: "pausing", lifecycleVersion: 5 }).where(eq(agents.id, agentId)); + await new Promise(resolve => { release = resolve; locked(); }); + }); + await held; + try { + const read = pluginAgentRuntimeIdentity(db, pluginId, companyId, agentId); + const timeout = new Promise((_, reject) => setTimeout(() => reject(new Error("identity read waited for the agent lock")), 5_000)); + expect((await Promise.race([read, timeout]))!.agent).toMatchObject({ lifecycleState: "ready", lifecycleVersion: 0 }); + } finally { + release(); + await writer; + } + expect((await pluginAgentRuntimeIdentity(db, pluginId, companyId, agentId))!.agent).toMatchObject({ lifecycleState: "pausing", lifecycleVersion: 5 }); + }); + it("uses the company reporting graph for runtime health data", async () => { const [manager] = await db.insert(agents).values({ companyId, name: "Manager" }).returning(); await db.update(agents).set({ reportsTo: manager.id }).where(eq(agents.id, agentId)); diff --git a/server/src/services/plugin-agent-runtime-state.ts b/server/src/services/plugin-agent-runtime-state.ts index 1478ba98399..57ee68cea44 100644 --- a/server/src/services/plugin-agent-runtime-state.ts +++ b/server/src/services/plugin-agent-runtime-state.ts @@ -1,4 +1,4 @@ -import { and, desc, eq } from "drizzle-orm"; +import { and, desc, eq, sql } from "drizzle-orm"; import { agents, environments, plugins, pluginCompanySettings, resourceLifecycleEvents, fileStorageDb, type Db } from "@paperclipai/db"; import type { PluginAgentRuntimeState, PluginAgentRuntimeIdentity } from "@paperclipai/plugin-sdk"; import { agentRecordQueries } from "../lib/agent-records.js"; @@ -13,28 +13,25 @@ import { instanceSettingsService } from "./instance-settings.js"; import { agentInstanceSecretService } from "./agent-instance-secrets.js"; import { environmentSkillConfig } from "./environment-skills.js"; -async function readState(db: Db, pluginId: string, companyId: string, agentId: string, identityOnly: true): Promise; -async function readState(db: Db, pluginId: string, companyId: string, agentId: string, identityOnly?: false): Promise | null>; -async function readState(db: Db, pluginId: string, companyId: string, agentId: string, identityOnly = false): Promise | null> { +function assertOwningLifecyclePlugin(plugin: Pick | null | undefined, + settings: Pick | null | undefined, requiredPluginIds: string[] | null | undefined, pluginId: string) { + if (plugin?.status !== "ready" || settings?.enabled === false || !plugin?.manifestJson.capabilities?.includes("agents.lifecycle.manage") + || !plugin.manifestJson.resourceLifecycle?.includes("agent_runtime") || !requiredPluginIds?.includes(pluginId)) { + throw forbidden("Agent runtime state requires the owning lifecycle plugin"); + } +} + +async function readState(db: Db, pluginId: string, companyId: string, agentId: string): Promise | null> { return fileStorageDb(db).transaction(async tx => { const [agent] = await tx.select().from(agents) .where(and(eq(agents.companyId, companyId), eq(agents.id, agentId))).for("update"); if (!agent) return null; const [plugin] = await tx.select().from(plugins).where(eq(plugins.id, pluginId)); const [settings] = await tx.select().from(pluginCompanySettings).where(and(eq(pluginCompanySettings.companyId, companyId), eq(pluginCompanySettings.pluginId, pluginId))); - if (plugin?.status !== "ready" || settings?.enabled === false || !plugin?.manifestJson.capabilities?.includes("agents.lifecycle.manage") - || !plugin.manifestJson.resourceLifecycle?.includes("agent_runtime") || !agent.lifecycleRequiredPluginIds?.includes(pluginId)) { - throw forbidden("Agent runtime state requires the owning lifecycle plugin"); - } + assertOwningLifecyclePlugin(plugin, settings, agent.lifecycleRequiredPluginIds, pluginId); const [latest] = await tx.select({ id: resourceLifecycleEvents.id }).from(resourceLifecycleEvents) .where(and(eq(resourceLifecycleEvents.companyId, companyId), eq(resourceLifecycleEvents.resourceType, "agent_runtime"), eq(resourceLifecycleEvents.resourceId, agentId))) .orderBy(desc(resourceLifecycleEvents.id)).limit(1); - if (identityOnly) return { - revision: String(latest?.id ?? 0), - agent: { id: agent.id, companyId: agent.companyId, status: agent.status as PluginAgentRuntimeIdentity["agent"]["status"], - lifecycleState: agent.lifecycleState, lifecycleVersion: agent.lifecycleVersion }, - instanceSecrets: await agentInstanceSecretService(tx as unknown as Db).read({ companyId, agentId, pluginId }), - }; const instructions = await agentInstructionsService(tx as unknown as Db).getBundleLocation(agent); const records = agentRecordQueries(tx as unknown as Db); const companyAgents = await records.listCompanyAgentEligibilityRows(companyId); @@ -46,9 +43,26 @@ async function readState(db: Db, pluginId: string, companyId: string, agentId: s }); } - export async function pluginAgentRuntimeIdentity(db: Db, pluginId: string, companyId: string, agentId: string): Promise { - return readState(db, pluginId, companyId, agentId, true); + const [row] = await db.select({ + id: agents.id, companyId: agents.companyId, status: agents.status, lifecycleState: agents.lifecycleState, + lifecycleVersion: agents.lifecycleVersion, requiredPluginIds: agents.lifecycleRequiredPluginIds, + plugin: { status: plugins.status, manifestJson: plugins.manifestJson }, settings: { enabled: pluginCompanySettings.enabled }, + revision: sql`(${db.select({ id: resourceLifecycleEvents.id }).from(resourceLifecycleEvents) + .where(and(eq(resourceLifecycleEvents.companyId, companyId), eq(resourceLifecycleEvents.resourceType, "agent_runtime"), eq(resourceLifecycleEvents.resourceId, agentId))) + .orderBy(desc(resourceLifecycleEvents.id)).limit(1)})`.mapWith(resourceLifecycleEvents.id), + }).from(agents) + .leftJoin(plugins, eq(plugins.id, pluginId)) + .leftJoin(pluginCompanySettings, and(eq(pluginCompanySettings.companyId, companyId), eq(pluginCompanySettings.pluginId, pluginId))) + .where(and(eq(agents.companyId, companyId), eq(agents.id, agentId))); + if (!row) return null; + assertOwningLifecyclePlugin(row.plugin, row.settings, row.requiredPluginIds, pluginId); + return { + revision: String(row.revision ?? 0), + agent: { id: row.id, companyId: row.companyId, status: row.status as PluginAgentRuntimeIdentity["agent"]["status"], + lifecycleState: row.lifecycleState, lifecycleVersion: row.lifecycleVersion }, + instanceSecrets: await agentInstanceSecretService(db).read({ companyId, agentId, pluginId }), + }; } export async function pluginAgentRuntimeState(db: Db, pluginId: string, companyId: string, agentId: string, environmentId?: string, installEnvironmentSkills = false): Promise { diff --git a/server/src/services/secrets.ts b/server/src/services/secrets.ts index 2730f1bc7c9..730c15c9059 100644 --- a/server/src/services/secrets.ts +++ b/server/src/services/secrets.ts @@ -1359,10 +1359,11 @@ export function secretService(db: Db | DbTransaction) { version: number | "latest", options?: SecretResolutionOptions, readOnlyEnvironmentInspection = false, + preloaded?: { secret: typeof companySecrets.$inferSelect; version: typeof companySecretVersions.$inferSelect | null }, ): Promise { const bindingContext = options?.bindingContext; const accessContext = options?.accessContext ?? bindingContext; - const secret = await getById(secretId); + const secret = preloaded && preloaded.secret.id === secretId ? preloaded.secret : await getById(secretId); if (!secret) throw notFound("Secret not found"); if (secret.companyId !== companyId) throw unprocessable("Secret must belong to same company"); const instanceOwned = secret.scope === "instance" && options?.instanceOwner && @@ -1384,7 +1385,8 @@ export function secretService(db: Db | DbTransaction) { throw unprocessable("Secret is not active", { code: "secret_inactive" }); } const binding = await assertBindingContext(companyId, secret.id, bindingContext); - const versionRow = await getSecretVersion(secret.id, resolvedVersion); + const versionRow = preloaded?.secret === secret && preloaded.version?.version === resolvedVersion + ? preloaded.version : await getSecretVersion(secret.id, resolvedVersion); if (!versionRow) throw new HttpError(404, "Secret version not found", { code: "version_missing" }); if (versionRow.status === "disabled" || versionRow.status === "destroyed" || versionRow.revokedAt) { throw unprocessable("Secret version is not active", { code: "version_inactive" }); @@ -3513,16 +3515,18 @@ export function secretService(db: Db | DbTransaction) { const instanceSecrets = { async read(scope: InstanceSecretScope): Promise> { - const rows = await db.select().from(companySecrets).where(and(eq(companySecrets.companyId, scope.companyId), - eq(companySecrets.scope, "instance"), scope.agentId !== undefined ? eq(companySecrets.ownerAgentId, scope.agentId) : eq(companySecrets.ownerProjectId, scope.projectId), - eq(companySecrets.ownerPluginId, scope.pluginId), ne(companySecrets.status, "deleted"))); + const rows = await db.select({ secret: companySecrets, version: companySecretVersions }).from(companySecrets) + .leftJoin(companySecretVersions, and(eq(companySecretVersions.secretId, companySecrets.id), eq(companySecretVersions.version, companySecrets.latestVersion))) + .where(and(eq(companySecrets.companyId, scope.companyId), + eq(companySecrets.scope, "instance"), scope.agentId !== undefined ? eq(companySecrets.ownerAgentId, scope.agentId) : eq(companySecrets.ownerProjectId, scope.projectId), + eq(companySecrets.ownerPluginId, scope.pluginId), ne(companySecrets.status, "deleted"))); const values: Record = Object.create(null); for (const row of rows) { - const resolved = await resolveSecretValueInternal(scope.companyId, row.id, "latest", { + const resolved = await resolveSecretValueInternal(scope.companyId, row.secret.id, "latest", { instanceOwner: scope, accessContext: { consumerType: "plugin_worker", consumerId: scope.pluginId, actorType: "plugin", actorId: scope.pluginId, pluginId: scope.pluginId }, - }); - values[row.name] = resolved.value; + }, false, row); + values[row.secret.name] = resolved.value; } return values; },