mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-19 10:36:26 +02:00
## Thinking Path
> - Paperclip is the open source app people use to manage AI agents for
work.
> - Plugins extend the application without adding each integration to
Core.
> - A downstream image needs a way to supply prebuilt plugins.
> - Some plugin UI must stay mounted as users move between pages.
> - This change adds an image catalog and a persistent application slot.
> - Operators can upgrade or remove these plugins through their image
and configuration.
## Linked Issues or Issue Description
**Subsystem affected**
Plugin packaging, activation and application UI.
**Problem or motivation**
The built-in plugin catalog is fixed in Core source. Downstream images
cannot add entries through an explicit catalog. Existing page slots also
cannot preserve a small application overlay across route changes.
**Proposed solution**
Read a bounded catalog of prebuilt plugins from the image. Verify its
files before importing manifests. Use the existing managed selection and
plugin lifecycle. Add an `appShellOverlay` slot with account and company
cleanup.
**Alternatives considered**
A downstream fork adds merge work. Script injection provides no plugin
lifecycle. A separate runtime download system adds a second distribution
channel.
**Roadmap alignment**
This extends the existing plugin system. Related PR #9006 covers runtime
install replication; this change covers immutable image contents. PR
#12555 covers CLI scaffolding. Neither provides this catalog or
application slot. The maintainer requested this work directly.
## What Changed
- Validate catalog identities, confined paths, package versions and
bundle hashes before importing code.
- Apply image selection to persisted plugin installs, including removal
and rollback. Adopt the verified image path from existing npm/local
installs and bind runtime worker/UI entrypoints to verified package
declarations.
- Mount application overlays in both UI shells. Preserve route state and
clear it on account, company and onboarding changes.
- Restrict service-worker offline storage/fallback to hashed public
assets in a separate cache namespace; exclude application HTML and
extension/API data, including after worker restart.
- Document the packaging contract, trust model and rollback
requirements.
## Verification
- Passed `pnpm -r typecheck`, `pnpm build`, and `pnpm
check:token-gates`. Affected server/UI typechecks and builds, plus token
gates, passed again after rebasing onto current master; the 124 focused
tests also passed after rebase.
- Latest focused verification: 124 tests in nine files passed for
catalog/reconciliation/loader, overlay lifecycle, Layout and
service-worker policy. The broader UI/shared/SDK run passed 7,204 tests
in 690 files with canonical `TMPDIR`.
- Real disposable Core/PostgreSQL: catalog install, selection removal,
0.1.0→0.1.1→0.1.0, same-version npm/legacy-path adoption, and
preservation of disabled status passed. Added permissions entered
`upgrade_pending`, withheld UI across restart, and activated only after
explicit operator enable.
- Real Chromium: desktop/mobile layout, route draft retention and
Escape/focus passed with mocked extension responses. A persistent
browser restart retained public hashed-asset offline fallback while
refusing seeded legacy/current private entries and legacy HTML.
- Full `pnpm test:run`: 12,539 passed; 17 failed across six existing
files, stopping later phases. macOS read-only directory renames fail in
runtime-skill-cache and company-skills-service; email tests require an
absent local AgentMail fixture. Native runner/comment-redaction passed
in isolation after temporary Rust setup; agent-conversations also passed
in isolation. No unrelated source was changed to hide failures.
- After rebase, two unchanged chat timing tests failed in CI and passed
locally in isolation. Their CI shard passed on its single retry. All
other current-head CI jobs passed on the initial run; review is 5/5 with
no unresolved threads.
- No live deployment or external plugin service was used.
## Risks
- Plugins are trusted code. The catalog detects packaging errors; it
does not authenticate an untrusted image builder.
- Invalid catalogs fail startup. Images must contain the catalog and
bundles together, with stable directories.
- A host older than this contract lacks the activation guard. Disable
added plugins and remove their configuration keys before reverting to
it.
- Offline navigation now returns 503 instead of replaying cached
application HTML. Only public build assets have offline fallback.
- Rolling back an unapproved permission change retains the approval
gate; review the current manifest and explicitly enable it. A reduced
permission set cannot establish prior approval or prior enabled status.
- Plugin data migrations need their own rollback policy. This change
retains installed records and does not reverse migrations.
## Model Used
- OpenAI GPT-6 (Codex), model ID `gpt-6`, with repository inspection,
code execution and browser verification. The runtime does not expose an
exact context-window size.
## 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 (relevant suites; broad
macOS server-run exceptions are documented above)
- [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
- [x] All Paperclip CI gates are green (fresh run on 488b3754ae; chat
shard passed its single retry)
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
(fresh review on 488b3754ae)
- [x] I will address all Greptile and reviewer comments before
requesting merge
---------
Co-authored-by: Paperclip <noreply@paperclip.ing>
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();
|
|
});
|
|
});
|