mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-13 04:02:28 +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>
211 lines
8.8 KiB
TypeScript
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 });
|
|
}
|
|
});
|
|
});
|