paperclip/server/src/services/bundled-plugins.ts

359 lines
13 KiB
TypeScript

import path from "node:path";
import fs from "node:fs";
import type { PaperclipPluginManifestV1 } from "@paperclipai/shared";
/**
* 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, string | undefined>,
): 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<string, string | undefined>;
enforceCatalogRoot: boolean;
},
): ResolvedBundledPlugin[] {
const resolved: ResolvedBundledPlugin[] = [];
const seen = new Set<string>();
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;
version: string;
manifestJson: PaperclipPluginManifestV1;
}
export interface BundledPluginProvisionerDeps {
registry: {
getByKey(pluginKey: string): Promise<RegistryPluginRow | null>;
update(
id: string,
data: { version?: string; manifest?: PaperclipPluginManifestV1 },
): Promise<unknown>;
};
loader: {
installPlugin(options: { localPath: string }): Promise<{
manifest: { id: string } | null;
}>;
loadManifest(packagePath: string): Promise<PaperclipPluginManifestV1 | null>;
};
lifecycle: {
load(pluginId: string): Promise<unknown>;
};
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"));
}
/**
* 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
* a manifest change (for example a new driver capability) to an existing
* install that the auto-install path skips.
*
* The step is fail-safe. A missing bundle, a manifest read error, or a
* database error is caught, logged, and swallowed, so boot always completes.
* The step updates only the stored manifest row; it never restarts the worker.
*/
async function reconcileBundledPluginManifest(
existing: RegistryPluginRow,
install: ResolvedBundledPlugin,
deps: BundledPluginProvisionerDeps,
bundleManifestExists: (localPath: string) => boolean,
): Promise<void> {
try {
if (!bundleManifestExists(install.localPath)) return;
const bundleManifest = await deps.loader.loadManifest(install.localPath);
if (!bundleManifest) return;
if (bundleManifest.version === existing.version) return;
await deps.registry.update(existing.id, {
version: bundleManifest.version,
manifest: bundleManifest,
});
deps.logger.info(
{
pluginKey: install.pluginKey,
fromVersion: existing.version,
toVersion: bundleManifest.version,
},
"reconciled bundled plugin manifest to the shipped bundle version",
);
} catch (err) {
deps.logger.error(
{ err, pluginKey: install.pluginKey },
"Failed to reconcile bundled plugin manifest; continuing boot with the stored manifest",
);
}
}
/**
* 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. Before the
* skip, the persisted manifest is reconciled to the shipped bundle version
* (see `reconcileBundledPluginManifest`).
* - 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<void> {
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)) {
// 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
// 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);
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)",
);
}
}
}