diff --git a/doc/DEVELOPING.md b/doc/DEVELOPING.md index f21ffd3348d..31196954cb9 100644 --- a/doc/DEVELOPING.md +++ b/doc/DEVELOPING.md @@ -2881,6 +2881,10 @@ Native event and retry-exhaustion ingestion lock the run row to verify its company and agent bindings. That lock reads only those two fields. Sequence allocation, replay checks, and event insertion remain in the same transaction. +Sandbox-provider driver lookup filters plugin manifests by driver key and kind +in Postgres before returning rows. Install order, status and worker checks still +select the provider. Each lookup reads current registry state without a cache. + Run the deterministic native event replay with the normal server test runner: ```sh diff --git a/server/src/__tests__/plugin-environment-driver-selection.test.ts b/server/src/__tests__/plugin-environment-driver-selection.test.ts new file mode 100644 index 00000000000..f04739a91a1 --- /dev/null +++ b/server/src/__tests__/plugin-environment-driver-selection.test.ts @@ -0,0 +1,95 @@ +import { randomBytes, randomUUID } from "node:crypto"; +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { eq } from "drizzle-orm"; +import { drizzle } from "drizzle-orm/postgres-js"; +import { createDb, plugins } from "@paperclipai/db"; +import type { PaperclipPluginManifestV1, PluginEnvironmentDriverDeclaration } from "@paperclipai/shared"; +import { pluginRegistryService } from "../services/plugin-registry.js"; +import { resolvePluginSandboxProviderDriverByKey } from "../services/plugin-environment-driver.js"; +import type { PluginWorkerManager } from "../services/plugin-worker-manager.js"; +import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js"; + +const support = await getEmbeddedPostgresTestSupport(); +const describePostgres = support.supported ? describe : describe.skip; + +describePostgres("sandbox-provider plugin selection", () => { + let temporary: Awaited>; + let db: ReturnType; + const key = "fixture-provider"; + const ids = Array.from({ length: 6 }, () => randomUUID()); + const driver = (driverKey: string, kind: PluginEnvironmentDriverDeclaration["kind"] = "sandbox_provider") => ({ + driverKey, kind, displayName: driverKey, configSchema: { type: "object" }, + }); + const manifest = (id: string, environmentDrivers: PluginEnvironmentDriverDeclaration[]): PaperclipPluginManifestV1 => ({ + id: `test.selection.${id}`, apiVersion: 1, version: "1.0.0", displayName: "Selection fixture", + description: "Fixture", author: "Test", categories: ["automation"], + capabilities: ["environment.drivers.register"], entrypoints: { worker: "worker.js" }, environmentDrivers, + }); + const largeDriver = { ...driver("other-provider"), configSchema: { type: "object", description: randomBytes(64 * 1024).toString("hex") } }; + + beforeAll(async () => { + temporary = await startEmbeddedPostgresTestDatabase("paperclip-provider-selection-"); + db = createDb(temporary.connectionString); + }, 60_000); + beforeEach(async () => { + await db.delete(plugins); + await db.insert(plugins).values(ids.map((id, index) => ({ + id, pluginKey: `test.selection.${id}`, packageName: "@test/selection", version: "1.0.0", + installOrder: index, status: index === 1 ? "disabled" as const : "ready" as const, + manifestJson: manifest(id, index === 0 ? [driver(key, "environment_driver"), largeDriver] + : index < 4 ? [driver(key)] : index === 4 ? [largeDriver] : []), + }))); + }); + afterAll(async () => { await temporary?.cleanup(); }); + + it("keeps the unfiltered catalog and filters matching declarations in install order", async () => { + const registry = pluginRegistryService(db); + expect((await registry.list()).map(plugin => plugin.id)).toEqual(ids); + const selected = await registry.list({ sandboxProviderDriverKey: key }); + expect(selected.map(plugin => plugin.id)).toEqual(ids.slice(1, 4)); + expect(selected[0].status).toBe("disabled"); + expect(selected.every(plugin => plugin.manifestJson.environmentDrivers?.some( + entry => entry.driverKey === key && entry.kind === "sandbox_provider", + ))).toBe(true); + expect(await registry.list({ sandboxProviderDriverKey: "unknown-provider" })).toEqual([]); + }); + + it("filters the actual resolver query before large unrelated manifests cross the DB boundary", async () => { + const statements: string[] = []; + const recorded = drizzle(db.$client, { + schema: db._.fullSchema, + logger: { logQuery: query => statements.push(query) }, + }) as typeof db; + const selected = await resolvePluginSandboxProviderDriverByKey({ db: recorded, driverKey: key }); + expect(selected?.plugin.id).toBe(ids[1]); + expect(statements).toHaveLength(1); + expect(statements[0]).toContain('where "plugins"."manifest_json" @>'); + expect(statements[0]).toContain('order by "plugins"."install_order" asc'); + }); + + it("keeps readiness and worker selection after the database filter", async () => { + const isRunning = vi.fn((id: string) => id === ids[3]); + const workerManager = { isRunning } as unknown as PluginWorkerManager; + const selected = await resolvePluginSandboxProviderDriverByKey({ db, driverKey: key, workerManager, requireRunning: true }); + expect(selected?.plugin.id).toBe(ids[3]); + expect(isRunning.mock.calls.map(([id]) => id)).toEqual([ids[2], ids[3]]); + expect(await resolvePluginSandboxProviderDriverByKey({ db, driverKey: key, requireRunning: true })).toBeNull(); + }); + + it("reads changed status and declarations on the next lookup", async () => { + const workerManager = { isRunning: () => true } as unknown as PluginWorkerManager; + const resolve = () => resolvePluginSandboxProviderDriverByKey({ db, driverKey: key, workerManager, requireRunning: true }); + expect((await resolve())?.plugin.id).toBe(ids[2]); + await db.update(plugins).set({ status: "disabled" }).where(eq(plugins.id, ids[2])); + expect((await resolve())?.plugin.id).toBe(ids[3]); + await db.update(plugins).set({ manifestJson: manifest(ids[3], [driver("replacement-provider")]) }).where(eq(plugins.id, ids[3])); + expect(await resolve()).toBeNull(); + }); + + it("binds unusual driver keys as JSON values", async () => { + const unusual = "provider-\"quoted' OR true --"; + await db.update(plugins).set({ manifestJson: manifest(ids[4], [driver(unusual)]) }).where(eq(plugins.id, ids[4])); + expect((await pluginRegistryService(db).list({ sandboxProviderDriverKey: unusual })).map(plugin => plugin.id)).toEqual([ids[4]]); + expect((await resolvePluginSandboxProviderDriverByKey({ db, driverKey: unusual }))?.plugin.id).toBe(ids[4]); + }); +}); diff --git a/server/src/services/plugin-environment-driver.ts b/server/src/services/plugin-environment-driver.ts index e9c4751985a..d7f56f2b197 100644 --- a/server/src/services/plugin-environment-driver.ts +++ b/server/src/services/plugin-environment-driver.ts @@ -169,7 +169,7 @@ export async function resolvePluginSandboxProviderDriverByKey(input: { requireRunning?: boolean; }): Promise<{ plugin: Awaited["list"]>>[number]; driver: PluginEnvironmentDriverDeclaration } | null> { const pluginRegistry = pluginRegistryService(input.db); - const plugins = await pluginRegistry.list(); + const plugins = await pluginRegistry.list({ sandboxProviderDriverKey: input.driverKey }); for (const plugin of plugins) { const driver = plugin.manifestJson.environmentDrivers?.find( (candidate) => candidate.driverKey === input.driverKey && candidate.kind === "sandbox_provider", diff --git a/server/src/services/plugin-registry.ts b/server/src/services/plugin-registry.ts index 2ff529cc2d8..c47cf4f6f5d 100644 --- a/server/src/services/plugin-registry.ts +++ b/server/src/services/plugin-registry.ts @@ -97,11 +97,13 @@ export function pluginRegistryService(db: Db) { // ----- Read ----------------------------------------------------------- /** List all registered plugins ordered by install order. */ - list: () => - db - .select() - .from(plugins) - .orderBy(asc(plugins.installOrder)), + list: (filter?: { sandboxProviderDriverKey: string }) => { + const query = db.select().from(plugins); + const driverKey = filter?.sandboxProviderDriverKey; + return (driverKey === undefined ? query : query.where(sql`${plugins.manifestJson} @> ${JSON.stringify({ + environmentDrivers: [{ driverKey, kind: "sandbox_provider" }], + })}::jsonb`)).orderBy(asc(plugins.installOrder)); + }, /** * List installed plugins (excludes soft-deleted/uninstalled).