diff --git a/doc/plugins/DISTRIBUTION-PLUGINS.md b/doc/plugins/DISTRIBUTION-PLUGINS.md index fea42368e5..d2fa5fd02a 100644 --- a/doc/plugins/DISTRIBUTION-PLUGINS.md +++ b/doc/plugins/DISTRIBUTION-PLUGINS.md @@ -51,6 +51,12 @@ is recorded as a plugin error without taking down the application. The catalog alone does not auto-enable plugins on self-hosted instances. Operators can explicitly install catalog entries through the normal plugin CLI. The package's manifest ID and version must match the catalog. +The manifest's worker and optional UI entrypoints must match the verified +`package.json` declarations and stay inside the bundle. + +At boot, selected distribution entries adopt the current image's package path +even when a previous npm or local install has the same version. Reconciliation +keeps the registry ID, configuration, stored state and operator-disabled status. Keep each key's directory stable across releases. The activation guard also covers persisted installs: a plugin removed from the image catalog, or no diff --git a/server/src/__tests__/bundled-plugins.test.ts b/server/src/__tests__/bundled-plugins.test.ts index 0015010b7e..57cb3e7c6f 100644 --- a/server/src/__tests__/bundled-plugins.test.ts +++ b/server/src/__tests__/bundled-plugins.test.ts @@ -215,6 +215,7 @@ type LooseRow = { version?: string; manifestJson?: Record; lastError?: string | null; + packagePath?: string | null; }; // Build a minimal manifest for a persisted row or a shipped bundle. The reconcile @@ -402,6 +403,41 @@ describe("ensureBundledPlugins", () => { expect(update).not.toHaveBeenCalled(); }); + it.each([null, "/old/plugins/widget"])("adopts a selected distribution path from %s even at the same version", async (packagePath) => { + const localPath = path.join(CATALOG_ROOT, "distribution/widget"); + const distribution = { key: "widget", pluginKey: "acme.widget", version: "0.1.0", directory: "widget", digest: `sha256:${"a".repeat(64)}`, localPath, entrypoints: { worker: "dist/worker.js" } }; + for (const status of ["ready", "disabled", "error"]) { + const { deps, loadManifest, update, installPlugin, updateStatus } = makeDeps({ + rows: { "acme.widget": { id: "row-widget", pluginKey: "acme.widget", status, version: "0.1.0", packagePath } }, + // Distribution packages may declare a manifest outside dist/manifest.js. + bundleManifestExists: () => false, + }); + const manifest = makeManifest("acme.widget", "0.1.0"); + loadManifest.mockResolvedValue(manifest); + await ensureBundledPlugins([{ ...distribution, distribution }], deps, { reinstallUninstalled: true }); + // Same row: retain config/state and operator-disabled status; only error + // gets the existing one-shot boot retry. No reinstall/capability reset. + expect(update).toHaveBeenCalledExactlyOnceWith("row-widget", { packagePath: localPath, version: "0.1.0", manifest }); + expect(installPlugin).not.toHaveBeenCalled(); + expect(updateStatus).toHaveBeenCalledTimes(status === "error" ? 1 : 0); + expect(loadManifest).toHaveBeenCalledExactlyOnceWith(localPath); + expect(deps.logger.error).not.toHaveBeenCalled(); + } + }); + + it("does not rebind a distribution install whose new manifest fails validation", async () => { + const localPath = path.join(CATALOG_ROOT, "distribution/widget"); + const distribution = { key: "widget", pluginKey: "acme.widget", version: "0.1.0", directory: "widget", digest: `sha256:${"a".repeat(64)}`, localPath, entrypoints: { worker: "dist/worker.js" } }; + const { deps, loadManifest, update, updateStatus } = makeDeps({ + rows: { "acme.widget": { id: "row-widget", pluginKey: "acme.widget", status: "error", packagePath: "/old/widget" } }, + }); + loadManifest.mockRejectedValue(new Error("Distribution manifest entrypoints do not match the verified package")); + await ensureBundledPlugins([{ ...distribution, distribution }], deps, { reinstallUninstalled: true }); + expect(update).not.toHaveBeenCalled(); + expect(updateStatus).not.toHaveBeenCalled(); + expect(deps.logger.error).toHaveBeenCalled(); + }); + it("swallows a reconcile error and continues boot", async () => { const { deps, update } = makeDeps({ rows: { diff --git a/server/src/__tests__/distribution-plugin-catalog.test.ts b/server/src/__tests__/distribution-plugin-catalog.test.ts index c3e5ec2514..a03afe8856 100644 --- a/server/src/__tests__/distribution-plugin-catalog.test.ts +++ b/server/src/__tests__/distribution-plugin-catalog.test.ts @@ -76,4 +76,29 @@ describe("image-owned plugin catalogs", () => { expect(() => guard({ pluginKey: "acme.widget", packageRoot: localPath, manifest: manifest as PaperclipPluginManifestV1 })).toThrow(/identity\/version/); } }); + + it("binds runtime worker and UI entrypoints to the digest-verified package declarations", () => { + const { root, localPath } = fixture(); + const entries = readDistributionPluginCatalog(root, BUNDLED_PLUGIN_CATALOG); + const guard = distributionPluginActivationGuard(root, entries, ["acme.widget"]); + const manifest = { id: "acme.widget", version: "1.0.0", entrypoints: { worker: "dist/worker.js", ui: "./dist/ui" } } as PaperclipPluginManifestV1; + expect(() => guard({ packageRoot: localPath, manifest })).not.toThrow(); + for (const name of ["worker", "ui"] as const) { + for (const value of ["/outside/worker.js", "../outside", "dist/../worker.js", "C:/outside", "dist\\worker.js", "dist/other", "", undefined]) { + const invalid = { ...manifest, entrypoints: { ...manifest.entrypoints, [name]: value } } as PaperclipPluginManifestV1; + expect(() => guard({ packageRoot: localPath, manifest: invalid })).toThrow(/entrypoint/); + } + } + }); + + it("accepts worker-only bundles but rejects a UI path absent from verified metadata", () => { + const { root, localPath, entry, save } = fixture(); + writeFileSync(path.join(localPath, "package.json"), JSON.stringify({ version: "1.0.0", paperclipPlugin: { manifest: "./dist/manifest.js", worker: "./dist/worker.js" } })); + save([{ ...entry, digest: distributionBundleDigest(localPath) }]); + const guard = distributionPluginActivationGuard(root, readDistributionPluginCatalog(root, BUNDLED_PLUGIN_CATALOG), null); + const manifest = { id: "acme.widget", version: "1.0.0", entrypoints: { worker: "./dist/worker.js" } } as PaperclipPluginManifestV1; + expect(() => guard({ packageRoot: localPath, manifest })).not.toThrow(); + manifest.entrypoints.ui = "./dist/ui"; + expect(() => guard({ packageRoot: localPath, manifest })).toThrow(/verified package/); + }); }); diff --git a/server/src/services/bundled-plugins.ts b/server/src/services/bundled-plugins.ts index 96d4930b8e..571b4e1d67 100644 --- a/server/src/services/bundled-plugins.ts +++ b/server/src/services/bundled-plugins.ts @@ -208,6 +208,7 @@ interface RegistryPluginRow { status: string; version: string; manifestJson: PaperclipPluginManifestV1; + packagePath?: string | null; lastError?: string | null; } @@ -216,7 +217,7 @@ export interface BundledPluginProvisionerDeps { getByKey(pluginKey: string): Promise; update( id: string, - data: { version?: string; manifest?: PaperclipPluginManifestV1 }, + data: { version?: string; manifest?: PaperclipPluginManifestV1; packagePath?: string }, ): Promise; updateStatus(id: string, input: { status: "ready"; lastError: string | null }): Promise; }; @@ -246,7 +247,9 @@ function defaultBundleManifestExists(localPath: string): boolean { * Reconcile a present bundled plugin's persisted manifest with the shipped * bundle. The bundle is part of the release image, so its manifest is the * source of truth. When the bundle declares a version that differs from the - * persisted version, update the stored manifest and version. This propagates + * persisted version, update the stored manifest and version. Distribution + * entries also adopt the image's package path, including same-version installs. + * This propagates * a manifest change (for example a new driver capability) to an existing * install that the auto-install path skips. * @@ -259,21 +262,25 @@ async function reconcileBundledPluginManifest( install: ResolvedBundledPlugin, deps: BundledPluginProvisionerDeps, bundleManifestExists: (localPath: string) => boolean, + verifiedManifest?: PaperclipPluginManifestV1, ): Promise { try { - if (!bundleManifestExists(install.localPath)) return; - const bundleManifest = await deps.loader.loadManifest(install.localPath); + if (!verifiedManifest && !bundleManifestExists(install.localPath)) return; + const bundleManifest = verifiedManifest ?? await deps.loader.loadManifest(install.localPath); if (!bundleManifest) return; - if (bundleManifest.version === existing.version) return; + const rebindPackage = install.distribution && existing.packagePath !== install.localPath && existing.status !== "uninstalled"; + if (bundleManifest.version === existing.version && !rebindPackage) return; await deps.registry.update(existing.id, { version: bundleManifest.version, manifest: bundleManifest, + ...(rebindPackage ? { packagePath: install.localPath } : {}), }); deps.logger.info( { pluginKey: install.pluginKey, fromVersion: existing.version, toVersion: bundleManifest.version, + ...(rebindPackage ? { packagePath: install.localPath } : {}), }, "reconciled bundled plugin manifest to the shipped bundle version", ); @@ -367,22 +374,24 @@ export async function ensureBundledPlugins( const bundleManifestExists = deps.bundleManifestExists ?? defaultBundleManifestExists; for (const install of installs) { try { + let verifiedManifest: PaperclipPluginManifestV1 | undefined; if (install.distribution) { const manifest = await deps.loader.loadManifest(install.localPath); if (manifest?.id !== install.pluginKey || manifest.version !== install.distribution.version) { throw new Error("Distribution manifest does not match its catalog identity/version"); } + verifiedManifest = manifest; } const existing = await deps.registry.getByKey(install.pluginKey); if (existing && (existing.status !== "uninstalled" || !opts.reinstallUninstalled)) { // The bundle ships with the release image, so its manifest is the // source of truth for a present plugin. Reconcile the persisted - // manifest when the shipped bundle declares a newer version. Without + // manifest when the shipped bundle declares a different version. Without // this step a manifest capability added to a bundle never reaches an // existing install, because the auto-install below skips a present - // plugin. The reconcile updates only the stored manifest row; the - // running worker already runs the shipped code. - await reconcileBundledPluginManifest(existing, install, deps, bundleManifestExists); + // plugin. Distribution entries also replace a legacy/npm package path + // before loadAll resolves the worker. Configuration and status stay put. + await reconcileBundledPluginManifest(existing, install, deps, bundleManifestExists, verifiedManifest); if (existing.status === "error") { await reenableErroredBundledPlugin(existing, install, deps); continue; @@ -395,7 +404,7 @@ export async function ensureBundledPlugins( } // Skip silently when the bundle is absent (e.g. local dev or an image // built without the plugin). Not an error condition. - if (!bundleManifestExists(install.localPath)) { + if (!verifiedManifest && !bundleManifestExists(install.localPath)) { deps.logger.info( { pluginKey: install.pluginKey, pluginPath: install.localPath }, "bundled plugin bundle not present; skipping auto-install", diff --git a/server/src/services/distribution-plugin-catalog.ts b/server/src/services/distribution-plugin-catalog.ts index a5a6e54793..40b9e5431c 100644 --- a/server/src/services/distribution-plugin-catalog.ts +++ b/server/src/services/distribution-plugin-catalog.ts @@ -18,8 +18,18 @@ export const distributionPluginCatalogSchema = z.object({ export type DistributionPlugin = z.infer["plugins"][number] & { localPath: string; + /** Normalized paths from the digest-verified package metadata. */ + entrypoints: { worker: string; ui?: string }; }; +function bundleEntrypoint(declared: unknown): string { + const relative = typeof declared === "string" ? declared.replace(/^\.\//, "").replace(/\/$/, "") : ""; + if (!relative || path.posix.isAbsolute(relative) || path.win32.isAbsolute(relative) || relative.includes("\\") || relative.split("/").some((part) => !part || part === "." || part === "..")) { + throw new Error("Distribution entrypoint must stay inside its bundle"); + } + return relative; +} + export function distributionPluginsRoot(catalogRoot: string): string { // Match canonical paths persisted by local-path installs (for example, // macOS /tmp -> /private/tmp), without permitting a symlinked catalog itself. @@ -54,6 +64,13 @@ export function distributionPluginActivationGuard( if (input.manifest && (input.manifest.id !== entry.pluginKey || input.manifest.version !== entry.version)) { throw new Error("Distribution manifest does not match its catalog identity/version"); } + if (input.manifest) { + const worker = bundleEntrypoint(input.manifest.entrypoints.worker); + const ui = input.manifest.entrypoints.ui === undefined ? undefined : bundleEntrypoint(input.manifest.entrypoints.ui); + if (worker !== entry.entrypoints.worker || ui !== entry.entrypoints.ui) { + throw new Error("Distribution manifest entrypoints do not match the verified package"); + } + } }; } @@ -117,13 +134,13 @@ export function readDistributionPluginCatalog( for (const name of ["manifest", "worker", "ui"] as const) { const declared = pkg.paperclipPlugin[name]; if (name === "ui" && declared === undefined) continue; - const relative = typeof declared === "string" ? declared.replace(/^\.\//, "").replace(/\/$/, "") : ""; - if (!relative || relative.startsWith("/") || relative.includes("\\") || relative.split("/").some((part: string) => !part || part === "." || part === "..")) { - throw new Error("Distribution entrypoint must stay inside its bundle"); - } + const relative = bundleEntrypoint(declared); const target = fs.statSync(path.join(localPath, relative)); if (name === "ui" ? !target.isDirectory() : !target.isFile()) throw new Error("Distribution entrypoint is not prebuilt"); } - return { ...entry, localPath }; + return { ...entry, localPath, entrypoints: { + worker: bundleEntrypoint(pkg.paperclipPlugin.worker), + ...(pkg.paperclipPlugin.ui === undefined ? {} : { ui: bundleEntrypoint(pkg.paperclipPlugin.ui) }), + } }; }); } diff --git a/server/src/services/plugin-registry.ts b/server/src/services/plugin-registry.ts index 9e2da31954..8c3b024d5e 100644 --- a/server/src/services/plugin-registry.ts +++ b/server/src/services/plugin-registry.ts @@ -199,6 +199,7 @@ export function pluginRegistryService(db: Db) { id: string, data: { packageName?: string; + packagePath?: string; version?: string; manifest?: PaperclipPluginManifestV1; }, @@ -210,6 +211,7 @@ export function pluginRegistryService(db: Db) { updatedAt: new Date(), }; if (data.packageName !== undefined) setClause.packageName = data.packageName; + if (data.packagePath !== undefined) setClause.packagePath = data.packagePath; if (data.version !== undefined) setClause.version = data.version; if (data.manifest !== undefined) { setClause.manifestJson = data.manifest;