From 10d8a9ce897e7e8266167b31d9dbbad674c0b9c8 Mon Sep 17 00:00:00 2001 From: Dotta Date: Fri, 30 Oct 2026 11:03:56 -0500 Subject: [PATCH] fix(cloud): resolve bundled plugins from current image identity Co-Authored-By: Paperclip --- doc/plugins/PLUGIN_SPEC.md | 26 +++ .../__tests__/bundled-plugin-package.test.ts | 66 ++++++ .../bundled-plugin-registration.test.ts | 204 ++++++++++++++++++ .../helpers/bundled-image-fixture.ts | 50 +++++ server/src/app.ts | 11 +- server/src/routes/plugin-ui-static.ts | 19 +- server/src/services/bundled-plugin-package.ts | 109 ++++++++++ server/src/services/bundled-plugins.ts | 9 +- server/src/services/plugin-loader.ts | 25 ++- server/src/services/plugin-registry.ts | 23 +- 10 files changed, 515 insertions(+), 27 deletions(-) create mode 100644 server/src/__tests__/bundled-plugin-package.test.ts create mode 100644 server/src/__tests__/bundled-plugin-registration.test.ts create mode 100644 server/src/__tests__/helpers/bundled-image-fixture.ts create mode 100644 server/src/services/bundled-plugin-package.ts diff --git a/doc/plugins/PLUGIN_SPEC.md b/doc/plugins/PLUGIN_SPEC.md index a6c744476d8..614d2ba50a1 100644 --- a/doc/plugins/PLUGIN_SPEC.md +++ b/doc/plugins/PLUGIN_SPEC.md @@ -34,6 +34,32 @@ Current limitations to keep in mind: In practice, that means the current implementation is a good fit for local development and self-hosted persistent deployments, but not yet for multi-instance cloud plugin distribution. +### Managed image packages + +Managed Cloud provisions only plugins selected from the current release image +catalog. The database registration's plugin key, package name, version and +approved manifest identify that package. Its legacy `packagePath` remains for +local/self-hosted compatibility; Cloud never uses that path to locate code. +Workers, plugin migrations and UI files resolve through the same current-image +package resolver. Cloud does not install npm dependencies, build packages, use +the development UI proxy, or fall back to a server home directory. + +Equivalent packages at different image roots reuse the existing registration, +configuration, approval status and company scopes without rewriting its stored +path. Concurrent first registration of the same package retains one plugin ID. +Conflicting package identities fail explicitly. Normal maintenance boot retains +the existing bundled manifest/version reconciliation and approval gates. + +`createBundledPluginPackageResolver(...).inspect(registration, verifiedManifest)` +is a read-only qualification hook. Resolve the current image path, load its +manifest with the validated plugin loader, and inspect the registration before +normal boot reconciliation. Missing manifest evidence and incompatible packages +produce explicit blockers. Distribution packages retain their catalog digest +verification; built-in packages trust the immutable release image and must bump +their version when their code changes. Qualification supports identical package +sets only. This package-resolution boundary does not coordinate workers, +background jobs, or deployment overlap between servers. + ## 1. Scope This spec covers: diff --git a/server/src/__tests__/bundled-plugin-package.test.ts b/server/src/__tests__/bundled-plugin-package.test.ts new file mode 100644 index 00000000000..d90192353a4 --- /dev/null +++ b/server/src/__tests__/bundled-plugin-package.test.ts @@ -0,0 +1,66 @@ +import { rmSync, writeFileSync, symlinkSync, mkdirSync } from "node:fs"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { bundledImageFixture } from "./helpers/bundled-image-fixture.js"; +import { createBundledPluginPackageResolver } from "../services/bundled-plugin-package.js"; +import { BUNDLED_PLUGIN_CATALOG, resolveBundledPluginInstalls } from "../services/bundled-plugins.js"; +import { readDistributionPluginCatalog } from "../services/distribution-plugin-catalog.js"; + +const roots: string[] = []; +function fixture() { const result = bundledImageFixture(); roots.push(result.root); return result; } +afterEach(() => { for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); }); + +describe("bundled image package identity", () => { + it("resolves independent image roots from the same registration without any previous path", () => { + const first = fixture(); + const second = fixture(); + const registration = { ...first.registration, packagePath: "/lost/old/home/plugins/fixture" }; + expect(first.resolver.resolve(registration)).toBe(first.packageRoot); + expect(second.resolver.resolve(registration)).toBe(second.packageRoot); + expect(first.resolver.inspect(registration, first.manifest)).toEqual(second.resolver.inspect(registration, second.manifest)); + expect(registration.packagePath).toBe("/lost/old/home/plugins/fixture"); + }); + + it("requires verified manifest evidence and blocks same-version manifest drift", () => { + const { resolver, registration, manifest } = fixture(); + expect(resolver.inspect(registration)).toMatchObject({ compatible: false, code: "missing_manifest_evidence" }); + expect(resolver.inspect(registration, { ...manifest, description: "changed" })).toMatchObject({ compatible: false, code: "incompatible_bundled_package", reason: expect.stringContaining("Approved plugin manifest") }); + expect(resolver.inspect(registration, manifest)).toMatchObject({ compatible: true, identity: { kind: "bundled_image", catalogKey: "fixture", pluginKey: manifest.id, version: "1.0.0" } }); + }); + + it("rejects mismatched package names, versions, identity and escaping entrypoints", () => { + const { resolver, registration } = fixture(); + for (const update of [ + { packageName: "@other/plugin" }, { version: "2.0.0" }, { pluginKey: "other.plugin" }, + { manifestJson: { ...registration.manifestJson, version: "2.0.0" } }, + { manifestJson: { ...registration.manifestJson, entrypoints: { worker: "../worker.js" } } }, + ]) expect(() => resolver.resolve({ ...registration, ...update })).toThrow(); + }); + + it("fails explicitly for missing or deselected bundles even when a stale npm package exists", () => { + const { root, packageRoot, resolver, registration } = fixture(); + const fallback = path.join(root, "home", "node_modules", registration.packageName); + mkdirSync(fallback, { recursive: true }); + writeFileSync(path.join(fallback, "worker.js"), "throw new Error('unsafe fallback');"); + rmSync(packageRoot, { recursive: true }); + expect(() => resolver.resolve(registration)).toThrow(/ENOENT/); + expect(() => createBundledPluginPackageResolver([]).resolve(registration)).toThrow(/absent or not selected/); + }); + + it("rejects tampering before manifest import and symlink escapes before worker resolution", () => { + const { root, packageRoot, registration, resolver } = fixture(); + writeFileSync(path.join(packageRoot, "dist", "manifest.js"), "throw new Error('must never import');"); + expect(() => readDistributionPluginCatalog(root, BUNDLED_PLUGIN_CATALOG)).toThrow(/digest mismatch/); + rmSync(path.join(packageRoot, "dist", "worker.js")); + symlinkSync(path.join(root, "distribution", "catalog.json"), path.join(packageRoot, "dist", "worker.js")); + expect(() => resolver.resolve(registration)).toThrow(/escapes/); + }); + + it("applies the same identity and containment checks to managed built-in packages", () => { + const { root, packageRoot, manifest, registration } = fixture(); + const installs = resolveBundledPluginInstalls(["kubernetes"], { catalogRoot: root, enforceCatalogRoot: true, distributionPlugins: [], env: {} }); + installs[0] = { ...installs[0]!, localPath: packageRoot, pluginKey: manifest.id }; + expect(createBundledPluginPackageResolver(installs).resolve(registration)).toBe(packageRoot); + expect(() => createBundledPluginPackageResolver(installs).resolve({ ...registration, version: "2.0.0" })).toThrow(/identity\/version/); + }); +}); diff --git a/server/src/__tests__/bundled-plugin-registration.test.ts b/server/src/__tests__/bundled-plugin-registration.test.ts new file mode 100644 index 00000000000..99147bc8619 --- /dev/null +++ b/server/src/__tests__/bundled-plugin-registration.test.ts @@ -0,0 +1,204 @@ +import express from "express"; +import request from "supertest"; +import { execFileSync } from "node:child_process"; +import { rmSync, mkdirSync, writeFileSync } from "node:fs"; +import path from "node:path"; +import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest"; +import { applyPendingMigrations, closeRegisteredClients, companies, createDb, plugins } from "@paperclipai/db"; +import { eq } from "drizzle-orm"; +import type { PluginRecord } from "@paperclipai/shared"; +import { createPluginWorkerManager } from "../services/plugin-worker-manager.js"; +import { pluginLoader, type PluginRuntimeServices } from "../services/plugin-loader.js"; +import { pluginRegistryService } from "../services/plugin-registry.js"; +import { pluginUiStaticRoutes } from "../routes/plugin-ui-static.js"; +import { errorHandler } from "../middleware/error-handler.js"; +import { derivePluginDatabaseNamespace } from "../services/plugin-database.js"; +import { ensureBundledPlugins } from "../services/bundled-plugins.js"; +import { bundledImageFixture } from "./helpers/bundled-image-fixture.js"; +import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js"; + +const externalDatabaseUrl = process.env.PAPERCLIP_BUNDLED_PLUGIN_TEST_DATABASE_URL?.trim(); +const support = externalDatabaseUrl ? { supported: true } : await getEmbeddedPostgresTestSupport(); +const describePostgres = support.supported ? describe : describe.skip; + +describePostgres("bundled image registration in PostgreSQL", () => { + let database: Awaited>; + let db: ReturnType; + let peer: ReturnType; + const roots: string[] = []; + const fixture = () => { const result = bundledImageFixture(); roots.push(result.root); return result; }; + beforeAll(async () => { + if (externalDatabaseUrl) { + await applyPendingMigrations(externalDatabaseUrl); + database = { connectionString: externalDatabaseUrl, cleanup: () => closeRegisteredClients(externalDatabaseUrl) }; + } else { + database = await startEmbeddedPostgresTestDatabase("bundled-image-registration-"); + } + db = createDb(database.connectionString); + peer = createDb(database.connectionString); + }, 90_000); + afterEach(async () => { + await db.delete(plugins); + await db.delete(companies); + for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); + }); + afterAll(async () => { await database?.cleanup(); }); + + function runtime() { + const output: string[] = []; + const services = { + workerManager: { + startWorker: vi.fn(async (_id: string, options: { entrypointPath: string }) => { output.push(execFileSync(process.execPath, [options.entrypointPath, "--source"], { encoding: "utf8" })); }), + call: vi.fn(async () => ({})), + }, + lifecycleManager: { markError: vi.fn() }, + jobStore: { syncJobDeclarations: vi.fn() }, jobScheduler: { registerPlugin: vi.fn() }, + toolDispatcher: {}, eventBus: { forPlugin: vi.fn(), subscriptionCount: vi.fn(() => 0) }, + buildHostHandlers: vi.fn(() => ({})), instanceInfo: { instanceId: "fixture", hostVersion: "1.0.0" }, + }; + return { services, output }; + } + function loaderFor(image: ReturnType, databaseClient = db, services?: PluginRuntimeServices) { + return pluginLoader(databaseClient, { + localPluginDir: path.join(image.root, "lost-home"), prebuiltOnly: true, + resolvePackageRoot: image.resolver.resolve, assertPackageActivation: image.resolver.assertActivation, + enableLocalFilesystem: false, enableNpmDiscovery: false, + }, services); + } + + it("starts current-image workers after losing the old home and retains each company's config", async () => { + const old = fixture(); + const first = fixture(); + const second = fixture(); + await loaderFor(old).installPlugin({ localPath: old.packageRoot }); + const registry = pluginRegistryService(db); + let row = (await registry.getByKey(old.manifest.id))!; + await registry.updateStatus(row.id, { status: "ready" }); + const [companyA, companyB, unconfigured] = await db.insert(companies).values([ + { name: "Company A", issuePrefix: "CFA" }, { name: "Company B", issuePrefix: "CFB" }, { name: "Unconfigured", issuePrefix: "CFC" }, + ]).returning(); + await registry.upsertConfig(row.id, companyA!.id, { configJson: { token: "only-A" } }); + await registry.upsertConfig(row.id, companyB!.id, { configJson: { token: "only-B" } }); + row = (await registry.getById(row.id))!; + rmSync(old.root, { recursive: true }); + const original = { ...row }; + for (const image of [first, second]) { + const current = runtime(); + const loader = loaderFor(image, db, current.services as unknown as PluginRuntimeServices); + const result = await loader.loadSingle(row.id); + expect(result.success).toBe(true); + expect(current.output).toEqual([expect.stringContaining(image.packageRoot)]); + expect(current.services.workerManager.startWorker).toHaveBeenCalledWith(row.id, expect.objectContaining({ + entrypointPath: path.join(image.packageRoot, "dist", "worker.js"), config: {}, proactiveCompanyScopes: [companyA!.id, companyB!.id].sort(), + })); + expect(current.services.workerManager.call.mock.calls).toEqual(expect.arrayContaining([ + [row.id, "configChanged", { companyId: companyA!.id, config: { token: "only-A" } }], + [row.id, "configChanged", { companyId: companyB!.id, config: { token: "only-B" } }], + ])); + expect(current.services.workerManager.call).toHaveBeenCalledTimes(2); + expect(await registry.getConfig(row.id, unconfigured!.id)).toBeNull(); + const app = express(); + app.use((req, _res, next) => { + req.actor = { type: "board", userId: "fixture-user", source: "session", isInstanceAdmin: false, companyIds: [companyA!.id] }; + next(); + }); + app.use(pluginUiStaticRoutes(db, { localPluginDir: path.join(image.root, "lost-home"), resolvePackageRoot: image.resolver.resolve })); + app.use(errorHandler); + const served = await request(app).get(`/_plugins/${row.id}/ui/index.js`).query({ companyId: companyA!.id }); + expect(served.status).toBe(200); + expect(served.text).toContain("current-image"); + expect((await request(app).get(`/_plugins/${row.id}/ui/index.js`).query({ companyId: companyB!.id })).status).toBe(403); + + } + expect(await registry.getById(row.id)).toEqual(original); + }); + + it("completes the real worker initialize and health protocol from a relocated image", async () => { + const old = fixture(); const image = fixture(); + await loaderFor(old).installPlugin({ localPath: old.packageRoot }); + const registry = pluginRegistryService(db); + const row = (await registry.getByKey(old.manifest.id))!; + await registry.updateStatus(row.id, { status: "ready" }); + rmSync(old.root, { recursive: true }); + const manager = createPluginWorkerManager(); + try { + const services = { ...runtime().services, workerManager: manager } as unknown as PluginRuntimeServices; + const result = await loaderFor(image, db, services).loadSingle(row.id); + expect(result.success).toBe(true); + expect(await manager.call(row.id, "health", {})).toMatchObject({ status: "ok", details: { source: expect.stringContaining(image.packageRoot), configs: {} } }); + } finally { await manager.stopAll(); } + }); + + it("registers the same package concurrently from two roots without replacing the winner", async () => { + const first = fixture(); const second = fixture(); + await Promise.all([ + loaderFor(first).installPlugin({ localPath: first.packageRoot }), + loaderFor(second, peer).installPlugin({ localPath: second.packageRoot }), + ]); + const rows = await pluginRegistryService(db).listInstalled(); + expect(rows).toHaveLength(1); + const original = rows[0]!; + expect([first.packageRoot, second.packageRoot]).toContain(original.packagePath); + await loaderFor(second, peer).installPlugin({ localPath: second.packageRoot }); + expect(await pluginRegistryService(db).getById(original.id)).toEqual(original); + await expect(pluginRegistryService(peer).install({ packageName: original.packageName }, { ...first.manifest, description: "different package metadata" }, { idempotent: true })).rejects.toThrow(/already installed/); + }); + + it("preserves disabled registrations and avoids path writes during managed boot", async () => { + const old = fixture(); const image = fixture(); + await loaderFor(old).installPlugin({ localPath: old.packageRoot }); + const registry = pluginRegistryService(db); + const row = (await registry.getByKey(old.manifest.id))!; + await db.update(plugins).set({ status: "disabled" }).where(eq(plugins.id, row.id)); + const original = await registry.getById(row.id); + await ensureBundledPlugins(image.installs, { registry, loader: loaderFor(image), lifecycle: { load: vi.fn() }, logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() } }, { reinstallUninstalled: true, preservePackagePath: true }); + expect(await registry.getById(row.id)).toEqual(original); + }); + + it("fails before worker startup for missing image bytes without using a home fallback or building", async () => { + const image = fixture(); + await loaderFor(image).installPlugin({ localPath: image.packageRoot }); + const registry = pluginRegistryService(db); + const row = (await registry.getByKey(image.manifest.id))!; + await registry.updateStatus(row.id, { status: "ready" }); + const fallback = path.join(image.root, "lost-home", "node_modules", row.packageName); + mkdirSync(fallback, { recursive: true }); + writeFileSync(path.join(fallback, "package.json"), JSON.stringify({ name: row.packageName })); + writeFileSync(path.join(fallback, "worker.js"), "throw new Error('must not start');"); + rmSync(path.join(image.packageRoot, "dist", "worker.js")); + const current = runtime(); + const loader = loaderFor(image, db, current.services as unknown as PluginRuntimeServices); + const result = await loader.loadSingle(row.id); + expect(result.success).toBe(false); + expect(result.error).toContain("ENOENT"); + expect(current.services.workerManager.startWorker).not.toHaveBeenCalled(); + await expect(loader.installPlugin({ packageName: row.packageName })).rejects.toThrow(/current image catalog/); + await loader.cleanupInstallArtifacts(row as PluginRecord); + expect(image.resolver.inspect(row, image.manifest).compatible).toBe(false); + }); + + it("applies plugin migrations from the current image after the legacy package disappears", async () => { + const image = bundledImageFixture({ + capabilities: ["database.namespace.migrate", "database.namespace.read", "database.namespace.write"], + database: { migrationsDir: "migrations" }, + }); + roots.push(image.root); + const [row] = await db.insert(plugins).values({ + ...image.registration, packagePath: "/lost/legacy/home/plugin", apiVersion: 1, categories: image.manifest.categories, status: "ready", + }).returning(); + const current = runtime(); + const result = await loaderFor(image, db, current.services as unknown as PluginRuntimeServices).loadSingle(row!.id); + expect(result.success).toBe(true); + expect(current.services.workerManager.startWorker).toHaveBeenCalledWith(row!.id, expect.objectContaining({ databaseNamespace: derivePluginDatabaseNamespace(image.manifest.id) })); + await db.execute((await import("drizzle-orm")).sql.raw(`DROP SCHEMA "${derivePluginDatabaseNamespace(image.manifest.id)}" CASCADE`)); + }); + + it("keeps self-hosted local-path loading available without a managed resolver", async () => { + const image = fixture(); + const loader = pluginLoader(db, { localPluginDir: path.join(image.root, "selfhost"), enableNpmDiscovery: false, enableLocalFilesystem: false }); + await loader.installPlugin({ localPath: image.packageRoot }); + const row = (await pluginRegistryService(db).getByKey(image.manifest.id))!; + expect(row.packagePath).toBe(image.packageRoot); + await expect(loader.installPlugin({ localPath: image.packageRoot })).rejects.toThrow(/already installed/); + }); +}); diff --git a/server/src/__tests__/helpers/bundled-image-fixture.ts b/server/src/__tests__/helpers/bundled-image-fixture.ts new file mode 100644 index 00000000000..dcb958a5235 --- /dev/null +++ b/server/src/__tests__/helpers/bundled-image-fixture.ts @@ -0,0 +1,50 @@ +import { mkdtempSync, mkdirSync, realpathSync, writeFileSync } from "node:fs"; +import path from "node:path"; +import os from "node:os"; +import type { PaperclipPluginManifestV1 } from "@paperclipai/shared"; +import { BUNDLED_PLUGIN_CATALOG, resolveBundledPluginInstalls } from "../../services/bundled-plugins.js"; +import { distributionBundleDigest, readDistributionPluginCatalog } from "../../services/distribution-plugin-catalog.js"; +import { derivePluginDatabaseNamespace } from "../../services/plugin-database.js"; +import { createBundledPluginPackageResolver } from "../../services/bundled-plugin-package.js"; + +export function bundledImageFixture(manifestOverrides: Partial = {}) { + const root = realpathSync(mkdtempSync(path.join(os.tmpdir(), "bundled-image-"))); + const packageRoot = path.join(root, "distribution", "fixture"); + const manifest: PaperclipPluginManifestV1 = { + id: "paperclip.image-fixture", apiVersion: 1, version: "1.0.0", displayName: "Image Fixture", + description: "Image package relocation fixture", author: "Paperclip", categories: ["automation"], capabilities: ["issues.read"], + entrypoints: { worker: "dist/worker.js", ui: "dist/ui" }, ...manifestOverrides, + }; + mkdirSync(path.join(packageRoot, "dist", "ui"), { recursive: true }); + writeFileSync(path.join(packageRoot, "package.json"), JSON.stringify({ + name: "@paperclipai/plugin-image-fixture", version: manifest.version, type: "module", + paperclipPlugin: { manifest: "dist/manifest.js", ...manifest.entrypoints }, + })); + writeFileSync(path.join(packageRoot, "dist", "manifest.js"), `export default ${JSON.stringify(manifest)};`); + writeFileSync(path.join(packageRoot, "dist", "worker.js"), `import readline from "node:readline"; +if (process.argv.includes("--source")) process.stdout.write(import.meta.url); +else { + const configs = {}; + readline.createInterface({ input: process.stdin }).on("line", line => { + const request = JSON.parse(line); + let result = {}; + if (request.method === "initialize") result = { ok: true, supportedMethods: ["configChanged", "health"] }; + if (request.method === "configChanged") configs[request.params.companyId] = request.params.config; + if (request.method === "health") result = { status: "ok", details: { source: import.meta.url, configs } }; + process.stdout.write(JSON.stringify({ jsonrpc: "2.0", id: request.id, result }) + "\\n"); + if (request.method === "shutdown") setImmediate(() => process.exit(0)); + }); +}`); + writeFileSync(path.join(packageRoot, "dist", "ui", "index.js"), "export const source = 'current-image';"); + if (manifest.database) { + mkdirSync(path.join(packageRoot, manifest.database.migrationsDir), { recursive: true }); + writeFileSync(path.join(packageRoot, manifest.database.migrationsDir, "001_fixture.sql"), `CREATE TABLE ${derivePluginDatabaseNamespace(manifest.id)}.image_rows (id uuid PRIMARY KEY);`); + } + const entry = { key: "fixture", pluginKey: manifest.id, version: manifest.version, directory: "fixture", digest: distributionBundleDigest(packageRoot) }; + writeFileSync(path.join(root, "distribution", "catalog.json"), JSON.stringify({ schemaVersion: 1, plugins: [entry] })); + const catalog = readDistributionPluginCatalog(root, BUNDLED_PLUGIN_CATALOG); + const installs = resolveBundledPluginInstalls(["fixture"], { catalogRoot: root, enforceCatalogRoot: true, env: {}, distributionPlugins: catalog }); + const resolver = createBundledPluginPackageResolver(installs); + const registration = { pluginKey: manifest.id, packageName: "@paperclipai/plugin-image-fixture", version: manifest.version, manifestJson: manifest }; + return { root, packageRoot, manifest, catalog, installs, resolver, registration }; +} diff --git a/server/src/app.ts b/server/src/app.ts index 2f703ec7e3a..07e9bc5174f 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -184,6 +184,7 @@ import { resolveBundledCatalogRoot, resolveBundledPluginInstalls, } from "./services/bundled-plugins.js"; +import { createBundledPluginPackageResolver } from "./services/bundled-plugin-package.js"; import { readDistributionPluginCatalog, distributionPluginActivationGuard } from "./services/distribution-plugin-catalog.js"; import { createPluginWorkerManager, @@ -636,6 +637,7 @@ export async function createApp( distributionPlugins, }, ); + const bundledPackageResolver = managedAutoInstallKeys === null ? undefined : createBundledPluginPackageResolver(bundledPluginInstalls); const managedBundledPluginKeys = managedAutoInstallKeys !== null ? bundledPluginInstalls.map((install) => install.pluginKey) @@ -906,7 +908,11 @@ export async function createApp( { localPluginDir: opts.localPluginDir ?? DEFAULT_LOCAL_PLUGIN_DIR, migrationDb: opts.pluginMigrationDb, - assertPackageActivation: distributionPluginActivationGuard(bundledCatalogRoot, distributionPlugins, managedAutoInstallKeys), + assertPackageActivation: bundledPackageResolver?.assertActivation ?? distributionPluginActivationGuard(bundledCatalogRoot, distributionPlugins, managedAutoInstallKeys), + resolvePackageRoot: bundledPackageResolver?.resolve, + prebuiltOnly: managedAutoInstallKeys !== null, + enableLocalFilesystem: managedAutoInstallKeys === null, + enableNpmDiscovery: managedAutoInstallKeys === null, }, { workerManager, @@ -1015,6 +1021,7 @@ export async function createApp( app.use( pluginUiStaticRoutes(db, { localPluginDir: opts.localPluginDir ?? DEFAULT_LOCAL_PLUGIN_DIR, + resolvePackageRoot: bundledPackageResolver?.resolve, }), ); @@ -1300,7 +1307,7 @@ export async function createApp( // owns provisioning); self-hosted leaves an operator's uninstall alone. // Disabled plugins never start automatically. Added distribution permissions // still enter upgrade_pending so enabling them requires an operator decision. - { reinstallUninstalled: managedAutoInstallKeys !== null }, + { reinstallUninstalled: managedAutoInstallKeys !== null, preservePackagePath: managedAutoInstallKeys !== null }, ) .then(() => loader.loadAll()) .then((result) => { diff --git a/server/src/routes/plugin-ui-static.ts b/server/src/routes/plugin-ui-static.ts index ca9cae7db08..7a9efb4b249 100644 --- a/server/src/routes/plugin-ui-static.ts +++ b/server/src/routes/plugin-ui-static.ts @@ -32,6 +32,7 @@ import path from "node:path"; import fs from "node:fs"; import crypto from "node:crypto"; import type { Db } from "@paperclipai/db"; +import type { PluginRecord } from "@paperclipai/shared"; import { pluginRegistryService } from "../services/plugin-registry.js"; import { logger } from "../middleware/logger.js"; import { assertCompanyAccess } from "./authz.js"; @@ -192,6 +193,7 @@ export interface PluginUiStaticRouteOptions { * Defaults to the standard `~/.paperclip/plugins/` location. */ localPluginDir: string; + resolvePackageRoot?: (plugin: PluginRecord) => string; } /** @@ -304,7 +306,7 @@ export function pluginUiStaticRoutes(db: Db, options: PluginUiStaticRouteOptions if (typeof devUiUrl === "string" && devUiUrl.length > 0) { // Dev proxy is only available in development mode - if (process.env.NODE_ENV === "production") { + if (options.resolvePackageRoot || process.env.NODE_ENV === "production") { log.warn( { pluginId: plugin.id }, "plugin-ui-static: devUiUrl ignored in production", @@ -405,12 +407,15 @@ export function pluginUiStaticRoutes(db: Db, options: PluginUiStaticRouteOptions } // Step 3: Resolve the plugin's UI directory - const uiDir = resolvePluginUiDir( - options.localPluginDir, - plugin.packageName, - manifest.entrypoints.ui, - plugin.packagePath, - ); + let uiDir: string | null; + try { + uiDir = options.resolvePackageRoot + ? path.resolve(options.resolvePackageRoot(plugin), manifest.entrypoints.ui) + : resolvePluginUiDir(options.localPluginDir, plugin.packageName, manifest.entrypoints.ui, plugin.packagePath); + } catch { + res.status(503).json({ error: "Plugin package is unavailable in the current image" }); + return; + } if (!uiDir) { log.warn( diff --git a/server/src/services/bundled-plugin-package.ts b/server/src/services/bundled-plugin-package.ts new file mode 100644 index 00000000000..a80446a824b --- /dev/null +++ b/server/src/services/bundled-plugin-package.ts @@ -0,0 +1,109 @@ +import fs from "node:fs"; +import path from "node:path"; +import { isDeepStrictEqual } from "node:util"; +import type { PaperclipPluginManifestV1, PluginRecord } from "@paperclipai/shared"; +import type { ResolvedBundledPlugin } from "./bundled-plugins.js"; +import { assertDistributionManifestCapabilities } from "./distribution-plugin-catalog.js"; + +export type BundledPluginRegistration = Pick; + +export interface BundledPluginPackageIdentity { + kind: "bundled_image"; + catalogKey: string; + pluginKey: string; + packageName: string; + version: string; + digest?: string; +} + +export type BundledPluginPackageInspection = + | { compatible: true; identity: BundledPluginPackageIdentity } + | { compatible: false; pluginKey: string; code: "incompatible_bundled_package" | "missing_manifest_evidence"; reason: string }; + +function containedEntrypoint(root: string, declared: unknown, directory: boolean): string { + const relative = typeof declared === "string" ? declared.replace(/^\.\//, "").replace(/\/$/, "") : ""; + if (!relative || path.isAbsolute(relative) || path.win32.isAbsolute(relative) || relative.includes("\\") || relative.split("/").length > 32 || relative.split("/").some((part) => !part || part === "." || part === "..")) { + throw new Error("Bundled plugin entrypoint must stay inside its package"); + } + const target = fs.realpathSync(path.join(root, relative)); + const resolvedRelative = path.relative(root, target); + if (!resolvedRelative || resolvedRelative.startsWith("..") || path.isAbsolute(resolvedRelative)) { + throw new Error("Bundled plugin entrypoint escapes its package"); + } + const stat = fs.statSync(target); + if (directory ? !stat.isDirectory() : !stat.isFile()) throw new Error("Bundled plugin entrypoint is not prebuilt"); + return relative; +} + +export function createBundledPluginPackageResolver(installs: readonly ResolvedBundledPlugin[]) { + const byKey = new Map(installs.map((install) => [install.pluginKey, install])); + + function read(install: ResolvedBundledPlugin) { + const root = fs.realpathSync(install.localPath); + if (root !== path.resolve(install.localPath)) throw new Error("Bundled plugin package root changed after catalog resolution"); + containedEntrypoint(root, "package.json", false); + const metadataPath = path.join(root, "package.json"); + if (fs.statSync(metadataPath).size > 128 * 1024) throw new Error("Bundled plugin package metadata exceeds verification limits"); + const pkg = JSON.parse(fs.readFileSync(metadataPath, "utf8")); + if (typeof pkg.name !== "string" || typeof pkg.version !== "string" || !pkg.paperclipPlugin) { + throw new Error("Bundled plugin package identity or entrypoints are missing"); + } + if (install.distribution && pkg.version !== install.distribution.version) throw new Error("Bundled plugin package version does not match the catalog"); + const manifest = containedEntrypoint(root, pkg.paperclipPlugin.manifest, false); + const worker = containedEntrypoint(root, pkg.paperclipPlugin.worker, false); + const ui = pkg.paperclipPlugin.ui === undefined ? undefined : containedEntrypoint(root, pkg.paperclipPlugin.ui, true); + if (install.distribution && (worker !== install.distribution.entrypoints.worker || ui !== install.distribution.entrypoints.ui)) { + throw new Error("Bundled plugin entrypoints do not match the verified package"); + } + return { root, pkg, manifest, worker, ui }; + } + + function assertManifest(install: ResolvedBundledPlugin, manifest: PaperclipPluginManifestV1, previousManifest?: PaperclipPluginManifestV1) { + const bundle = read(install); + if (manifest.id !== install.pluginKey || manifest.version !== bundle.pkg.version) throw new Error("Bundled plugin manifest identity/version does not match the image"); + const worker = containedEntrypoint(bundle.root, manifest.entrypoints.worker, false); + const ui = manifest.entrypoints.ui === undefined ? undefined : containedEntrypoint(bundle.root, manifest.entrypoints.ui, true); + if (worker !== bundle.worker || ui !== bundle.ui) throw new Error("Bundled plugin manifest entrypoints do not match the image"); + assertDistributionManifestCapabilities(manifest); + if (previousManifest) { + const approved = new Set(previousManifest.capabilities); + if (manifest.capabilities.some((capability) => !approved.has(capability))) throw new Error("Bundled plugin capabilities require approval"); + } + return bundle; + } + + function resolve(plugin: BundledPluginRegistration): string { + const install = byKey.get(plugin.pluginKey); + if (!install) throw new Error(`Bundled plugin is absent or not selected in this deployment: ${plugin.pluginKey}`); + const bundle = assertManifest(install, plugin.manifestJson); + if (plugin.packageName !== bundle.pkg.name || plugin.version !== bundle.pkg.version) throw new Error("Bundled plugin registration identity/version does not match the image"); + return bundle.root; + } + + return { + resolve, + inspect(plugin: BundledPluginRegistration, verifiedManifest?: PaperclipPluginManifestV1): BundledPluginPackageInspection { + try { + resolve(plugin); + const install = byKey.get(plugin.pluginKey)!; + if (!verifiedManifest) return { compatible: false, pluginKey: plugin.pluginKey, code: "missing_manifest_evidence", reason: "Image manifest evidence is required" }; + assertManifest(install, verifiedManifest); + if (!isDeepStrictEqual(verifiedManifest, plugin.manifestJson)) throw new Error("Approved plugin manifest does not match the image"); + return { compatible: true, identity: { + kind: "bundled_image", catalogKey: install.key, pluginKey: plugin.pluginKey, + packageName: plugin.packageName, version: plugin.version, + ...(install.distribution ? { digest: install.distribution.digest } : {}), + } }; + } catch (error) { + return { compatible: false, pluginKey: plugin.pluginKey, code: "incompatible_bundled_package", reason: error instanceof Error ? error.message : String(error) }; + } + }, + assertActivation(input: { pluginKey?: string; packageRoot: string; manifest?: PaperclipPluginManifestV1; previousManifest?: PaperclipPluginManifestV1 }) { + const install = input.pluginKey ? byKey.get(input.pluginKey) : installs.find((candidate) => candidate.localPath === input.packageRoot); + if (!install || path.resolve(input.packageRoot) !== install.localPath) throw new Error("Bundled plugin is absent or not selected in this deployment"); + read(install); + if (input.manifest) assertManifest(install, input.manifest, input.previousManifest); + }, + }; +} + diff --git a/server/src/services/bundled-plugins.ts b/server/src/services/bundled-plugins.ts index 9a728f452fc..a53bc124ae9 100644 --- a/server/src/services/bundled-plugins.ts +++ b/server/src/services/bundled-plugins.ts @@ -203,7 +203,7 @@ export function resolveBundledPluginInstalls( `bundled plugin "${key}" resolves to "${localPath}", outside the bundled catalog root "${opts.catalogRoot}"; refusing to start`, ); } - resolved.push({ key: entry.key, pluginKey: entry.pluginKey, localPath }); + resolved.push({ key: entry.key, pluginKey: entry.pluginKey, localPath: opts.enforceCatalogRoot ? canonicalize(localPath) : localPath }); } return resolved; } @@ -269,6 +269,7 @@ async function reconcileBundledPluginManifest( deps: BundledPluginProvisionerDeps, bundleManifestExists: (localPath: string) => boolean, verifiedManifest?: PaperclipPluginManifestV1, + preservePackagePath = false, ): Promise<"upgrade_pending" | undefined> { try { // Managed reinstalls take the install path instead; this branch means the @@ -283,7 +284,7 @@ async function reconcileBundledPluginManifest( const approved = new Set(existing.manifestJson.capabilities ?? []); requiresApproval = bundleManifest.capabilities.some((capability) => !approved.has(capability)); } - const rebindPackage = install.distribution && existing.packagePath !== install.localPath && existing.status !== "uninstalled"; + const rebindPackage = !preservePackagePath && install.distribution && existing.packagePath !== install.localPath && existing.status !== "uninstalled"; const refreshDistribution = install.distribution && !isDeepStrictEqual(bundleManifest, existing.manifestJson); if (bundleManifest.version === existing.version && !rebindPackage && !requiresApproval && !refreshDistribution) return; await deps.registry.update(existing.id, { @@ -389,7 +390,7 @@ async function reenableErroredBundledPlugin( export async function ensureBundledPlugins( installs: readonly ResolvedBundledPlugin[], deps: BundledPluginProvisionerDeps, - opts: { reinstallUninstalled: boolean }, + opts: { reinstallUninstalled: boolean; preservePackagePath?: boolean }, ): Promise { const bundleManifestExists = deps.bundleManifestExists ?? defaultBundleManifestExists; for (const install of installs) { @@ -411,7 +412,7 @@ export async function ensureBundledPlugins( // existing install, because the auto-install below skips a present // plugin. Distribution entries also replace a legacy/npm package path // before loadAll resolves the worker. Configuration and status stay put. - const reconciledStatus = await reconcileBundledPluginManifest(existing, install, deps, bundleManifestExists, verifiedManifest); + const reconciledStatus = await reconcileBundledPluginManifest(existing, install, deps, bundleManifestExists, verifiedManifest, opts.preservePackagePath); if (reconciledStatus === "upgrade_pending") continue; if (existing.status === "error") { await reenableErroredBundledPlugin(existing, install, deps); diff --git a/server/src/services/plugin-loader.ts b/server/src/services/plugin-loader.ts index d4da285265b..7c9701fe8fe 100644 --- a/server/src/services/plugin-loader.ts +++ b/server/src/services/plugin-loader.ts @@ -31,7 +31,7 @@ import { execFile } from "node:child_process"; import os from "node:os"; import path from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; -import { promisify } from "node:util"; +import { isDeepStrictEqual, promisify } from "node:util"; import type { Db } from "@paperclipai/db"; import { PLUGIN_RPC_ERROR_CODES } from "@paperclipai/plugin-sdk"; import type { @@ -271,6 +271,8 @@ function getDeclaredPageRoutePaths(manifest: PaperclipPluginManifestV1): string[ * Options for the plugin loader service. */ export interface PluginLoaderOptions { + resolvePackageRoot?: (plugin: PluginRecord) => string; + prebuiltOnly?: boolean; /** Image-owned deployment policy, checked before importing code and starting workers. */ assertPackageActivation?: (input: { pluginKey?: string; @@ -1157,6 +1159,8 @@ export function pluginLoader( enableLocalFilesystem = true, enableNpmDiscovery = true, assertPackageActivation, + resolvePackageRoot, + prebuiltOnly = false, } = options; const registry = pluginRegistryService(db); @@ -1211,6 +1215,7 @@ export function pluginLoader( installOptions: PluginInstallOptions, ): Promise { const { packageName, localPath, version, installDir } = installOptions; + if (prebuiltOnly && !localPath) throw new Error("Managed plugins must come from the current image catalog"); if (!packageName && !localPath) { throw new Error("Either packageName or localPath must be provided"); @@ -1286,7 +1291,7 @@ export function pluginLoader( const pkgJson = await readPackageJson(resolvedPackagePath); if (!pkgJson) throw new Error(`Missing package.json at ${resolvedPackagePath}`); - if (localPath) { + if (localPath && !prebuiltOnly) { await ensureLocalPluginBuilt(resolvedPackagePath, pkgJson); } @@ -1398,7 +1403,7 @@ export function pluginLoader( } assertPackageActivation?.({ packageRoot, installedPackagePath: plugin.packagePath, pluginKey: plugin.pluginKey, manifest, previousManifest: plugin.manifestJson }); - if (JSON.stringify(manifest) === JSON.stringify(plugin.manifestJson)) { + if (isDeepStrictEqual(manifest, plugin.manifestJson)) { return plugin; } @@ -1749,6 +1754,7 @@ export function pluginLoader( packagePath: discovered.source === "local-filesystem" ? discovered.packagePath : undefined, }, manifest, + { idempotent: prebuiltOnly }, ); if (!installed) { @@ -1817,7 +1823,7 @@ export function pluginLoader( // For local-path installs, fall back to the stored packagePath so // `upgradePlugin` can re-read the manifest from disk without needing // the caller to re-supply the path every time. - localPath = plugin.packagePath ?? undefined, + localPath = resolvePackageRoot ? resolvePackageRoot(plugin) : plugin.packagePath ?? undefined, version, } = upgradeOptions; @@ -1887,6 +1893,7 @@ export function pluginLoader( // ----------------------------------------------------------------------- async cleanupInstallArtifacts(plugin: PluginRecord): Promise { + if (prebuiltOnly) return; const managedTargets = new Set(); const managedNodeModulesDir = resolveManagedInstallPackageDir(localPluginDir, plugin.packageName); const directManagedDir = path.join(localPluginDir, plugin.packageName); @@ -2262,12 +2269,14 @@ export function pluginLoader( // ------------------------------------------------------------------ // 1. Resolve worker entrypoint // ------------------------------------------------------------------ - const packageRoot = resolvePluginPackageRoot(activePlugin, localPluginDir); + const packageRoot = resolvePackageRoot ? resolvePackageRoot(activePlugin) : resolvePluginPackageRoot(activePlugin, localPluginDir); assertPackageActivation?.({ pluginKey, packageRoot, installedPackagePath: activePlugin.packagePath }); activePlugin = await refreshPluginManifestFromPackage(activePlugin, packageRoot); manifest = activePlugin.manifestJson; assertPackageActivation?.({ pluginKey, packageRoot, installedPackagePath: activePlugin.packagePath, manifest }); - const workerEntrypoint = resolveWorkerEntrypoint(activePlugin, localPluginDir); + const workerEntrypoint = resolvePackageRoot + ? path.resolve(packageRoot, manifest.entrypoints.worker) + : resolveWorkerEntrypoint(activePlugin, localPluginDir); // ------------------------------------------------------------------ // 2. Apply restricted database migrations before worker startup @@ -2334,7 +2343,7 @@ export function pluginLoader( env: buildPluginWorkerEnv({ manifest, packageName: activePlugin.packageName, - packagePath: activePlugin.packagePath, + packagePath: packageRoot, instanceInfo, }), // Authorize the worker to act on each configured company from its @@ -2348,7 +2357,7 @@ export function pluginLoader( // Repo-local plugin installs can resolve workspace TS sources at runtime // (for example @paperclipai/shared exports). Run those workers through // the tsx loader so first-party example plugins work in development. - if (activePlugin.packagePath && existsSync(DEV_TSX_LOADER_PATH)) { + if (!prebuiltOnly && activePlugin.packagePath && existsSync(DEV_TSX_LOADER_PATH)) { workerOptions.execArgv = ["--import", DEV_TSX_LOADER_PATH]; } diff --git a/server/src/services/plugin-registry.ts b/server/src/services/plugin-registry.ts index f7574bb293d..239c3c50490 100644 --- a/server/src/services/plugin-registry.ts +++ b/server/src/services/plugin-registry.ts @@ -1,3 +1,4 @@ +import { isDeepStrictEqual } from "node:util"; import { writePluginJobChange } from "./plugin-job-work-notifications.js"; import { asc, eq, isNull, ne, sql, and } from "drizzle-orm"; import type { Db } from "@paperclipai/db"; @@ -31,6 +32,10 @@ import type { import { conflict, notFound } from "../errors.js"; import { DELIVERY_QUEUES, notifyDeliveryWork } from "./delivery-work-notifications.js"; +function sameBundledPluginRegistration(existing: typeof plugins.$inferSelect, packageName: string, manifest: PaperclipPluginManifestV1): boolean { + return existing.pluginKey === manifest.id && existing.packageName === packageName && existing.version === manifest.version && isDeepStrictEqual(existing.manifestJson, manifest); +} + // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- @@ -140,10 +145,11 @@ export function pluginRegistryService(db: Db) { * manifest from the package. This method persists the plugin row and * assigns the next install order. */ - install: async (input: InstallPlugin, manifest: PaperclipPluginManifestV1) => { + install: async (input: InstallPlugin, manifest: PaperclipPluginManifestV1, options: { idempotent?: boolean } = {}) => { const existing = await getByKey(manifest.id); if (existing) { if (existing.status !== "uninstalled") { + if (options.idempotent && sameBundledPluginRegistration(existing, input.packageName, manifest)) return existing; throw conflict(`Plugin already installed: ${manifest.id}`); } @@ -153,7 +159,7 @@ export function pluginRegistryService(db: Db) { .update(plugins) .set({ packageName: input.packageName, - packagePath: input.packagePath ?? null, + packagePath: options.idempotent ? existing.packagePath : input.packagePath ?? null, version: manifest.version, apiVersion: manifest.apiVersion, categories: manifest.categories, @@ -170,7 +176,7 @@ export function pluginRegistryService(db: Db) { const installOrder = await nextInstallOrder(); try { - const rows = await db + const insertion = db .insert(plugins) .values({ pluginKey: manifest.id, @@ -182,9 +188,14 @@ export function pluginRegistryService(db: Db) { status: "installed" as PluginStatus, installOrder, packagePath: input.packagePath ?? null, - }) - .returning(); - return rows[0]; + }); + const rows = await (options.idempotent + ? insertion.onConflictDoNothing({ target: plugins.pluginKey }) + : insertion).returning(); + if (rows[0]) return rows[0]; + const concurrent = await getByKey(manifest.id); + if (concurrent && concurrent.status !== "uninstalled" && sameBundledPluginRegistration(concurrent, input.packageName, manifest)) return concurrent; + throw conflict(`Plugin registration differs from the current image: ${manifest.id}`); } catch (error) { if (isPluginKeyConflict(error)) { throw conflict(`Plugin already installed: ${manifest.id}`);