mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-07 07:23:08 +02:00
624 lines
28 KiB
TypeScript
624 lines
28 KiB
TypeScript
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 the optional CreateOS sandbox provider without making it a self-hosted default", () => {
|
|
expect(resolveBundledPluginInstalls(["createos"], {
|
|
catalogRoot: CATALOG_ROOT,
|
|
env: {},
|
|
enforceCatalogRoot: true,
|
|
})).toEqual([{
|
|
key: "createos",
|
|
pluginKey: "paperclip.createos-sandbox-provider",
|
|
localPath: path.join(CATALOG_ROOT, "sandbox-providers/createos"),
|
|
}]);
|
|
expect(SELF_HOSTED_AUTO_INSTALL_KEYS).not.toContain("createos");
|
|
});
|
|
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)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
type LooseRow = {
|
|
id: string;
|
|
pluginKey: string;
|
|
status: string;
|
|
version?: string;
|
|
manifestJson?: Record<string, unknown>;
|
|
lastError?: string | null;
|
|
packagePath?: string | null;
|
|
};
|
|
|
|
// Build a minimal manifest for a persisted row or a shipped bundle. The reconcile
|
|
// step compares the bundle version with the persisted version.
|
|
function makeManifest(pluginKey: string, version: string) {
|
|
return { id: pluginKey, apiVersion: 1, version, capabilities: [] } as unknown as import("@paperclipai/shared").PaperclipPluginManifestV1;
|
|
}
|
|
|
|
function makeDeps(overrides?: {
|
|
rows?: Record<string, LooseRow | null>;
|
|
bundleManifestExists?: (localPath: string) => boolean;
|
|
installError?: Error;
|
|
/** Version the shipped bundle manifest reports, keyed by pluginKey. */
|
|
bundleVersionByKey?: Record<string, string>;
|
|
}) {
|
|
const rows = overrides?.rows ?? {};
|
|
const normalize = (row: LooseRow | null): (LooseRow & { version: string; manifestJson: Record<string, unknown> }) | null => {
|
|
if (!row) return null;
|
|
const version = row.version ?? "0.1.0";
|
|
return {
|
|
...row,
|
|
version,
|
|
manifestJson: row.manifestJson ?? { id: row.pluginKey, apiVersion: 1, version },
|
|
};
|
|
};
|
|
const installedRows = new Map(
|
|
Object.entries(rows).map(([key, row]) => [key, normalize(row)] as const),
|
|
);
|
|
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, normalize({ id: `id-${pluginKey}`, pluginKey, status: "installed" }));
|
|
return { manifest: { id: pluginKey } };
|
|
});
|
|
const update = vi.fn(async () => undefined);
|
|
const updateStatus = vi.fn(async () => undefined);
|
|
const loadManifest = vi.fn(async (localPath: string) => {
|
|
const entry = BUNDLED_PLUGIN_CATALOG.find((candidate) =>
|
|
localPath.endsWith(candidate.relativePath),
|
|
);
|
|
const pluginKey = entry?.pluginKey ?? "unknown";
|
|
const persisted = installedRows.get(pluginKey);
|
|
// Default: the bundle version matches the persisted version, so no
|
|
// reconcile fires unless a test overrides `bundleVersionByKey`.
|
|
const version = overrides?.bundleVersionByKey?.[pluginKey] ?? persisted?.version ?? "0.1.0";
|
|
return makeManifest(pluginKey, version);
|
|
});
|
|
const deps: BundledPluginProvisionerDeps = {
|
|
registry: {
|
|
getByKey: vi.fn(async (pluginKey: string) => installedRows.get(pluginKey) ?? null),
|
|
update,
|
|
updateStatus,
|
|
} as unknown as BundledPluginProvisionerDeps["registry"],
|
|
loader: { installPlugin, loadManifest } as unknown as BundledPluginProvisionerDeps["loader"],
|
|
lifecycle: { load: vi.fn(async () => undefined) },
|
|
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
|
|
bundleManifestExists: overrides?.bundleManifestExists ?? (() => true),
|
|
};
|
|
return { deps, installPlugin, update, updateStatus, loadManifest };
|
|
}
|
|
|
|
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", "upgrade_pending"]) {
|
|
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();
|
|
expect(deps.registry.updateStatus).not.toHaveBeenCalled();
|
|
}
|
|
});
|
|
|
|
it("resets a bundled plugin that a previous activation left in error back to ready, without reinstalling it", async () => {
|
|
const { deps, installPlugin, updateStatus } = makeDeps({
|
|
rows: {
|
|
[K8S.pluginKey]: {
|
|
id: "row-1",
|
|
pluginKey: K8S.pluginKey,
|
|
status: "error",
|
|
lastError: 'RPC call "initialize" timed out after 15000ms',
|
|
},
|
|
},
|
|
});
|
|
await ensureBundledPlugins([K8S], deps, { reinstallUninstalled: false });
|
|
expect(installPlugin).not.toHaveBeenCalled();
|
|
// No lifecycle call: the row goes straight back to `ready` with its error
|
|
// cleared, and the startup loadAll() does the activation (and emits the
|
|
// lifecycle events only once the worker actually started).
|
|
expect(deps.lifecycle.load).not.toHaveBeenCalled();
|
|
expect(updateStatus).toHaveBeenCalledTimes(1);
|
|
expect(updateStatus).toHaveBeenCalledWith("row-1", { status: "ready", lastError: null });
|
|
// The prior failure is surfaced at warn level with its recorded cause, so
|
|
// an operator reading boot logs sees why the plugin needed a retry.
|
|
expect(deps.logger.warn).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
pluginKey: K8S.pluginKey,
|
|
lastError: 'RPC call "initialize" timed out after 15000ms',
|
|
}),
|
|
expect.stringContaining("re-enabling"),
|
|
);
|
|
expect(deps.logger.error).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("continues boot when resetting an errored bundled plugin fails", async () => {
|
|
const { deps, installPlugin, updateStatus } = makeDeps({
|
|
rows: {
|
|
[K8S.pluginKey]: { id: "row-1", pluginKey: K8S.pluginKey, status: "error" },
|
|
},
|
|
});
|
|
updateStatus.mockRejectedValueOnce(new Error("db down"));
|
|
await expect(
|
|
ensureBundledPlugins([K8S, DAYTONA], deps, { reinstallUninstalled: true }),
|
|
).resolves.toBeUndefined();
|
|
expect(deps.logger.error).toHaveBeenCalledWith(
|
|
expect.objectContaining({ pluginKey: K8S.pluginKey }),
|
|
expect.stringContaining("continuing boot"),
|
|
);
|
|
// The later entry is still provisioned.
|
|
expect(installPlugin).toHaveBeenCalledWith({ localPath: DAYTONA.localPath });
|
|
});
|
|
|
|
it("reconciles the persisted manifest of a present plugin when the bundle version changed", async () => {
|
|
const { deps, installPlugin, update } = makeDeps({
|
|
rows: {
|
|
[DAYTONA.pluginKey]: {
|
|
id: "row-daytona",
|
|
pluginKey: DAYTONA.pluginKey,
|
|
status: "ready",
|
|
version: "0.1.1",
|
|
},
|
|
},
|
|
bundleVersionByKey: { [DAYTONA.pluginKey]: "0.1.2" },
|
|
});
|
|
await ensureBundledPlugins([DAYTONA], deps, { reinstallUninstalled: false });
|
|
// The plugin stays present, so it is never re-installed.
|
|
expect(installPlugin).not.toHaveBeenCalled();
|
|
// The persisted manifest is refreshed to the shipped bundle version, so a
|
|
// capability added to the bundle reaches the existing install.
|
|
expect(update).toHaveBeenCalledWith(
|
|
"row-daytona",
|
|
expect.objectContaining({ version: "0.1.2" }),
|
|
);
|
|
});
|
|
|
|
it("does not reconcile when the persisted version already matches the bundle", async () => {
|
|
const { deps, update } = makeDeps({
|
|
rows: {
|
|
[DAYTONA.pluginKey]: {
|
|
id: "row-daytona",
|
|
pluginKey: DAYTONA.pluginKey,
|
|
status: "ready",
|
|
version: "0.1.2",
|
|
},
|
|
},
|
|
bundleVersionByKey: { [DAYTONA.pluginKey]: "0.1.2" },
|
|
});
|
|
await ensureBundledPlugins([DAYTONA], deps, { reinstallUninstalled: false });
|
|
expect(update).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it.each([null, "/old/plugins/widget"])("adopts a selected distribution path from %s even at the same version", async (packagePath) => {
|
|
const localPath = path.join(CATALOG_ROOT, "distribution/widget");
|
|
const distribution = { key: "widget", pluginKey: "acme.widget", version: "0.1.0", directory: "widget", digest: `sha256:${"a".repeat(64)}`, localPath, entrypoints: { worker: "dist/worker.js" } };
|
|
for (const status of ["ready", "disabled", "error"]) {
|
|
const { deps, loadManifest, update, installPlugin, updateStatus } = makeDeps({
|
|
rows: { "acme.widget": { id: "row-widget", pluginKey: "acme.widget", status, version: "0.1.0", packagePath } },
|
|
// Distribution packages may declare a manifest outside dist/manifest.js.
|
|
bundleManifestExists: () => false,
|
|
});
|
|
const manifest = makeManifest("acme.widget", "0.1.0");
|
|
loadManifest.mockResolvedValue(manifest);
|
|
await ensureBundledPlugins([{ ...distribution, distribution }], deps, { reinstallUninstalled: true });
|
|
// Same row: retain config/state and operator-disabled status; only error
|
|
// gets the existing one-shot boot retry. No reinstall/capability reset.
|
|
expect(update).toHaveBeenCalledExactlyOnceWith("row-widget", { packagePath: localPath, version: "0.1.0", manifest });
|
|
expect(installPlugin).not.toHaveBeenCalled();
|
|
expect(updateStatus).toHaveBeenCalledTimes(status === "error" ? 1 : 0);
|
|
expect(loadManifest).toHaveBeenCalledExactlyOnceWith(localPath);
|
|
expect(deps.logger.error).not.toHaveBeenCalled();
|
|
}
|
|
});
|
|
|
|
it("does not rebind a distribution install whose new manifest fails validation", async () => {
|
|
const localPath = path.join(CATALOG_ROOT, "distribution/widget");
|
|
const distribution = { key: "widget", pluginKey: "acme.widget", version: "0.1.0", directory: "widget", digest: `sha256:${"a".repeat(64)}`, localPath, entrypoints: { worker: "dist/worker.js" } };
|
|
const { deps, loadManifest, update, updateStatus } = makeDeps({
|
|
rows: { "acme.widget": { id: "row-widget", pluginKey: "acme.widget", status: "error", packagePath: "/old/widget" } },
|
|
});
|
|
loadManifest.mockRejectedValue(new Error("Distribution manifest entrypoints do not match the verified package"));
|
|
await ensureBundledPlugins([{ ...distribution, distribution }], deps, { reinstallUninstalled: true });
|
|
expect(update).not.toHaveBeenCalled();
|
|
expect(updateStatus).not.toHaveBeenCalled();
|
|
expect(deps.logger.error).toHaveBeenCalled();
|
|
});
|
|
|
|
it.each(["ready", "error", "disabled"])("gates same-version distribution capability additions from %s atomically", async (status) => {
|
|
const localPath = path.join(CATALOG_ROOT, "distribution/widget");
|
|
const distribution = { key: "widget", pluginKey: "acme.widget", version: "0.1.0", directory: "widget", digest: `sha256:${"a".repeat(64)}`, localPath, entrypoints: { worker: "dist/worker.js" } };
|
|
const oldManifest = makeManifest("acme.widget", "0.1.0");
|
|
const { deps, loadManifest, update, updateStatus, installPlugin } = makeDeps({
|
|
rows: { "acme.widget": { id: "row-widget", pluginKey: "acme.widget", status, packagePath: localPath, manifestJson: { ...oldManifest } } },
|
|
});
|
|
const replacement = { ...oldManifest, capabilities: ["issues.read" as const] };
|
|
loadManifest.mockResolvedValue(replacement);
|
|
await ensureBundledPlugins([{ ...distribution, distribution }], deps, { reinstallUninstalled: true });
|
|
expect(update).toHaveBeenCalledExactlyOnceWith("row-widget", { version: "0.1.0", manifest: replacement, status: "upgrade_pending" });
|
|
expect(updateStatus).not.toHaveBeenCalled();
|
|
expect(installPlugin).not.toHaveBeenCalled();
|
|
expect(deps.lifecycle.load).not.toHaveBeenCalled();
|
|
|
|
// A later boot must leave the approval gate in place.
|
|
vi.mocked(deps.registry.getByKey).mockResolvedValue({ id: "row-widget", pluginKey: "acme.widget", status: "upgrade_pending", version: "0.1.0", packagePath: localPath, manifestJson: replacement });
|
|
await ensureBundledPlugins([{ ...distribution, distribution }], deps, { reinstallUninstalled: true });
|
|
expect(update).toHaveBeenCalledTimes(1);
|
|
expect(updateStatus).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("does not save an inconsistent distribution manifest", async () => {
|
|
const localPath = path.join(CATALOG_ROOT, "distribution/widget");
|
|
const distribution = { key: "widget", pluginKey: "acme.widget", version: "0.1.0", directory: "widget", digest: `sha256:${"a".repeat(64)}`, localPath, entrypoints: { worker: "dist/worker.js" } };
|
|
const { deps, loadManifest, update } = makeDeps({ rows: { "acme.widget": { id: "row-widget", pluginKey: "acme.widget", status: "ready", packagePath: localPath } } });
|
|
const manifest = makeManifest("acme.widget", "0.1.0");
|
|
manifest.ui = { slots: [{ type: "appShellOverlay", id: "overlay", displayName: "Overlay", exportName: "Overlay" }] };
|
|
loadManifest.mockResolvedValue(manifest);
|
|
await ensureBundledPlugins([{ ...distribution, distribution }], deps, { reinstallUninstalled: true });
|
|
expect(update).not.toHaveBeenCalled();
|
|
expect(deps.logger.error).toHaveBeenCalled();
|
|
});
|
|
|
|
it("does not replace an operator uninstall with a distribution approval request", async () => {
|
|
const localPath = path.join(CATALOG_ROOT, "distribution/widget");
|
|
const distribution = { key: "widget", pluginKey: "acme.widget", version: "0.1.0", directory: "widget", digest: `sha256:${"a".repeat(64)}`, localPath, entrypoints: { worker: "dist/worker.js" } };
|
|
const { deps, loadManifest, update, updateStatus, installPlugin } = makeDeps({ rows: { "acme.widget": { id: "row-widget", pluginKey: "acme.widget", status: "uninstalled" } } });
|
|
loadManifest.mockResolvedValue({ ...makeManifest("acme.widget", "0.1.0"), capabilities: ["issues.read"] });
|
|
await ensureBundledPlugins([{ ...distribution, distribution }], deps, { reinstallUninstalled: false });
|
|
expect(update).not.toHaveBeenCalled();
|
|
expect(updateStatus).not.toHaveBeenCalled();
|
|
expect(installPlugin).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("refreshes a same-version rollback while retaining the pending operator decision", async () => {
|
|
const localPath = path.join(CATALOG_ROOT, "distribution/widget");
|
|
const distribution = { key: "widget", pluginKey: "acme.widget", version: "0.1.0", directory: "widget", digest: `sha256:${"a".repeat(64)}`, localPath, entrypoints: { worker: "dist/worker.js" } };
|
|
const pending = { ...makeManifest("acme.widget", "0.1.0"), capabilities: ["issues.read", "issues.update"] };
|
|
const { deps, loadManifest, update, updateStatus } = makeDeps({ rows: { "acme.widget": { id: "row-widget", pluginKey: "acme.widget", status: "upgrade_pending", packagePath: localPath, manifestJson: pending } } });
|
|
// A smaller capability set is not proof that every remaining capability
|
|
// was approved, or that the plugin was enabled before the pending upgrade.
|
|
const rollback = { ...makeManifest("acme.widget", "0.1.0"), capabilities: ["issues.read" as const] };
|
|
loadManifest.mockResolvedValue(rollback);
|
|
await ensureBundledPlugins([{ ...distribution, distribution }], deps, { reinstallUninstalled: true });
|
|
expect(update).toHaveBeenCalledExactlyOnceWith("row-widget", { version: "0.1.0", manifest: rollback });
|
|
expect(updateStatus).not.toHaveBeenCalled();
|
|
expect(deps.lifecycle.load).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("ignores object key order when comparing a distribution manifest read from JSONB", async () => {
|
|
const localPath = path.join(CATALOG_ROOT, "distribution/widget");
|
|
const distribution = { key: "widget", pluginKey: "acme.widget", version: "0.1.0", directory: "widget", digest: `sha256:${"a".repeat(64)}`, localPath, entrypoints: { worker: "dist/worker.js" } };
|
|
const manifest = { ...makeManifest("acme.widget", "0.1.0"), entrypoints: { worker: "dist/worker.js", ui: "dist/ui" } };
|
|
const reordered = { entrypoints: { ui: "dist/ui", worker: "dist/worker.js" }, capabilities: [], version: "0.1.0", apiVersion: 1, id: "acme.widget" };
|
|
const { deps, loadManifest, update } = makeDeps({ rows: { "acme.widget": { id: "row-widget", pluginKey: "acme.widget", status: "ready", packagePath: localPath, manifestJson: reordered } } });
|
|
loadManifest.mockResolvedValue(manifest);
|
|
await ensureBundledPlugins([{ ...distribution, distribution }], deps, { reinstallUninstalled: true });
|
|
expect(update).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("swallows a reconcile error and continues boot", async () => {
|
|
const { deps, update } = makeDeps({
|
|
rows: {
|
|
[DAYTONA.pluginKey]: {
|
|
id: "row-daytona",
|
|
pluginKey: DAYTONA.pluginKey,
|
|
status: "ready",
|
|
version: "0.1.1",
|
|
},
|
|
},
|
|
bundleVersionByKey: { [DAYTONA.pluginKey]: "0.1.2" },
|
|
});
|
|
(update as ReturnType<typeof vi.fn>).mockRejectedValueOnce(new Error("db down"));
|
|
await expect(
|
|
ensureBundledPlugins([DAYTONA], deps, { reinstallUninstalled: false }),
|
|
).resolves.toBeUndefined();
|
|
expect(deps.logger.error).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();
|
|
});
|
|
});
|