Files
PaperClipAI/server/src/services/bundled-plugins.ts
T
Devin FoleyandPaperclip c4cdcc4826 Generalize bundled plugin provisioning: ensureBundledKubernetesPlugin → ensureBundledPlugins (#10063)
**Builds on** #10058 — reads `plugins.autoInstall` from the parsed
managed-config contract #10058 introduces (the interim
`readManagedPluginAutoInstall` shim is retired at rebase).

**Summary.** Boot-time bundled-plugin provisioning becomes
catalog-driven. A new bundled-plugin catalog lists the sandbox providers
shipped in-tree (keys like `kubernetes`, `daytona` → plugin key + path
under the catalog root). Managed instances read `plugins.autoInstall`
from `PAPERCLIP_MANAGED_CONFIG`; unknown keys or paths escaping the
catalog root (symlinks resolved) **throw before listen** — a managed
instance refuses to start rather than boot half-provisioned.
Installation keeps today's mechanism: an in-process, fail-safe
`loader.installPlugin({ localPath })` under a system actor — no HTTP
route, no user, no role widening. Self-hosted boot is unchanged
(kubernetes bundle only, existing env override honored, install failures
still log-and-continue).

**Semantics.** A plugin already present in any non-uninstalled state is
skipped, so an operator-disabled plugin is never silently re-enabled;
managed mode reinstalls soft-uninstalled bundles (the control plane owns
provisioning); removal from the autoInstall list never auto-uninstalls.

## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - Sandbox-provider plugins ship in-tree, but boot-time provisioning is
hard-coded to exactly one of them (Kubernetes) via a bespoke function
> - On managed hosting, tenant users have no install privileges, so any
bundled plugin that is not provisioned at boot is unusable
> - Widening install routes or granting roles to fix that would trade a
provisioning gap for a security regression
> - This pull request generalizes the existing boot installer into a
catalog-driven `ensureBundledPlugins`, fed by `plugins.autoInstall` from
`PAPERCLIP_MANAGED_CONFIG`
> - The benefit is that managed tenants get working bundled plugins out
of the box, through the same in-process, role-free mechanism the
codebase already trusts, while self-hosted boot is unchanged

## Linked Issues or Issue Description

No public issue exists; `feature_request` template fields:

- **Problem or motivation:** on managed instances tenant users cannot
install plugins (by design they never hold instance admin), so even
plugins shipped with the product are unusable; boot provisioning
currently knows only the Kubernetes bundle.
- **Proposed solution:** a bundled-plugin catalog plus
`ensureBundledPlugins(keys)` driven by the managed config; same
in-process `loader.installPlugin({ localPath })` under a system actor;
unknown keys or catalog-escaping paths fail startup; already-present
plugins are skipped so operator-disabled plugins are never silently
re-enabled.
- **Alternatives considered:** granting tenant users install privileges
(widens secrets/adapters/settings access to solve a one-button problem);
a separate non-admin install route for bundled plugins (new authz
surface; provisioning removes the need for any install action at all).
- **Roadmap alignment:** supports the in-progress "Cloud deployments"
milestone and builds on the shipped sandbox-provider milestone in
`ROADMAP.md`.

Refs #10058.

## What Changed

- New `server/src/services/bundled-plugins.ts`: the bundled-plugin
catalog, the fail-to-start resolver (`resolveBundledPluginInstalls`,
positive allowlist + catalog-root containment with symlinks resolved),
and the fail-safe installer (`ensureBundledPlugins`).
- `server/src/app.ts`: replaces the hard-coded
`ensureBundledKubernetesPlugin` boot hook with resolver + installer
wiring, with test hooks (`managedPluginAutoInstall`,
`bundledPluginCatalogRoot` options).
- `server/src/index.ts`: passes `plugins.autoInstall` from the single
fail-closed `PAPERCLIP_MANAGED_CONFIG` startup parse (#10058) into
`createApp`; absent env means self-hosted and changes nothing.

## Verification

- 24 new tests in `server/src/__tests__/bundled-plugins.test.ts`
(catalog resolution, containment incl. symlink and `..` escapes,
skip/reinstall matrix, self-hosted invariants, installer error paths) —
all green.
- 85 adjacent startup/plugin-route/auto-build/managed-config tests green
(`managed-config`, `instance-settings-managed-overlay`,
`plugin-install-autobuild`, `plugin-routes-authz`,
`server-startup-feedback-export`).
- Server `tsc --noEmit` clean.

```bash
cd server
npx vitest run src/__tests__/bundled-plugins.test.ts
npx vitest run src/__tests__/managed-config.test.ts src/__tests__/instance-settings-managed-overlay.test.ts src/__tests__/plugin-install-autobuild.test.ts src/__tests__/plugin-routes-authz.test.ts src/__tests__/server-startup-feedback-export.test.ts
npx tsc --noEmit
```

## Risks

- Managed instances with a malformed or unknown `plugins.autoInstall`
entry now **refuse to start** (fail closed, by design) instead of
booting half-provisioned; harness misconfiguration surfaces as a precise
startup error.
- Self-hosted behavior is unchanged (kubernetes bundle only,
`PAPERCLIP_KUBERNETES_PLUGIN_PATH` honored without containment, install
failures log-and-continue), so the default deployment path carries low
risk.
- No uninstall path exists in this module; removal from the autoInstall
list can leave a previously provisioned plugin installed (intentional v1
semantics, documented in code).

## Model Used

Claude Fable 5 (`claude-fable-5`), extended thinking, agentic tool use;
independently peer-reviewed by a second AI agent before push.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [ ] All Paperclip CI gates are green
- [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-07-22 20:35:51 -07:00

298 lines
11 KiB
TypeScript

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, 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;
}
export interface BundledPluginProvisionerDeps {
registry: {
getByKey(pluginKey: string): Promise<RegistryPluginRow | null>;
};
loader: {
installPlugin(options: { localPath: string }): Promise<{
manifest: { id: string } | 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"));
}
/**
* 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<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)) {
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)",
);
}
}
}