Files
PaperClipAI/server/src/__tests__/plugin-loader-error-retry.test.ts
T
Devin FoleyandPaperclip b70641f23f feat(plugins): support image catalogs and persistent application overlays (#13646)
## 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>
2026-09-19 09:18:10 -07:00

211 lines
8.8 KiB
TypeScript

/**
* loadAll boot retry for plugins stranded in error status.
*
* An activation failure marks a plugin `error`, and the loader used to skip
* those rows on every later boot — the row stayed dead until an operator
* flipped it back to `ready` by hand, even when the underlying cause (missing
* package dependencies, a stale build output) had long been fixed on disk.
* loadAll now queues errored plugins for one retry per boot: it flips each row
* to `ready` first (the error status cannot legally re-enter `error`, so a
* failed retry could not re-mark itself otherwise) and then activates it like
* any ready plugin. A retry that fails re-records the error through markError.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { Db } from "@paperclipai/db";
import { mkdtempSync, mkdirSync, writeFileSync, rmSync, realpathSync } from "node:fs";
import os from "node:os";
import path from "node:path";
import { distributionBundleDigest, distributionPluginActivationGuard, readDistributionPluginCatalog } from "../services/distribution-plugin-catalog.js";
const mockRegistry = vi.hoisted(() => ({
getById: vi.fn(),
getByKey: vi.fn(),
list: vi.fn(),
listInstalled: vi.fn(),
listByStatus: vi.fn(),
update: vi.fn(),
updateStatus: vi.fn(),
upsertConfig: vi.fn(),
getConfig: vi.fn(),
delete: vi.fn(),
}));
vi.mock("../services/plugin-registry.js", () => ({
pluginRegistryService: () => mockRegistry,
}));
import { pluginLoader } from "../services/plugin-loader.js";
import type { PluginRuntimeServices } from "../services/plugin-loader.js";
function createPluginRecord(overrides: Record<string, unknown> = {}) {
return {
id: "plugin-err-1",
pluginKey: "example.broken-plugin",
packageName: "@example/broken-plugin",
packagePath: "/nonexistent/broken-plugin",
version: "1.0.0",
apiVersion: 1,
categories: [],
status: "error",
lastError: "Activation failed: previous boot failure",
installOrder: 1,
manifestJson: {
id: "example.broken-plugin",
apiVersion: 1,
version: "1.0.0",
displayName: "Broken Plugin",
description: "Fixture",
author: "Test",
categories: [],
capabilities: [],
entrypoints: { worker: "dist/worker.js" },
},
...overrides,
};
}
function createRuntimeServices() {
return {
lifecycleManager: {
markError: vi.fn(async () => createPluginRecord()),
},
workerManager: {},
eventBus: {},
jobScheduler: {},
jobStore: {},
toolDispatcher: {},
buildHostHandlers: vi.fn(() => ({})),
instanceInfo: { hostVersion: "0.0.0-test" },
} as unknown as PluginRuntimeServices;
}
function createLoader(runtimeServices: PluginRuntimeServices) {
return pluginLoader(
{} as unknown as Db,
{ localPluginDir: "/nonexistent/local-plugins", enableLocalFilesystem: false, enableNpmDiscovery: false },
runtimeServices,
);
}
describe("pluginLoader.loadAll error retry", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("flips an errored plugin to ready and retries its activation at boot", async () => {
const erroredPlugin = createPluginRecord();
mockRegistry.listByStatus.mockImplementation(async (status: string) => {
if (status === "error") return [erroredPlugin];
return [];
});
mockRegistry.updateStatus.mockResolvedValue({ ...erroredPlugin, status: "ready", lastError: null });
const runtimeServices = createRuntimeServices();
const loader = createLoader(runtimeServices);
const result = await loader.loadAll();
// The flip precedes activation, and clears the stale error.
expect(mockRegistry.updateStatus).toHaveBeenCalledExactlyOnceWith(erroredPlugin.id, { status: "ready" });
// The retried plugin joins the boot batch; its package is unresolvable, so
// the attempt fails and re-records a fresh error via markError.
expect(result.total).toBe(1);
expect(result.succeeded).toBe(0);
expect(result.failed).toBe(1);
expect(runtimeServices.lifecycleManager.markError).toHaveBeenCalledWith(
erroredPlugin.id,
expect.stringContaining("Activation failed"),
);
});
it("keeps loading ready plugins when the errored flip fails", async () => {
const readyPlugin = createPluginRecord({
id: "plugin-ready-1",
pluginKey: "example.ready-plugin",
status: "ready",
lastError: null,
});
const erroredPlugin = createPluginRecord();
mockRegistry.listByStatus.mockImplementation(async (status: string) => {
if (status === "ready") return [readyPlugin];
if (status === "error") return [erroredPlugin];
return [];
});
mockRegistry.updateStatus.mockRejectedValue(new Error("db write refused"));
const loader = createLoader(createRuntimeServices());
const result = await loader.loadAll();
// The failed flip skips the retry but never aborts the boot load.
expect(mockRegistry.updateStatus).toHaveBeenCalledExactlyOnceWith(erroredPlugin.id, { status: "ready" });
expect(result.total).toBe(1);
expect(result.results[0]?.plugin.id).toBe(readyPlugin.id);
});
it("returns the empty result when no plugin is ready or errored", async () => {
mockRegistry.listByStatus.mockResolvedValue([]);
const loader = createLoader(createRuntimeServices());
const result = await loader.loadAll();
expect(result).toEqual({ total: 0, succeeded: 0, failed: 0, results: [] });
});
it("rejects distribution capability escalation before saving a runtime refresh or starting a worker", async () => {
const root = realpathSync(mkdtempSync(path.join(os.tmpdir(), "distribution-refresh-")));
try {
const packageRoot = path.join(root, "distribution", "example");
mkdirSync(path.join(packageRoot, "dist"), { recursive: true });
const plugin = createPluginRecord({ status: "ready", packagePath: packageRoot });
const replacement = { ...plugin.manifestJson, categories: ["ui"], capabilities: ["issues.read"] };
writeFileSync(path.join(packageRoot, "package.json"), JSON.stringify({ name: plugin.packageName, version: "1.0.0", type: "module", paperclipPlugin: { manifest: "dist/manifest.js", worker: "dist/worker.js" } }));
writeFileSync(path.join(packageRoot, "dist/manifest.js"), `export default ${JSON.stringify(replacement)};`);
writeFileSync(path.join(packageRoot, "dist/worker.js"), "throw new Error('unapproved worker must not start');");
writeFileSync(path.join(root, "distribution/catalog.json"), JSON.stringify({ schemaVersion: 1, plugins: [{ key: "example", pluginKey: plugin.pluginKey, version: "1.0.0", directory: "example", digest: distributionBundleDigest(packageRoot) }] }));
const runtime = createRuntimeServices();
const startWorker = vi.fn();
runtime.workerManager.startWorker = startWorker;
mockRegistry.getById.mockResolvedValue(plugin);
const loader = pluginLoader({} as Db, {
localPluginDir: root,
assertPackageActivation: distributionPluginActivationGuard(root, readDistributionPluginCatalog(root, []), ["example"]),
}, runtime);
const result = await loader.loadSingle(plugin.id);
expect(result.success).toBe(false);
expect(result.error).toContain("capabilities require approval: issues.read");
expect(mockRegistry.update).not.toHaveBeenCalled();
expect(startWorker).not.toHaveBeenCalled();
} finally {
rmSync(root, { recursive: true, force: true });
}
});
it("does not import an npm fallback for a removed distribution install", async () => {
const root = realpathSync(mkdtempSync(path.join(os.tmpdir(), "distribution-removal-")));
try {
const packageRoot = path.join(root, "node_modules/@example/broken-plugin");
mkdirSync(packageRoot, { recursive: true });
const plugin = createPluginRecord({ status: "ready", packagePath: path.join(root, "distribution/removed") });
writeFileSync(path.join(packageRoot, "package.json"), JSON.stringify({ name: plugin.packageName, type: "module", paperclipPlugin: { manifest: "manifest.js" } }));
writeFileSync(path.join(packageRoot, "manifest.js"), "throw new Error('fallback manifest must never import');");
const runtime = createRuntimeServices();
const startWorker = vi.fn();
runtime.workerManager.startWorker = startWorker;
mockRegistry.getById.mockResolvedValue(plugin);
const loader = pluginLoader({} as Db, {
localPluginDir: root,
assertPackageActivation: distributionPluginActivationGuard(root, [], []),
}, runtime);
const result = await loader.loadSingle(plugin.id);
expect(result.success).toBe(false);
expect(result.error).toContain("Distribution plugin is absent or not selected");
expect(mockRegistry.update).not.toHaveBeenCalled();
expect(startWorker).not.toHaveBeenCalled();
} finally {
rmSync(root, { recursive: true, force: true });
}
});
});