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>
This commit is contained in:
parent
216d3d2680
commit
c4cdcc4826
|
|
@ -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<string, { id: string; pluginKey: string; status: string } | null>;
|
||||
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<typeof vi.fn>).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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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<BetterAuthSessionResult | null>;
|
||||
/**
|
||||
* `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<void> => {
|
||||
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;
|
||||
|
|
|
|||
|
|
@ -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<StartedServer> {
|
|||
// 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<StartedServer> {
|
|||
}
|
||||
};
|
||||
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<StartedServer> {
|
|||
betterAuthHandler,
|
||||
resolveSession,
|
||||
pluginWorkerManager,
|
||||
managedPluginAutoInstall,
|
||||
});
|
||||
const server = createServer(app as unknown as Parameters<typeof createServer>[0]);
|
||||
|
||||
|
|
|
|||
|
|
@ -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, 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)",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue