mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-20 15:09:57 +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>
117 lines
8.7 KiB
TypeScript
117 lines
8.7 KiB
TypeScript
import { mkdtempSync, mkdirSync, rmSync, writeFileSync, symlinkSync, realpathSync } from "node:fs";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import { afterEach, describe, expect, it } from "vitest";
|
|
import type { PaperclipPluginManifestV1 } from "@paperclipai/shared";
|
|
import { distributionBundleDigest, distributionPluginActivationGuard, readDistributionPluginCatalog } from "../services/distribution-plugin-catalog.js";
|
|
import { BUNDLED_PLUGIN_CATALOG, resolveBundledPluginInstalls } from "../services/bundled-plugins.js";
|
|
|
|
const roots: string[] = [];
|
|
afterEach(() => { for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); });
|
|
function fixture() {
|
|
const root = realpathSync(mkdtempSync(path.join(os.tmpdir(), "distribution-plugin-"))); roots.push(root);
|
|
const localPath = path.join(root, "distribution", "acme.widget");
|
|
mkdirSync(path.join(localPath, "dist", "ui"), { recursive: true });
|
|
writeFileSync(path.join(localPath, "package.json"), JSON.stringify({ name: "@acme/plugin-widget", version: "1.0.0", paperclipPlugin: { manifest: "./dist/manifest.js", worker: "./dist/worker.js", ui: "./dist/ui/" } }));
|
|
writeFileSync(path.join(localPath, "dist", "manifest.js"), "export default {};");
|
|
writeFileSync(path.join(localPath, "dist", "worker.js"), "export default {};");
|
|
const entry = { key: "acme.widget", pluginKey: "acme.widget", version: "1.0.0", directory: "acme.widget", digest: distributionBundleDigest(localPath) };
|
|
const save = (plugins: unknown[] = [entry]) => writeFileSync(path.join(root, "distribution", "catalog.json"), JSON.stringify({ schemaVersion: 1, plugins }));
|
|
save(); return { root, localPath, entry, save };
|
|
}
|
|
describe("image-owned plugin catalogs", () => {
|
|
it("preserves images without a distribution catalog", () => {
|
|
expect(readDistributionPluginCatalog("/nonexistent/catalog", BUNDLED_PLUGIN_CATALOG)).toEqual([]);
|
|
});
|
|
it("resolves selected private bundles alongside built-ins after verifying their bytes", () => {
|
|
const { root, localPath } = fixture();
|
|
const installs = resolveBundledPluginInstalls(["daytona", "acme.widget"], { catalogRoot: root, env: {}, enforceCatalogRoot: true });
|
|
expect(installs.map(({ key }) => key)).toEqual(["daytona", "acme.widget"]);
|
|
expect(installs[1]?.localPath).toBe(localPath);
|
|
});
|
|
it("rejects tampered bundle bytes before importing manifest code", () => {
|
|
const { root, localPath } = fixture();
|
|
writeFileSync(path.join(localPath, "dist", "manifest.js"), "throw new Error('must never execute');");
|
|
expect(() => readDistributionPluginCatalog(root, BUNDLED_PLUGIN_CATALOG)).toThrow(/digest mismatch/);
|
|
});
|
|
it("rejects duplicate keys, plugin identities and built-in replacement", () => {
|
|
const { root, entry, save } = fixture();
|
|
for (const plugins of [[entry, entry], [{ ...entry, key: "daytona" }], [{ ...entry, pluginKey: "paperclip.daytona-sandbox-provider" }]]) {
|
|
save(plugins);
|
|
expect(() => readDistributionPluginCatalog(root, BUNDLED_PLUGIN_CATALOG)).toThrow(/Duplicate or built-in/);
|
|
}
|
|
});
|
|
it("rejects traversal, symlinked artifacts and unbuilt entrypoints", () => {
|
|
const { root, entry, localPath, save } = fixture();
|
|
save([{ ...entry, directory: "../elsewhere" }]);
|
|
expect(() => readDistributionPluginCatalog(root, BUNDLED_PLUGIN_CATALOG)).toThrow();
|
|
save(); symlinkSync(os.tmpdir(), path.join(localPath, "outside"));
|
|
expect(() => readDistributionPluginCatalog(root, BUNDLED_PLUGIN_CATALOG)).toThrow(/symlinks/);
|
|
rmSync(path.join(localPath, "outside")); rmSync(path.join(localPath, "dist", "worker.js"));
|
|
save([{ ...entry, digest: distributionBundleDigest(localPath) }]);
|
|
expect(() => readDistributionPluginCatalog(root, BUNDLED_PLUGIN_CATALOG)).toThrow();
|
|
});
|
|
it("blocks a persisted plugin after deselection/removal while preserving ordinary plugins", () => {
|
|
const { root, localPath } = fixture();
|
|
const entries = readDistributionPluginCatalog(root, BUNDLED_PLUGIN_CATALOG);
|
|
const input = { pluginKey: "acme.widget", packageRoot: localPath };
|
|
expect(() => distributionPluginActivationGuard(root, entries, ["acme.widget"])(input)).not.toThrow();
|
|
expect(() => distributionPluginActivationGuard(root, entries, ["acme.widget"])({ packageRoot: localPath })).not.toThrow();
|
|
expect(() => distributionPluginActivationGuard(root, entries, [])({ packageRoot: localPath })).toThrow(/not selected/);
|
|
expect(() => distributionPluginActivationGuard(root, entries, [])(input)).toThrow(/not selected/);
|
|
expect(() => distributionPluginActivationGuard(root, [], [])(input)).toThrow(/absent/);
|
|
expect(() => distributionPluginActivationGuard(root, [], [])({ ...input, packageRoot: path.join(root, "node_modules/acme"), installedPackagePath: localPath })).toThrow(/absent/);
|
|
expect(() => distributionPluginActivationGuard(root, [], [])({ pluginKey: "ordinary.plugin", packageRoot: path.join(root, "ordinary") })).not.toThrow();
|
|
});
|
|
it("rejects dangling catalog symlinks instead of treating them as absent", () => {
|
|
const { root } = fixture();
|
|
const file = path.join(root, "distribution", "catalog.json");
|
|
rmSync(file); symlinkSync(path.join(root, "missing"), file);
|
|
expect(() => readDistributionPluginCatalog(root, BUNDLED_PLUGIN_CATALOG)).toThrow(/Invalid distribution catalog/);
|
|
});
|
|
it("requires catalog identity and version before activating imported manifests", () => {
|
|
const { root, localPath } = fixture();
|
|
const entries = readDistributionPluginCatalog(root, BUNDLED_PLUGIN_CATALOG);
|
|
const guard = distributionPluginActivationGuard(root, entries, ["acme.widget"]);
|
|
for (const manifest of [{ id: "other.plugin", version: "1.0.0" }, { id: "acme.widget", version: "2.0.0" }]) {
|
|
expect(() => guard({ pluginKey: "acme.widget", packageRoot: localPath, manifest: manifest as PaperclipPluginManifestV1 })).toThrow(/identity\/version/);
|
|
}
|
|
});
|
|
|
|
it("binds runtime worker and UI entrypoints to the digest-verified package declarations", () => {
|
|
const { root, localPath } = fixture();
|
|
const entries = readDistributionPluginCatalog(root, BUNDLED_PLUGIN_CATALOG);
|
|
const guard = distributionPluginActivationGuard(root, entries, ["acme.widget"]);
|
|
const manifest = { id: "acme.widget", version: "1.0.0", capabilities: [], entrypoints: { worker: "dist/worker.js", ui: "./dist/ui" } } as unknown as PaperclipPluginManifestV1;
|
|
expect(() => guard({ packageRoot: localPath, manifest })).not.toThrow();
|
|
for (const name of ["worker", "ui"] as const) {
|
|
for (const value of ["/outside/worker.js", "../outside", "dist/../worker.js", "C:/outside", "dist\\worker.js", "dist/other", "", undefined]) {
|
|
const invalid = { ...manifest, entrypoints: { ...manifest.entrypoints, [name]: value } } as PaperclipPluginManifestV1;
|
|
expect(() => guard({ packageRoot: localPath, manifest: invalid })).toThrow(/entrypoint/);
|
|
}
|
|
}
|
|
});
|
|
|
|
it("accepts worker-only bundles but rejects a UI path absent from verified metadata", () => {
|
|
const { root, localPath, entry, save } = fixture();
|
|
writeFileSync(path.join(localPath, "package.json"), JSON.stringify({ version: "1.0.0", paperclipPlugin: { manifest: "./dist/manifest.js", worker: "./dist/worker.js" } }));
|
|
save([{ ...entry, digest: distributionBundleDigest(localPath) }]);
|
|
const guard = distributionPluginActivationGuard(root, readDistributionPluginCatalog(root, BUNDLED_PLUGIN_CATALOG), null);
|
|
const manifest = { id: "acme.widget", version: "1.0.0", capabilities: [], entrypoints: { worker: "./dist/worker.js" } } as unknown as PaperclipPluginManifestV1;
|
|
expect(() => guard({ packageRoot: localPath, manifest })).not.toThrow();
|
|
manifest.entrypoints.ui = "./dist/ui";
|
|
expect(() => guard({ packageRoot: localPath, manifest })).toThrow(/verified package/);
|
|
});
|
|
|
|
it("rejects inconsistent capabilities and unapproved runtime refreshes", () => {
|
|
const { root, localPath } = fixture();
|
|
const guard = distributionPluginActivationGuard(root, readDistributionPluginCatalog(root, BUNDLED_PLUGIN_CATALOG), null);
|
|
const previousManifest = { id: "acme.widget", version: "1.0.0", capabilities: [], entrypoints: { worker: "./dist/worker.js", ui: "./dist/ui" } } as unknown as PaperclipPluginManifestV1;
|
|
const manifest = { ...previousManifest, capabilities: ["issues.read" as const] };
|
|
expect(() => guard({ packageRoot: localPath, manifest, previousManifest })).toThrow(/require approval/);
|
|
expect(() => guard({ packageRoot: localPath, manifest, previousManifest: manifest })).not.toThrow();
|
|
previousManifest.ui = { slots: [{ type: "appShellOverlay", id: "overlay", displayName: "Overlay", exportName: "Overlay" }] };
|
|
expect(() => guard({ packageRoot: localPath, manifest: previousManifest })).toThrow(/missing required capabilities: ui.action.register/);
|
|
});
|
|
});
|