diff --git a/server/src/__tests__/bundled-plugins.test.ts b/server/src/__tests__/bundled-plugins.test.ts new file mode 100644 index 0000000000..e137634c9c --- /dev/null +++ b/server/src/__tests__/bundled-plugins.test.ts @@ -0,0 +1,353 @@ +import { mkdtempSync, mkdirSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterAll, describe, expect, it, vi } from "vitest"; +import { + BUNDLED_PLUGIN_CATALOG, + DEFAULT_BUNDLED_CATALOG_ROOT, + SELF_HOSTED_AUTO_INSTALL_KEYS, + ensureBundledPlugins, + resolveBundledCatalogRoot, + resolveBundledPluginInstalls, + type BundledPluginProvisionerDeps, + type ResolvedBundledPlugin, +} from "../services/bundled-plugins.js"; + +const CATALOG_ROOT = "/app/packages/plugins"; + +const tempDirs: string[] = []; +function makeTempDir(prefix: string): string { + const dir = mkdtempSync(path.join(os.tmpdir(), prefix)); + tempDirs.push(dir); + return dir; +} + +afterAll(() => { + for (const dir of tempDirs) rmSync(dir, { recursive: true, force: true }); +}); + +// --------------------------------------------------------------------------- +// Catalog-root resolution (fail-to-start allowlist) +// --------------------------------------------------------------------------- + +describe("resolveBundledPluginInstalls", () => { + it("resolves known keys to paths inside the catalog root", () => { + const resolved = resolveBundledPluginInstalls(["kubernetes", "daytona"], { + catalogRoot: CATALOG_ROOT, + env: {}, + enforceCatalogRoot: true, + }); + expect(resolved).toEqual([ + { + key: "kubernetes", + pluginKey: "paperclip.kubernetes-sandbox-provider", + localPath: path.join(CATALOG_ROOT, "sandbox-providers/kubernetes"), + }, + { + key: "daytona", + pluginKey: "paperclip.daytona-sandbox-provider", + localPath: path.join(CATALOG_ROOT, "sandbox-providers/daytona"), + }, + ]); + }); + + it("throws on a key outside the bundled catalog (fail to start)", () => { + expect(() => + resolveBundledPluginInstalls(["kubernetes", "not-a-bundled-plugin"], { + catalogRoot: CATALOG_ROOT, + env: {}, + enforceCatalogRoot: true, + }), + ).toThrow(/"not-a-bundled-plugin" is not in the bundled catalog.*refusing to start/); + }); + + it("names the known catalog keys in the unknown-key error", () => { + expect(() => + resolveBundledPluginInstalls(["nope"], { + catalogRoot: CATALOG_ROOT, + env: {}, + enforceCatalogRoot: true, + }), + ).toThrow(new RegExp(BUNDLED_PLUGIN_CATALOG.map((entry) => entry.key).join(", "))); + }); + + it("resolves an empty key list to no installs", () => { + expect( + resolveBundledPluginInstalls([], { + catalogRoot: CATALOG_ROOT, + env: {}, + enforceCatalogRoot: true, + }), + ).toEqual([]); + }); + + it("throws when an env override escapes the catalog root under enforcement", () => { + expect(() => + resolveBundledPluginInstalls(["kubernetes"], { + catalogRoot: CATALOG_ROOT, + env: { PAPERCLIP_KUBERNETES_PLUGIN_PATH: "/srv/evil/plugin" }, + enforceCatalogRoot: true, + }), + ).toThrow(/outside the bundled catalog root.*refusing to start/); + }); + + it("collapses `..` segments in an override before the containment check", () => { + expect(() => + resolveBundledPluginInstalls(["kubernetes"], { + catalogRoot: CATALOG_ROOT, + env: { + PAPERCLIP_KUBERNETES_PLUGIN_PATH: path.join( + CATALOG_ROOT, + "sandbox-providers/../../../../etc/kubernetes", + ), + }, + enforceCatalogRoot: true, + }), + ).toThrow(/outside the bundled catalog root/); + }); + + it("throws when a symlink inside the root points outside it under enforcement", () => { + const outside = makeTempDir("bundled-outside-"); + const root = makeTempDir("bundled-root-"); + mkdirSync(path.join(root, "sandbox-providers"), { recursive: true }); + symlinkSync(outside, path.join(root, "sandbox-providers", "kubernetes")); + expect(() => + resolveBundledPluginInstalls(["kubernetes"], { + catalogRoot: root, + env: {}, + enforceCatalogRoot: true, + }), + ).toThrow(/outside the bundled catalog root/); + }); + + it("honors the legacy kubernetes path override without enforcement (self-hosted)", () => { + const resolved = resolveBundledPluginInstalls(["kubernetes"], { + catalogRoot: CATALOG_ROOT, + env: { PAPERCLIP_KUBERNETES_PLUGIN_PATH: "/somewhere/else/kubernetes" }, + enforceCatalogRoot: false, + }); + expect(resolved).toEqual([ + { + key: "kubernetes", + pluginKey: "paperclip.kubernetes-sandbox-provider", + localPath: "/somewhere/else/kubernetes", + }, + ]); + }); + + it("honors an env override that stays inside the catalog root under enforcement", () => { + const inside = path.join(CATALOG_ROOT, "sandbox-providers", "kubernetes"); + const resolved = resolveBundledPluginInstalls(["kubernetes"], { + catalogRoot: CATALOG_ROOT, + env: { PAPERCLIP_KUBERNETES_PLUGIN_PATH: inside }, + enforceCatalogRoot: true, + }); + expect(resolved[0]!.localPath).toBe(inside); + }); + + it("dedupes repeated keys", () => { + const resolved = resolveBundledPluginInstalls(["kubernetes", "kubernetes"], { + catalogRoot: CATALOG_ROOT, + env: {}, + enforceCatalogRoot: true, + }); + expect(resolved).toHaveLength(1); + }); + + it("keeps the self-hosted default list to exactly the kubernetes bundle", () => { + expect(SELF_HOSTED_AUTO_INSTALL_KEYS).toEqual(["kubernetes"]); + const [entry] = resolveBundledPluginInstalls(SELF_HOSTED_AUTO_INSTALL_KEYS, { + catalogRoot: resolveBundledCatalogRoot({}), + env: {}, + enforceCatalogRoot: false, + }); + // Exactly the pre-refactor default path. + expect(entry).toEqual({ + key: "kubernetes", + pluginKey: "paperclip.kubernetes-sandbox-provider", + localPath: "/app/packages/plugins/sandbox-providers/kubernetes", + }); + }); + + it("covers every catalog entry with a path inside the default root", () => { + const keys = BUNDLED_PLUGIN_CATALOG.map((entry) => entry.key); + const resolved = resolveBundledPluginInstalls(keys, { + catalogRoot: DEFAULT_BUNDLED_CATALOG_ROOT, + env: {}, + enforceCatalogRoot: true, + }); + expect(resolved).toHaveLength(BUNDLED_PLUGIN_CATALOG.length); + }); +}); + +describe("resolveBundledCatalogRoot", () => { + it("defaults to the image catalog root", () => { + expect(resolveBundledCatalogRoot({})).toBe(DEFAULT_BUNDLED_CATALOG_ROOT); + }); + + it("honors PAPERCLIP_BUNDLED_PLUGIN_ROOT", () => { + expect(resolveBundledCatalogRoot({ PAPERCLIP_BUNDLED_PLUGIN_ROOT: "/custom/root" })).toBe( + "/custom/root", + ); + }); +}); + +// --------------------------------------------------------------------------- +// ensureBundledPlugins (fail-safe installer) +// --------------------------------------------------------------------------- + +function makeDeps(overrides?: { + rows?: Record; + bundleManifestExists?: (localPath: string) => boolean; + installError?: Error; +}) { + const rows = overrides?.rows ?? {}; + const installedRows = new Map(Object.entries(rows)); + const installPlugin = vi.fn(async ({ localPath }: { localPath: string }) => { + if (overrides?.installError) throw overrides.installError; + const entry = BUNDLED_PLUGIN_CATALOG.find((candidate) => + localPath.endsWith(candidate.relativePath), + ); + const pluginKey = entry?.pluginKey ?? "unknown"; + installedRows.set(pluginKey, { id: `id-${pluginKey}`, pluginKey, status: "installed" }); + return { manifest: { id: pluginKey } }; + }); + const deps: BundledPluginProvisionerDeps = { + registry: { + getByKey: vi.fn(async (pluginKey: string) => installedRows.get(pluginKey) ?? null), + }, + loader: { installPlugin } as unknown as BundledPluginProvisionerDeps["loader"], + lifecycle: { load: vi.fn(async () => undefined) }, + logger: { info: vi.fn(), error: vi.fn() }, + bundleManifestExists: overrides?.bundleManifestExists ?? (() => true), + }; + return { deps, installPlugin }; +} + +const K8S: ResolvedBundledPlugin = { + key: "kubernetes", + pluginKey: "paperclip.kubernetes-sandbox-provider", + localPath: path.join(CATALOG_ROOT, "sandbox-providers/kubernetes"), +}; +const DAYTONA: ResolvedBundledPlugin = { + key: "daytona", + pluginKey: "paperclip.daytona-sandbox-provider", + localPath: path.join(CATALOG_ROOT, "sandbox-providers/daytona"), +}; + +describe("ensureBundledPlugins", () => { + it("installs and loads a missing bundled plugin", async () => { + const { deps, installPlugin } = makeDeps(); + await ensureBundledPlugins([K8S], deps, { reinstallUninstalled: true }); + expect(installPlugin).toHaveBeenCalledWith({ localPath: K8S.localPath }); + expect(deps.lifecycle.load).toHaveBeenCalledWith( + "id-paperclip.kubernetes-sandbox-provider", + ); + }); + + it("skips a plugin present in any non-uninstalled state (disabled is not re-enabled)", async () => { + for (const status of ["installed", "ready", "disabled", "error"]) { + const { deps, installPlugin } = makeDeps({ + rows: { + [K8S.pluginKey]: { id: "row-1", pluginKey: K8S.pluginKey, status }, + }, + }); + await ensureBundledPlugins([K8S], deps, { reinstallUninstalled: true }); + expect(installPlugin).not.toHaveBeenCalled(); + expect(deps.lifecycle.load).not.toHaveBeenCalled(); + } + }); + + it("reinstalls a soft-uninstalled plugin in managed mode", async () => { + const { deps, installPlugin } = makeDeps({ + rows: { + [K8S.pluginKey]: { id: "row-1", pluginKey: K8S.pluginKey, status: "uninstalled" }, + }, + }); + await ensureBundledPlugins([K8S], deps, { reinstallUninstalled: true }); + expect(installPlugin).toHaveBeenCalledOnce(); + }); + + it("leaves a soft-uninstalled plugin alone in self-hosted mode (pre-refactor behavior)", async () => { + const { deps, installPlugin } = makeDeps({ + rows: { + [K8S.pluginKey]: { id: "row-1", pluginKey: K8S.pluginKey, status: "uninstalled" }, + }, + }); + await ensureBundledPlugins([K8S], deps, { reinstallUninstalled: false }); + expect(installPlugin).not.toHaveBeenCalled(); + }); + + it("skips silently when the bundle is absent on disk", async () => { + const { deps, installPlugin } = makeDeps({ bundleManifestExists: () => false }); + await ensureBundledPlugins([K8S], deps, { reinstallUninstalled: true }); + expect(installPlugin).not.toHaveBeenCalled(); + expect(deps.logger.error).not.toHaveBeenCalled(); + }); + + it("logs and continues past a failing install, still processing later entries", async () => { + const { deps, installPlugin } = makeDeps(); + installPlugin.mockRejectedValueOnce(new Error("disk exploded")); + await expect( + ensureBundledPlugins([K8S, DAYTONA], deps, { reinstallUninstalled: true }), + ).resolves.toBeUndefined(); + expect(deps.logger.error).toHaveBeenCalledWith( + expect.objectContaining({ pluginKey: K8S.pluginKey }), + expect.stringContaining("continuing boot"), + ); + // Daytona still installed after the kubernetes failure. + expect(installPlugin).toHaveBeenCalledTimes(2); + expect(deps.lifecycle.load).toHaveBeenCalledWith("id-paperclip.daytona-sandbox-provider"); + }); + + it("never uninstalls anything: plugins absent from the list are untouched", async () => { + const { deps, installPlugin } = makeDeps({ + rows: { + [DAYTONA.pluginKey]: { id: "row-d", pluginKey: DAYTONA.pluginKey, status: "ready" }, + }, + }); + // Daytona was removed from the autoInstall list; only kubernetes remains. + await ensureBundledPlugins([K8S], deps, { reinstallUninstalled: true }); + expect(installPlugin).toHaveBeenCalledOnce(); + expect(installPlugin).toHaveBeenCalledWith({ localPath: K8S.localPath }); + // No uninstall/unload calls exist on the provisioner deps at all; daytona + // was never queried beyond its own key and its row is untouched. + expect(deps.lifecycle.load).toHaveBeenCalledTimes(1); + }); + + it("logs an error and does not load when install returns no manifest", async () => { + const { deps, installPlugin } = makeDeps(); + installPlugin.mockResolvedValueOnce({ manifest: null }); + await ensureBundledPlugins([K8S], deps, { reinstallUninstalled: true }); + expect(deps.logger.error).toHaveBeenCalledWith( + expect.objectContaining({ pluginKey: K8S.pluginKey }), + expect.stringContaining("manifest is missing"), + ); + expect(deps.lifecycle.load).not.toHaveBeenCalled(); + }); + + it("logs an error when the installed plugin never appears in the registry", async () => { + const { deps, installPlugin } = makeDeps(); + (deps.registry.getByKey as ReturnType).mockResolvedValue(null); + await ensureBundledPlugins([K8S], deps, { reinstallUninstalled: true }); + expect(installPlugin).toHaveBeenCalledOnce(); + expect(deps.logger.error).toHaveBeenCalledWith( + expect.objectContaining({ pluginKey: K8S.pluginKey }), + expect.stringContaining("not found in registry"), + ); + expect(deps.lifecycle.load).not.toHaveBeenCalled(); + }); + + it("checks the real bundle manifest path by default (dist/manifest.js)", async () => { + const bundleDir = makeTempDir("bundled-bundle-"); + const { deps, installPlugin } = makeDeps(); + delete deps.bundleManifestExists; + const install = { ...K8S, localPath: bundleDir }; + await ensureBundledPlugins([install], deps, { reinstallUninstalled: true }); + expect(installPlugin).not.toHaveBeenCalled(); + mkdirSync(path.join(bundleDir, "dist"), { recursive: true }); + writeFileSync(path.join(bundleDir, "dist", "manifest.js"), "module.exports = {}\n"); + await ensureBundledPlugins([install], deps, { reinstallUninstalled: true }); + expect(installPlugin).toHaveBeenCalledOnce(); + }); +}); diff --git a/server/src/app.ts b/server/src/app.ts index 26bcefd866..cca5958cae 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -64,6 +64,12 @@ import { readBrandedStaticIndexHtml } from "./static-index-html.js"; import { applyUiBranding } from "./ui-branding.js"; import { logger } from "./middleware/logger.js"; import { DEFAULT_LOCAL_PLUGIN_DIR, pluginLoader } from "./services/plugin-loader.js"; +import { + SELF_HOSTED_AUTO_INSTALL_KEYS, + ensureBundledPlugins, + resolveBundledCatalogRoot, + resolveBundledPluginInstalls, +} from "./services/bundled-plugins.js"; import { createPluginWorkerManager, type PluginWorkerManager } from "./services/plugin-worker-manager.js"; import { createPluginJobScheduler } from "./services/plugin-job-scheduler.js"; import { pluginJobStore } from "./services/plugin-job-store.js"; @@ -170,6 +176,15 @@ export async function createApp( pluginWorkerManager?: PluginWorkerManager; betterAuthHandler?: express.RequestHandler; resolveSession?: (req: ExpressRequest) => Promise; + /** + * `plugins.autoInstall` from the managed config (PAPERCLIP_MANAGED_CONFIG). + * `null`/absent ⇒ self-hosted: only the built-in kubernetes bundle is + * ensured, exactly as before. A managed list is resolved against the + * bundled catalog fail-to-start (see services/bundled-plugins.ts). + */ + managedPluginAutoInstall?: readonly string[] | null; + /** Test override for the bundled plugin catalog root. */ + bundledPluginCatalogRoot?: string; }, ) { const app = express(); @@ -536,65 +551,51 @@ export async function createApp( lifecycle, async (pluginId) => (await pluginRegistry.getById(pluginId))?.packagePath ?? null, ); - // Auto-install the bundled kubernetes sandbox-provider plugin so the - // "kubernetes" sandbox provider is registered for agent runs. The plugin is - // excluded from the pnpm workspace and built standalone into the image (see - // Dockerfile), then installed here from its local path. This runs BEFORE - // loadAll() so loadAll() can activate it in the same startup pass. + // Auto-provision bundled plugins so their providers are registered for + // agent runs. Bundles are excluded from the pnpm + // workspace and built standalone into the image (see Dockerfile), then + // installed here from their local paths. This runs BEFORE loadAll() so + // loadAll() can activate them in the same startup pass. // - // SAFETY (invariant B): this is fully fail-safe. Any failure (missing path, - // install error, load error) is caught, logged, and swallowed so the server - // ALWAYS finishes booting. A degraded boot (no kubernetes provider, agents - // cannot run) is strictly preferable to a crash loop. - const ensureBundledKubernetesPlugin = async (): Promise => { - const KUBERNETES_PLUGIN_KEY = "paperclip.kubernetes-sandbox-provider"; - const pluginPath = - process.env["PAPERCLIP_KUBERNETES_PLUGIN_PATH"] ?? - "/app/packages/plugins/sandbox-providers/kubernetes"; - try { - // Idempotent: skip if already installed (any non-uninstalled status). - const existing = await pluginRegistry.getByKey(KUBERNETES_PLUGIN_KEY); - if (existing) { - logger.info( - { pluginKey: KUBERNETES_PLUGIN_KEY, status: existing.status }, - "kubernetes sandbox plugin already installed; skipping auto-install", - ); - return; - } - // Skip silently when the bundle is absent (e.g. local dev or an image - // built without the plugin). Not an error condition. - if (!fs.existsSync(path.join(pluginPath, "dist", "manifest.js"))) { - logger.info( - { pluginPath }, - "kubernetes sandbox plugin bundle not present; skipping auto-install", - ); - return; - } - logger.info({ pluginPath }, "auto-installing bundled kubernetes sandbox plugin"); - const discovered = await loader.installPlugin({ localPath: pluginPath }); - if (!discovered.manifest) { - logger.error("kubernetes sandbox plugin installed but manifest is missing"); - return; - } - // Transition installed -> ready and activate the worker. - const installed = await pluginRegistry.getByKey(discovered.manifest.id); - if (installed) { - await lifecycle.load(installed.id); - logger.info( - { pluginId: installed.id, pluginKey: installed.pluginKey }, - "kubernetes sandbox plugin auto-installed and loaded", - ); - } else { - logger.error("kubernetes sandbox plugin installed but not found in registry"); - } - } catch (err) { - logger.error( - { err }, - "Failed to auto-install the kubernetes sandbox plugin; continuing boot (degraded: kubernetes provider unavailable)", - ); - } - }; - void ensureBundledKubernetesPlugin() + // Workers are started exactly once, by loadAll(): the `lifecycle` manager + // above is constructed without a runtime-capable loader + // (pluginLifecycleManager(db, { workerManager }) — no `loader` option), so + // the lifecycle.load() that ensureBundledPlugins performs per newly + // installed bundle only records the `ready` status and does not spawn a + // worker (see activateReadyPlugin in services/plugin-lifecycle.ts). + // + // Managed instances (`plugins.autoInstall` from PAPERCLIP_MANAGED_CONFIG) + // drive the key list from the control plane; self-hosted instances keep + // the pre-existing behavior of ensuring only the kubernetes bundle. + // + // Resolution below is deliberately synchronous and NOT fail-safe: an + // unknown key or a path escaping the bundled catalog root throws out of + // createApp so a managed instance refuses to start (positive allowlist, + // fail closed). + const managedAutoInstallKeys = opts.managedPluginAutoInstall ?? null; + const bundledCatalogRoot = + opts.bundledPluginCatalogRoot ?? resolveBundledCatalogRoot(process.env); + const bundledPluginInstalls = resolveBundledPluginInstalls( + managedAutoInstallKeys ?? SELF_HOSTED_AUTO_INSTALL_KEYS, + { + catalogRoot: bundledCatalogRoot, + env: process.env, + enforceCatalogRoot: managedAutoInstallKeys !== null, + }, + ); + // SAFETY: installation is fully fail-safe. Any failure + // (missing bundle, install error, load error) is caught, logged, and + // swallowed per plugin so the server ALWAYS finishes booting. A degraded + // boot (a provider unavailable, some agents cannot run) is strictly + // preferable to a crash loop. + void ensureBundledPlugins( + bundledPluginInstalls, + { registry: pluginRegistry, loader, lifecycle, logger }, + // Managed mode reinstalls soft-uninstalled bundles (the control plane + // owns provisioning); self-hosted leaves an operator's uninstall alone. + // Operator-DISABLED plugins are never touched in either mode. + { reinstallUninstalled: managedAutoInstallKeys !== null }, + ) .then(() => loader.loadAll()) .then((result) => { if (!result) return; diff --git a/server/src/index.ts b/server/src/index.ts index 03f6104a3f..f89f06e98b 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -33,7 +33,10 @@ import detectPort from "detect-port"; import { createApp } from "./app.js"; import { loadConfig } from "./config.js"; import { logger } from "./middleware/logger.js"; -import { getManagedInstanceConfig } from "./services/managed-config.js"; +import { + getManagedInstanceConfig, + type ManagedInstanceConfig, +} from "./services/managed-config.js"; import { setupEnvironmentCustomImageTerminalWebSocketServer } from "./realtime/environment-custom-image-terminal-ws.js"; import { setupLiveEventsWebSocketServer } from "./realtime/live-events-ws.js"; import { @@ -595,8 +598,9 @@ export async function startServer(): Promise { // overlays it per read. This MUST run before any instanceSettingsService(db) // construction — that constructor parses the same env, and it would otherwise // throw first, bypassing this fail-closed log path. + let managedConfig: ManagedInstanceConfig | null; try { - const managedConfig = getManagedInstanceConfig(); + managedConfig = getManagedInstanceConfig(); if (managedConfig) { logger.warn( { @@ -691,6 +695,10 @@ export async function startServer(): Promise { } }; const pluginWorkerManager = createPluginWorkerManager(); + // Managed instances drive bundled plugin auto-install from the managed-config + // document parsed fail-closed above (`plugins.autoInstall`). Absent env means + // self-hosted: createApp falls back to its built-in kubernetes-only default. + const managedPluginAutoInstall = managedConfig?.plugins.autoInstall ?? null; const app = await createApp(db as any, { uiMode, serverPort: listenPort, @@ -724,6 +732,7 @@ export async function startServer(): Promise { betterAuthHandler, resolveSession, pluginWorkerManager, + managedPluginAutoInstall, }); const server = createServer(app as unknown as Parameters[0]); diff --git a/server/src/services/bundled-plugins.ts b/server/src/services/bundled-plugins.ts new file mode 100644 index 0000000000..a3c27a7e26 --- /dev/null +++ b/server/src/services/bundled-plugins.ts @@ -0,0 +1,297 @@ +import path from "node:path"; +import fs from "node:fs"; + +/** + * Bundled plugin auto-provisioning. + * + * Managed-cloud instances receive a `plugins.autoInstall` key list through + * `PAPERCLIP_MANAGED_CONFIG` (parsed fail-closed at startup — see + * `managed-config.ts`). Each key maps to a plugin bundled into + * the release image under the bundled catalog root. Nobody "installs" on a + * managed instance: the control plane provisions, tenants use. + * + * Two distinct failure postures, deliberately split: + * + * 1. **Resolution (this file, `resolveBundledPluginInstalls`) fails to + * start.** An unknown key or a path that escapes the bundled catalog + * root is a configuration/security violation — a positive allowlist, + * not a lookup. Throwing here happens + * synchronously inside `createApp`, before the server listens, so a bad + * document refuses to start rather than silently widening what code can + * be loaded into the host. + * + * 2. **Installation (`ensureBundledPlugins`) is fail-safe.** + * Missing bundle on disk, install error, load error: caught, logged, + * and swallowed per entry so the server ALWAYS finishes booting. A + * degraded boot (one provider unavailable) is strictly preferable to a + * crash loop across a fleet. + * + * Removal of a key from `autoInstall` stops future installs but never + * auto-uninstalls: there is intentionally no uninstall + * path anywhere in this module. + */ + +/** Default location of the bundled plugin catalog inside the release image. */ +export const DEFAULT_BUNDLED_CATALOG_ROOT = "/app/packages/plugins"; + +/** + * Env var that relocates the bundled catalog root (dev images, tests). + */ +export const BUNDLED_CATALOG_ROOT_ENV_VAR = "PAPERCLIP_BUNDLED_PLUGIN_ROOT"; + +export interface BundledPluginCatalogEntry { + /** Key the managed config's `plugins.autoInstall` list uses. */ + key: string; + /** Manifest id / registry `pluginKey` the bundle installs as. */ + pluginKey: string; + /** Bundle location relative to the bundled catalog root. */ + relativePath: string; + /** + * Legacy absolute-path override honored for compatibility (the kubernetes + * bundle predates the catalog). Overrides are still subject to catalog + * containment when enforcement is on. + */ + pathOverrideEnvVar?: string; +} + +/** + * The positive allowlist of plugins the control plane may auto-provision. + * Keys outside this table can never be installed through this path, + * regardless of what the managed config document says. + */ +export const BUNDLED_PLUGIN_CATALOG: readonly BundledPluginCatalogEntry[] = [ + { + key: "cloudflare", + pluginKey: "paperclip.cloudflare-sandbox-provider", + relativePath: "sandbox-providers/cloudflare", + }, + { + key: "daytona", + pluginKey: "paperclip.daytona-sandbox-provider", + relativePath: "sandbox-providers/daytona", + }, + { + key: "e2b", + pluginKey: "paperclip.e2b-sandbox-provider", + relativePath: "sandbox-providers/e2b", + }, + { + key: "exe-dev", + pluginKey: "paperclip.exe-dev-sandbox-provider", + relativePath: "sandbox-providers/exe-dev", + }, + { + key: "kubernetes", + pluginKey: "paperclip.kubernetes-sandbox-provider", + relativePath: "sandbox-providers/kubernetes", + pathOverrideEnvVar: "PAPERCLIP_KUBERNETES_PLUGIN_PATH", + }, + { + key: "modal", + pluginKey: "paperclip.modal-sandbox-provider", + relativePath: "sandbox-providers/modal", + }, + { + key: "novita", + pluginKey: "paperclip.novita-sandbox-provider", + relativePath: "sandbox-providers/novita", + }, +]; + +/** + * Keys ensured on a self-hosted instance (no managed config present). + * Exactly the pre-refactor behavior: the kubernetes sandbox provider is + * auto-installed when its bundle is present, nothing else. + */ +export const SELF_HOSTED_AUTO_INSTALL_KEYS: readonly string[] = ["kubernetes"]; + +export function resolveBundledCatalogRoot( + env: Record, +): string { + const override = env[BUNDLED_CATALOG_ROOT_ENV_VAR]?.trim(); + return override ? override : DEFAULT_BUNDLED_CATALOG_ROOT; +} + +export interface ResolvedBundledPlugin { + key: string; + pluginKey: string; + /** Absolute path handed to `loader.installPlugin({ localPath })`. */ + localPath: string; +} + +/** + * Canonicalize a path for containment comparison. Symlinks are resolved + * when the path exists so a link inside the catalog cannot point install + * resolution at a directory outside it; nonexistent paths fall back to a + * lexical resolve (`..` segments still collapse). + */ +function canonicalize(p: string): string { + const resolved = path.resolve(p); + try { + return fs.realpathSync(resolved); + } catch { + return resolved; + } +} + +function isInsideRoot(candidate: string, root: string): boolean { + const rel = path.relative(root, candidate); + return rel === "" || (!rel.startsWith("..") && !path.isAbsolute(rel)); +} + +/** + * Resolve auto-install keys to concrete bundle paths. + * + * Throws — and the instance must refuse to start — when a key is not in + * the bundled catalog, or when `enforceCatalogRoot` is set and the + * resolved path escapes the catalog root. Callers pass + * `enforceCatalogRoot: true` for managed (control-plane-driven) key lists + * and `false` for the self-hosted built-in list, where the legacy + * kubernetes path override may point anywhere (unchanged behavior). + */ +export function resolveBundledPluginInstalls( + keys: readonly string[], + opts: { + catalogRoot: string; + env: Record; + enforceCatalogRoot: boolean; + }, +): ResolvedBundledPlugin[] { + const resolved: ResolvedBundledPlugin[] = []; + const seen = new Set(); + const canonicalRoot = canonicalize(opts.catalogRoot); + for (const key of keys) { + if (seen.has(key)) continue; + seen.add(key); + const entry = BUNDLED_PLUGIN_CATALOG.find((candidate) => candidate.key === key); + if (!entry) { + const known = BUNDLED_PLUGIN_CATALOG.map((candidate) => candidate.key).join(", "); + throw new Error( + `bundled plugin auto-install key "${key}" is not in the bundled catalog (known keys: ${known}); refusing to start`, + ); + } + const override = entry.pathOverrideEnvVar + ? opts.env[entry.pathOverrideEnvVar]?.trim() + : undefined; + const localPath = override + ? path.resolve(override) + : path.resolve(opts.catalogRoot, entry.relativePath); + if (opts.enforceCatalogRoot && !isInsideRoot(canonicalize(localPath), canonicalRoot)) { + throw new Error( + `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 }); + } + return resolved; +} + +interface RegistryPluginRow { + id: string; + pluginKey: string; + status: string; +} + +export interface BundledPluginProvisionerDeps { + registry: { + getByKey(pluginKey: string): Promise; + }; + loader: { + installPlugin(options: { localPath: string }): Promise<{ + manifest: { id: string } | null; + }>; + }; + lifecycle: { + load(pluginId: string): Promise; + }; + logger: { + info(obj: unknown, msg?: string): void; + error(obj: unknown, msg?: string): void; + }; + /** Overridable for tests; defaults to checking `dist/manifest.js`. */ + bundleManifestExists?: (localPath: string) => boolean; +} + +function defaultBundleManifestExists(localPath: string): boolean { + return fs.existsSync(path.join(localPath, "dist", "manifest.js")); +} + +/** + * Ensure each resolved bundled plugin is installed and loaded. + * + * Same mechanism the kubernetes bundle has always used: in-process + * `loader.installPlugin({ localPath })` at boot — no HTTP, no user, no + * role. Fully fail-safe per entry: any disk/install/load + * failure is caught, logged, and swallowed so boot always completes. + * + * Skip semantics: + * - A plugin present in any non-uninstalled state is skipped, so an + * operator-disabled plugin is not silently re-enabled on reboot. + * - A soft-uninstalled plugin is reinstalled only when + * `reinstallUninstalled` is set (managed mode, where the control plane + * owns provisioning). Self-hosted keeps the pre-refactor behavior of + * leaving an operator's uninstall alone. + */ +export async function ensureBundledPlugins( + installs: readonly ResolvedBundledPlugin[], + deps: BundledPluginProvisionerDeps, + opts: { reinstallUninstalled: boolean }, +): Promise { + const bundleManifestExists = deps.bundleManifestExists ?? defaultBundleManifestExists; + for (const install of installs) { + try { + const existing = await deps.registry.getByKey(install.pluginKey); + if (existing && (existing.status !== "uninstalled" || !opts.reinstallUninstalled)) { + deps.logger.info( + { pluginKey: install.pluginKey, status: existing.status }, + "bundled plugin already present; skipping auto-install", + ); + continue; + } + // 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)) { + deps.logger.info( + { pluginKey: install.pluginKey, pluginPath: install.localPath }, + "bundled plugin bundle not present; skipping auto-install", + ); + continue; + } + deps.logger.info( + { pluginKey: install.pluginKey, pluginPath: install.localPath }, + "auto-installing bundled plugin", + ); + const discovered = await deps.loader.installPlugin({ localPath: install.localPath }); + if (!discovered.manifest) { + deps.logger.error( + { pluginKey: install.pluginKey }, + "bundled plugin installed but manifest is missing", + ); + continue; + } + // Transition installed -> ready. Whether this also starts the worker + // depends on the injected lifecycle manager: one built with a + // runtime-capable loader activates here; the boot-time manager in + // app.ts is not, so at startup this only records `ready` and the + // worker is started exactly once by the subsequent loader.loadAll(). + const installed = await deps.registry.getByKey(discovered.manifest.id); + if (installed) { + await deps.lifecycle.load(installed.id); + deps.logger.info( + { pluginId: installed.id, pluginKey: installed.pluginKey }, + "bundled plugin auto-installed and loaded", + ); + } else { + deps.logger.error( + { pluginKey: install.pluginKey }, + "bundled plugin installed but not found in registry", + ); + } + } catch (err) { + deps.logger.error( + { err, pluginKey: install.pluginKey }, + "Failed to auto-install bundled plugin; continuing boot (degraded: plugin unavailable)", + ); + } + } +}