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>
This commit is contained in:
Devin Foley
2026-09-19 09:18:10 -07:00
committed by GitHub
co-authored by Paperclip
parent 64895f187b
commit b70641f23f
24 changed files with 961 additions and 32 deletions
+111
View File
@@ -0,0 +1,111 @@
# Plugins supplied by an application distribution
A downstream image can add prebuilt plugins without changing Paperclip's
built-in catalog. The operator owns the image and trusts its plugin code.
This is packaging and activation policy, not a sandbox or entitlement system.
Ordinary self-hosted images need no catalog and keep their existing behavior.
## Image layout
Use `distribution/catalog.json` beneath `PAPERCLIP_BUNDLED_PLUGIN_ROOT`
(default `/app/packages/plugins`). Each plugin has a stable directory below
`distribution/`, containing its `package.json`, compiled manifest, worker and
optional UI. Bundle runtime dependencies; startup never installs them.
```json
{
"schemaVersion": 1,
"plugins": [{
"key": "example-extension",
"pluginKey": "example.extension",
"version": "1.0.0",
"directory": "example-extension",
"digest": "sha256:<64 lowercase hex characters>"
}]
}
```
The digest covers a sorted depth-first file inventory. Each entry is
`[relativePosixPath, "sha256:" + sha256(fileBytes), permissionBits & 0777]`.
Hash the UTF-8 JSON serialization of the inventory and prefix it with
`sha256:`. The server's `distributionBundleDigest` implements this contract.
There are no symbolic links or special files. A bundle is limited to 10,000
files, 256 MiB and 32 directory levels. Catalog keys, plugin IDs and directory
names must be unique; a distribution cannot replace a built-in key or ID.
On startup, the host validates the catalog, hashes the bundle before importing
its executable manifest, and validates package version and confined prebuilt
entrypoints. Malformed catalogs and integrity failures stop startup. Deploy
the catalog and bundles atomically as part of the image; keep them read-only
in operation. This detects packaging errors but does not authenticate an
untrusted image builder. Image provenance and signatures remain deployment
responsibilities.
## Selection, upgrades and rollback
Managed instances select a distribution key through the existing
`plugins.autoInstall` list. Existing install, capability validation, API
compatibility, worker and health mechanisms apply. A worker or install failure
is recorded as a plugin error without taking down the application.
The catalog alone does not auto-enable plugins on self-hosted instances.
Operators can explicitly install catalog entries through the normal plugin
CLI. The package's manifest ID and version must match the catalog.
The manifest's worker and optional UI entrypoints must match the verified
`package.json` declarations and stay inside the bundle.
At boot, selected distribution entries adopt the current image's package path
even when a previous npm or local install has the same version. Reconciliation
keeps the registry ID, configuration and stored state. With unchanged permissions,
operator-disabled status is retained. A replacement that adds capabilities is
saved atomically in `upgrade_pending`, even for same-version bundles. It cannot
activate until an operator reviews the manifest and enables it through the normal
plugin lifecycle. Invalid capability declarations are rejected before persistence.
Runtime refreshes also reject unapproved capability additions before starting code.
Rolling back an unapproved replacement refreshes the displayed manifest but
retains `upgrade_pending`. Review the rollback manifest and explicitly enable it
to resume. A smaller capability set alone cannot prove prior approval: it may
retain an unapproved permission, and the plugin may originally have been disabled.
Ordinary upgrades/downgrades of an approved, ready plugin continue automatically.
Keep each key's directory stable across releases. The activation guard also
covers persisted installs: a plugin removed from the image catalog, or no
longer selected in managed configuration, cannot activate on restart. Its
stored image path remains the source marker if the directory disappears; package
resolution cannot substitute an npm copy. Keep the catalog root stable as well.
An explicit operator reinstall changes a package's source; editing database rows
or replacing the catalog root is outside this image-selection contract. Plugin
database records remain for rollback. Plugin data migrations must themselves
support the intended rollback window; removing a bundle does not undo them.
A deployment controller must generate `plugins.autoInstall` from the **target
image's** catalog. A union of catalogs from different releases is insufficient:
an older image rejects a key it does not know. Before reverting to a host
version that predates this catalog contract, disable the distribution plugins
and remove their keys from configuration. Such older hosts do not have the
new activation guard.
## Persistent application UI
The `appShellOverlay` slot requires `ui.action.register`. It receives the usual
`PluginWidgetProps` context. It mounts once in both application shells and
survives route navigation. It is disposed when the account or selected company
changes, during onboarding, and on sign-out. It is not mounted on login pages.
Local-trusted mode has no login requirement: its sessionless board may mount
overlays, but transitions to or from an account still dispose the prior state.
The host positions contributions above the mobile navigation and stacks them
at the bottom right. Each plugin owns its launcher, panel, keyboard handling,
focus restoration, accessible labels and request cancellation. Use a bounded,
responsive panel. This slot is not a launcher placement zone and does not
replace modal/launcher APIs. Errors remain inside the existing plugin mount
error boundary.
UI code is trusted browser code. Host context is display context, never proof
of server authorization. A distribution backend must independently validate
the signed-in session and enforce company, tenant and user access rules for
every read and mutation. Keep provider secrets out of plugin UI and manifests.
The service worker's offline cache accepts only same-origin, hashed build assets
under `/assets/`. It does not store or replay application HTML, extension routes
or API data. This policy remains in effect after worker restarts and does not read
the arbitrary-response caches created by older workers.
+4
View File
@@ -368,6 +368,7 @@ Mount surfaces currently wired in the host include:
- `taskDetailView`
- `projectSidebarItem`
- `globalToolbarButton`
- `appShellOverlay` (persistent, signed-in application shell)
- `toolbarButton`
- `contextMenuItem`
- `commentAnnotation`
@@ -613,3 +614,6 @@ pnpm -r typecheck
pnpm test:run
pnpm build
```
For image-supplied plugins and the persistent shell lifecycle, see
[Distribution plugins](DISTRIBUTION-PLUGINS.md).
+1
View File
@@ -360,6 +360,7 @@ export interface PaperclipPluginManifestV1 {
| "sidebarPanel"
| "projectSidebarItem"
| "globalToolbarButton"
| "appShellOverlay"
| "toolbarButton"
| "contextMenuItem"
| "commentAnnotation"
+8
View File
@@ -214,6 +214,7 @@ Slot types describe where a component mounts. Most values also exist as launcher
| `settingsPage` | Global | — |
| `dashboardWidget` | Global | — |
| `globalToolbarButton` | Global | — |
| `appShellOverlay` (slot only) | Signed-in application shell | — |
| `detailTab` | Entity | `project`, `issue`, `agent`, `goal`, `run` |
| `taskDetailView` | Entity | (task/issue context) |
| `commentAnnotation` | Entity | `comment` |
@@ -1297,3 +1298,10 @@ const server = await startPluginDevServer({ rootDir: process.cwd() });
Dev server endpoints:
- `GET /__paperclip__/health` returns `{ ok, rootDir, uiDir }`
- `GET /__paperclip__/events` streams `reload` SSE events on UI build changes
### Persistent application shell contributions
An `appShellOverlay` slot uses `ui.action.register` and the standard
`PluginWidgetProps` context. It survives navigation and unmounts on account or
company changes, sign-out and onboarding. Plugins own panel accessibility and
request cleanup. See [the distribution and lifecycle contract](../../../doc/plugins/DISTRIBUTION-PLUGINS.md).
+1
View File
@@ -1482,6 +1482,7 @@ export const PLUGIN_UI_SLOT_TYPES = [
"sidebarPanel",
"projectSidebarItem",
"globalToolbarButton",
"appShellOverlay",
"toolbarButton",
"contextMenuItem",
"commentAnnotation",
+108 -1
View File
@@ -215,12 +215,13 @@ type LooseRow = {
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 } as unknown as import("@paperclipai/shared").PaperclipPluginManifestV1;
return { id: pluginKey, apiVersion: 1, version, capabilities: [] } as unknown as import("@paperclipai/shared").PaperclipPluginManifestV1;
}
function makeDeps(overrides?: {
@@ -402,6 +403,112 @@ describe("ensureBundledPlugins", () => {
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: {
@@ -0,0 +1,116 @@
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/);
});
});
@@ -12,6 +12,10 @@
*/
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(),
@@ -148,4 +152,59 @@ describe("pluginLoader.loadAll error retry", () => {
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 });
}
});
});
+7 -1
View File
@@ -132,10 +132,12 @@ import {
} from "./services/plugin-loader.js";
import {
SELF_HOSTED_AUTO_INSTALL_KEYS,
BUNDLED_PLUGIN_CATALOG,
ensureBundledPlugins,
resolveBundledCatalogRoot,
resolveBundledPluginInstalls,
} from "./services/bundled-plugins.js";
import { readDistributionPluginCatalog, distributionPluginActivationGuard } from "./services/distribution-plugin-catalog.js";
import {
createPluginWorkerManager,
type PluginWorkerManager,
@@ -596,12 +598,14 @@ export async function createApp(
const managedAutoInstallKeys = opts.managedPluginAutoInstall ?? null;
const bundledCatalogRoot =
opts.bundledPluginCatalogRoot ?? resolveBundledCatalogRoot(process.env);
const distributionPlugins = readDistributionPluginCatalog(bundledCatalogRoot, BUNDLED_PLUGIN_CATALOG);
const bundledPluginInstalls = resolveBundledPluginInstalls(
managedAutoInstallKeys ?? SELF_HOSTED_AUTO_INSTALL_KEYS,
{
catalogRoot: bundledCatalogRoot,
env: process.env,
enforceCatalogRoot: managedAutoInstallKeys !== null,
distributionPlugins,
},
);
const managedBundledPluginKeys =
@@ -869,6 +873,7 @@ export async function createApp(
{
localPluginDir: opts.localPluginDir ?? DEFAULT_LOCAL_PLUGIN_DIR,
migrationDb: opts.pluginMigrationDb,
assertPackageActivation: distributionPluginActivationGuard(bundledCatalogRoot, distributionPlugins, managedAutoInstallKeys),
},
{
workerManager,
@@ -1267,7 +1272,8 @@ export async function createApp(
{ registry: pluginRegistry, loader, lifecycle, logger },
// Managed mode reinstalls soft-uninstalled bundles (the control plane
// owns provisioning); self-hosted leaves an operator's uninstall alone.
// Operator-DISABLED plugins are never touched in either mode.
// Disabled plugins never start automatically. Added distribution permissions
// still enter upgrade_pending so enabling them requires an operator decision.
{ reinstallUninstalled: managedAutoInstallKeys !== null },
)
.then(() => loader.loadAll())
+52 -11
View File
@@ -1,6 +1,8 @@
import path from "node:path";
import fs from "node:fs";
import { isDeepStrictEqual } from "node:util";
import type { PaperclipPluginManifestV1 } from "@paperclipai/shared";
import { assertDistributionManifestCapabilities, readDistributionPluginCatalog, type DistributionPlugin } from "./distribution-plugin-catalog.js";
/**
* Bundled plugin auto-provisioning.
@@ -123,6 +125,8 @@ export interface ResolvedBundledPlugin {
pluginKey: string;
/** Absolute path handed to `loader.installPlugin({ localPath })`. */
localPath: string;
/** Image-owned entries require exact manifest identity/version matching. */
distribution?: DistributionPlugin;
}
/**
@@ -161,16 +165,23 @@ export function resolveBundledPluginInstalls(
catalogRoot: string;
env: Record<string, string | undefined>;
enforceCatalogRoot: boolean;
distributionPlugins?: readonly DistributionPlugin[];
},
): ResolvedBundledPlugin[] {
const resolved: ResolvedBundledPlugin[] = [];
const seen = new Set<string>();
const canonicalRoot = canonicalize(opts.catalogRoot);
const distributionPlugins = opts.distributionPlugins ?? readDistributionPluginCatalog(opts.catalogRoot, BUNDLED_PLUGIN_CATALOG);
for (const key of keys) {
if (seen.has(key)) continue;
seen.add(key);
const entry = BUNDLED_PLUGIN_CATALOG.find((candidate) => candidate.key === key);
if (!entry) {
const distribution = distributionPlugins.find((candidate) => candidate.key === key);
if (distribution) {
resolved.push({ key, pluginKey: distribution.pluginKey, localPath: distribution.localPath, distribution });
continue;
}
const known = BUNDLED_PLUGIN_CATALOG.map((candidate) => candidate.key).join(", ");
throw new Error(
`bundled plugin auto-install key "${key}" is not in the bundled catalog (known keys: ${known}); refusing to start`,
@@ -198,6 +209,7 @@ interface RegistryPluginRow {
status: string;
version: string;
manifestJson: PaperclipPluginManifestV1;
packagePath?: string | null;
lastError?: string | null;
}
@@ -206,7 +218,7 @@ export interface BundledPluginProvisionerDeps {
getByKey(pluginKey: string): Promise<RegistryPluginRow | null>;
update(
id: string,
data: { version?: string; manifest?: PaperclipPluginManifestV1 },
data: { version?: string; manifest?: PaperclipPluginManifestV1; packagePath?: string; status?: "upgrade_pending" },
): Promise<unknown>;
updateStatus(id: string, input: { status: "ready"; lastError: string | null }): Promise<unknown>;
};
@@ -236,7 +248,9 @@ function defaultBundleManifestExists(localPath: string): boolean {
* Reconcile a present bundled plugin's persisted manifest with the shipped
* bundle. The bundle is part of the release image, so its manifest is the
* source of truth. When the bundle declares a version that differs from the
* persisted version, update the stored manifest and version. This propagates
* persisted version, update the stored manifest and version. Distribution
* entries also adopt the image's package path, including same-version installs.
* This propagates
* a manifest change (for example a new driver capability) to an existing
* install that the auto-install path skips.
*
@@ -249,24 +263,42 @@ async function reconcileBundledPluginManifest(
install: ResolvedBundledPlugin,
deps: BundledPluginProvisionerDeps,
bundleManifestExists: (localPath: string) => boolean,
): Promise<void> {
verifiedManifest?: PaperclipPluginManifestV1,
): Promise<"upgrade_pending" | undefined> {
try {
if (!bundleManifestExists(install.localPath)) return;
const bundleManifest = await deps.loader.loadManifest(install.localPath);
// Managed reinstalls take the install path instead; this branch means the
// operator's uninstall must be retained, including its status.
if (install.distribution && existing.status === "uninstalled") return;
if (!verifiedManifest && !bundleManifestExists(install.localPath)) return;
const bundleManifest = verifiedManifest ?? await deps.loader.loadManifest(install.localPath);
if (!bundleManifest) return;
if (bundleManifest.version === existing.version) return;
let requiresApproval = false;
if (install.distribution) {
assertDistributionManifestCapabilities(bundleManifest);
const approved = new Set(existing.manifestJson.capabilities ?? []);
requiresApproval = bundleManifest.capabilities.some((capability) => !approved.has(capability));
}
const rebindPackage = install.distribution && existing.packagePath !== install.localPath && existing.status !== "uninstalled";
const refreshDistribution = install.distribution && !isDeepStrictEqual(bundleManifest, existing.manifestJson);
if (bundleManifest.version === existing.version && !rebindPackage && !requiresApproval && !refreshDistribution) return;
await deps.registry.update(existing.id, {
version: bundleManifest.version,
manifest: bundleManifest,
...(rebindPackage ? { packagePath: install.localPath } : {}),
// Persist the replacement and its approval gate in one write. A crash
// between separate manifest/status updates must never grant capabilities.
...(requiresApproval ? { status: "upgrade_pending" as const } : {}),
});
deps.logger.info(
{
pluginKey: install.pluginKey,
fromVersion: existing.version,
toVersion: bundleManifest.version,
...(rebindPackage ? { packagePath: install.localPath } : {}),
},
"reconciled bundled plugin manifest to the shipped bundle version",
);
if (requiresApproval) return "upgrade_pending";
} catch (err) {
deps.logger.error(
{ err, pluginKey: install.pluginKey },
@@ -357,16 +389,25 @@ export async function ensureBundledPlugins(
const bundleManifestExists = deps.bundleManifestExists ?? defaultBundleManifestExists;
for (const install of installs) {
try {
let verifiedManifest: PaperclipPluginManifestV1 | undefined;
if (install.distribution) {
const manifest = await deps.loader.loadManifest(install.localPath);
if (manifest?.id !== install.pluginKey || manifest.version !== install.distribution.version) {
throw new Error("Distribution manifest does not match its catalog identity/version");
}
verifiedManifest = manifest;
}
const existing = await deps.registry.getByKey(install.pluginKey);
if (existing && (existing.status !== "uninstalled" || !opts.reinstallUninstalled)) {
// The bundle ships with the release image, so its manifest is the
// source of truth for a present plugin. Reconcile the persisted
// manifest when the shipped bundle declares a newer version. Without
// manifest when the shipped bundle declares a different version. Without
// this step a manifest capability added to a bundle never reaches an
// existing install, because the auto-install below skips a present
// plugin. The reconcile updates only the stored manifest row; the
// running worker already runs the shipped code.
await reconcileBundledPluginManifest(existing, install, deps, bundleManifestExists);
// plugin. Distribution entries also replace a legacy/npm package path
// before loadAll resolves the worker. Configuration and status stay put.
const reconciledStatus = await reconcileBundledPluginManifest(existing, install, deps, bundleManifestExists, verifiedManifest);
if (reconciledStatus === "upgrade_pending") continue;
if (existing.status === "error") {
await reenableErroredBundledPlugin(existing, install, deps);
continue;
@@ -379,7 +420,7 @@ export async function ensureBundledPlugins(
}
// Skip silently when the bundle is absent (e.g. local dev or an image
// built without the plugin). Not an error condition.
if (!bundleManifestExists(install.localPath)) {
if (!verifiedManifest && !bundleManifestExists(install.localPath)) {
deps.logger.info(
{ pluginKey: install.pluginKey, pluginPath: install.localPath },
"bundled plugin bundle not present; skipping auto-install",
@@ -0,0 +1,165 @@
import fs from "node:fs";
import path from "node:path";
import { createHash } from "node:crypto";
import { z } from "zod";
import type { PaperclipPluginManifestV1 } from "@paperclipai/shared";
import { pluginCapabilityValidator } from "./plugin-capability-validator.js";
const segment = z.string().regex(/^[a-z][a-z0-9.-]{0,99}$/);
export const distributionPluginCatalogSchema = z.object({
schemaVersion: z.literal(1),
plugins: z.array(z.object({
key: segment,
pluginKey: segment,
version: z.string().regex(/^\d+\.\d+\.\d+(?:-[a-zA-Z0-9.-]+)?(?:\+[a-zA-Z0-9.-]+)?$/),
directory: segment,
digest: z.string().regex(/^sha256:[a-f0-9]{64}$/),
}).strict()).max(100),
}).strict();
export type DistributionPlugin = z.infer<typeof distributionPluginCatalogSchema>["plugins"][number] & {
localPath: string;
/** Normalized paths from the digest-verified package metadata. */
entrypoints: { worker: string; ui?: string };
};
function bundleEntrypoint(declared: unknown): string {
const relative = typeof declared === "string" ? declared.replace(/^\.\//, "").replace(/\/$/, "") : "";
if (!relative || path.posix.isAbsolute(relative) || path.win32.isAbsolute(relative) || relative.includes("\\") || relative.split("/").some((part) => !part || part === "." || part === "..")) {
throw new Error("Distribution entrypoint must stay inside its bundle");
}
return relative;
}
export function assertDistributionManifestCapabilities(manifest: PaperclipPluginManifestV1): void {
const result = pluginCapabilityValidator().validateManifestCapabilities(manifest);
if (!result.allowed) {
throw new Error(`Distribution manifest is missing required capabilities: ${result.missing.join(", ")}`);
}
}
export function distributionPluginsRoot(catalogRoot: string): string {
// Match canonical paths persisted by local-path installs (for example,
// macOS /tmp -> /private/tmp), without permitting a symlinked catalog itself.
try { return path.join(fs.realpathSync(catalogRoot), "distribution"); }
catch (error) {
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
return path.resolve(catalogRoot, "distribution");
}
}
/** Guard all activation paths, including persisted installs after a rollback. */
export function distributionPluginActivationGuard(
catalogRoot: string,
entries: readonly DistributionPlugin[],
selectedKeys: readonly string[] | null,
) {
const root = distributionPluginsRoot(catalogRoot);
return (input: { pluginKey?: string; packageRoot: string; installedPackagePath?: string | null; manifest?: PaperclipPluginManifestV1; previousManifest?: PaperclipPluginManifestV1 }) => {
let packageRoot: string;
try { packageRoot = fs.realpathSync(input.packageRoot); }
catch (error) {
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
packageRoot = path.resolve(input.packageRoot);
}
const entry = entries.find((candidate) => input.pluginKey ? candidate.pluginKey === input.pluginKey : candidate.localPath === packageRoot);
const relative = path.relative(root, packageRoot);
const inside = relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
// A missing image directory can make package resolution fall back to npm.
// Retain the persisted image-path provenance even when that directory no
// longer exists; the resolved fallback is not an ordinary installation.
const installedRelative = input.installedPackagePath ? path.relative(root, path.resolve(input.installedPackagePath)) : null;
const installedInside = installedRelative !== null && (installedRelative === "" || (!installedRelative.startsWith("..") && !path.isAbsolute(installedRelative)));
if (!inside && !installedInside && !entry) return;
if (!entry || (selectedKeys !== null && !selectedKeys.includes(entry.key)) || packageRoot !== entry.localPath) {
throw new Error("Distribution plugin is absent or not selected in this deployment");
}
if (input.manifest && (input.manifest.id !== entry.pluginKey || input.manifest.version !== entry.version)) {
throw new Error("Distribution manifest does not match its catalog identity/version");
}
if (input.manifest) {
assertDistributionManifestCapabilities(input.manifest);
if (input.previousManifest) {
const approved = new Set(input.previousManifest.capabilities);
const added = input.manifest.capabilities.filter((capability) => !approved.has(capability));
if (added.length) throw new Error(`Distribution plugin capabilities require approval: ${added.join(", ")}`);
}
const worker = bundleEntrypoint(input.manifest.entrypoints.worker);
const ui = input.manifest.entrypoints.ui === undefined ? undefined : bundleEntrypoint(input.manifest.entrypoints.ui);
if (worker !== entry.entrypoints.worker || ui !== entry.entrypoints.ui) {
throw new Error("Distribution manifest entrypoints do not match the verified package");
}
}
};
}
/** Same portable file inventory used by image builders: path, SHA-256, mode. */
export function distributionBundleDigest(root: string): string {
const inventory: Array<[string, string, number]> = [];
let bytesRead = 0;
const hash = (value: Buffer | string) => `sha256:${createHash("sha256").update(value).digest("hex")}`;
function walk(directory: string, relative = "") {
if (relative.split("/").length > 32) throw new Error("Distribution bundle exceeds directory depth limit");
for (const name of fs.readdirSync(directory).sort()) {
const relativePath = relative ? `${relative}/${name}` : name;
const file = path.join(directory, name);
const stat = fs.lstatSync(file);
if (stat.isSymbolicLink()) throw new Error("Distribution bundles cannot contain symlinks");
if (stat.isDirectory()) walk(file, relativePath);
else if (stat.isFile()) {
bytesRead += stat.size;
if (bytesRead > 256 * 1024 * 1024 || inventory.length >= 10_000) throw new Error("Distribution bundle exceeds verification limits");
inventory.push([relativePath, hash(fs.readFileSync(file)), stat.mode & 0o777]);
} else throw new Error("Distribution bundles contain only regular files and directories");
}
}
walk(root);
if (!inventory.length) throw new Error("Distribution bundle is empty");
return hash(JSON.stringify(inventory));
}
/**
* Optional, image-owned extension catalog. No URLs, executable configuration,
* runtime dependency installation, or replacement of a built-in entry.
* Validate bytes before importing any manifest code.
*/
export function readDistributionPluginCatalog(
catalogRoot: string,
builtins: readonly { key: string; pluginKey: string }[],
): DistributionPlugin[] {
const root = distributionPluginsRoot(catalogRoot);
const file = path.join(root, "catalog.json");
const rootStat = fs.lstatSync(root, { throwIfNoEntry: false });
if (!rootStat) return [];
if (rootStat.isSymbolicLink() || !rootStat.isDirectory()) throw new Error("Distribution catalog root must be a regular directory, not a symlink");
const stat = fs.lstatSync(file, { throwIfNoEntry: false });
if (!stat) return [];
if (!stat.isFile() || stat.isSymbolicLink() || stat.size > 128 * 1024) throw new Error("Invalid distribution catalog file");
const catalog = distributionPluginCatalogSchema.parse(JSON.parse(fs.readFileSync(file, "utf8")));
const keys = new Set(builtins.map((entry) => entry.key));
const pluginKeys = new Set(builtins.map((entry) => entry.pluginKey));
const directories = new Set<string>();
return catalog.plugins.map((entry) => {
if (keys.has(entry.key) || pluginKeys.has(entry.pluginKey) || directories.has(entry.directory)) {
throw new Error("Duplicate or built-in distribution plugin identity");
}
keys.add(entry.key); pluginKeys.add(entry.pluginKey); directories.add(entry.directory);
const localPath = path.join(root, entry.directory);
const info = fs.lstatSync(localPath);
if (!info.isDirectory() || info.isSymbolicLink()) throw new Error("Invalid distribution bundle directory");
if (distributionBundleDigest(localPath) !== entry.digest) throw new Error(`Distribution bundle digest mismatch: ${entry.key}`);
const pkg = JSON.parse(fs.readFileSync(path.join(localPath, "package.json"), "utf8"));
if (pkg.version !== entry.version || !pkg.paperclipPlugin) throw new Error("Distribution package version or entrypoints missing");
for (const name of ["manifest", "worker", "ui"] as const) {
const declared = pkg.paperclipPlugin[name];
if (name === "ui" && declared === undefined) continue;
const relative = bundleEntrypoint(declared);
const target = fs.statSync(path.join(localPath, relative));
if (name === "ui" ? !target.isDirectory() : !target.isFile()) throw new Error("Distribution entrypoint is not prebuilt");
}
return { ...entry, localPath, entrypoints: {
worker: bundleEntrypoint(pkg.paperclipPlugin.worker),
...(pkg.paperclipPlugin.ui === undefined ? {} : { ui: bundleEntrypoint(pkg.paperclipPlugin.ui) }),
} };
});
}
@@ -163,6 +163,7 @@ const UI_SLOT_CAPABILITIES: Record<PluginUiSlotType, PluginCapability> = {
taskDetailView: "ui.detailTab.register",
dashboardWidget: "ui.dashboardWidget.register",
globalToolbarButton: "ui.action.register",
appShellOverlay: "ui.action.register",
toolbarButton: "ui.action.register",
contextMenuItem: "ui.action.register",
commentAnnotation: "ui.commentAnnotation.register",
+2 -1
View File
@@ -27,6 +27,7 @@
import { realpath, stat } from "node:fs/promises";
import path from "node:path";
import { BUNDLED_LOCAL_PLUGIN_ROOT } from "./plugin-loader.js";
import { BUNDLED_CATALOG_ROOT_ENV_VAR } from "./bundled-plugins.js";
export { isCloudManagedInstance } from "./cloud-instance.js";
@@ -93,7 +94,7 @@ export async function isWithinBundledPluginRoot(
canonicalPath: string,
bundledRootOverride?: string,
): Promise<boolean> {
const bundledRoot = bundledRootOverride ?? BUNDLED_LOCAL_PLUGIN_ROOT;
const bundledRoot = bundledRootOverride ?? (process.env[BUNDLED_CATALOG_ROOT_ENV_VAR]?.trim() || BUNDLED_LOCAL_PLUGIN_ROOT);
let canonicalRoot: string;
try {
+22 -1
View File
@@ -269,6 +269,16 @@ function getDeclaredPageRoutePaths(manifest: PaperclipPluginManifestV1): string[
* Options for the plugin loader service.
*/
export interface PluginLoaderOptions {
/** Image-owned deployment policy, checked before importing code and starting workers. */
assertPackageActivation?: (input: {
pluginKey?: string;
packageRoot: string;
/** Persisted source path, even when package resolution used a fallback. */
installedPackagePath?: string | null;
manifest?: PaperclipPluginManifestV1;
/** Persisted grants, supplied before a runtime manifest refresh is saved. */
previousManifest?: PaperclipPluginManifestV1;
}) => void;
/**
* Path to the local plugin directory to scan.
* Defaults to ~/.paperclip/plugins/
@@ -1144,6 +1154,7 @@ export function pluginLoader(
migrationDb = db,
enableLocalFilesystem = true,
enableNpmDiscovery = true,
assertPackageActivation,
} = options;
const registry = pluginRegistryService(db);
@@ -1269,6 +1280,7 @@ export function pluginLoader(
// Step 3: Read and validate plugin manifest
// Note: this.loadManifest (used via current context)
assertPackageActivation?.({ packageRoot: resolvedPackagePath });
const pkgJson = await readPackageJson(resolvedPackagePath);
if (!pkgJson) throw new Error(`Missing package.json at ${resolvedPackagePath}`);
@@ -1287,6 +1299,7 @@ export function pluginLoader(
}
const manifest = await loadManifestFromPath(manifestPath);
assertPackageActivation?.({ packageRoot: resolvedPackagePath, pluginKey: manifest.id, manifest });
// Step 4: Reject incompatible plugin API versions
if (!manifestValidator.getSupportedVersions().includes(manifest.apiVersion)) {
@@ -1382,6 +1395,7 @@ export function pluginLoader(
);
}
assertPackageActivation?.({ packageRoot, installedPackagePath: plugin.packagePath, pluginKey: plugin.pluginKey, manifest, previousManifest: plugin.manifestJson });
if (JSON.stringify(manifest) === JSON.stringify(plugin.manifestJson)) {
return plugin;
}
@@ -1409,6 +1423,7 @@ export function pluginLoader(
packagePath: string,
source: PluginSource,
): Promise<DiscoveredPlugin | null> {
assertPackageActivation?.({ packageRoot: packagePath });
const pkgJson = await readPackageJson(packagePath);
if (!pkgJson) return null;
@@ -1438,6 +1453,7 @@ export function pluginLoader(
try {
const manifest = await loadManifestFromPath(manifestPath);
assertPackageActivation?.({ packageRoot: packagePath, pluginKey: manifest.id, manifest });
return {
packagePath,
packageName,
@@ -1690,6 +1706,7 @@ export function pluginLoader(
// -----------------------------------------------------------------------
async loadManifest(packagePath: string): Promise<PaperclipPluginManifestV1 | null> {
assertPackageActivation?.({ packageRoot: packagePath });
const pkgJson = await readPackageJson(packagePath);
if (!pkgJson) return null;
@@ -1704,7 +1721,9 @@ export function pluginLoader(
const manifestPath = resolveManifestPath(packagePath, pkgJson);
if (!manifestPath || !existsSync(manifestPath)) return null;
return loadManifestFromPath(manifestPath);
const manifest = await loadManifestFromPath(manifestPath);
assertPackageActivation?.({ packageRoot: packagePath, pluginKey: manifest.id, manifest });
return manifest;
},
// -----------------------------------------------------------------------
@@ -2246,8 +2265,10 @@ export function pluginLoader(
// 1. Resolve worker entrypoint
// ------------------------------------------------------------------
const packageRoot = resolvePluginPackageRoot(activePlugin, localPluginDir);
assertPackageActivation?.({ pluginKey, packageRoot, installedPackagePath: activePlugin.packagePath });
activePlugin = await refreshPluginManifestFromPackage(activePlugin, packageRoot);
manifest = activePlugin.manifestJson;
assertPackageActivation?.({ pluginKey, packageRoot, installedPackagePath: activePlugin.packagePath, manifest });
const workerEntrypoint = resolveWorkerEntrypoint(activePlugin, localPluginDir);
// ------------------------------------------------------------------
+7
View File
@@ -199,6 +199,8 @@ export function pluginRegistryService(db: Db) {
id: string,
data: {
packageName?: string;
packagePath?: string;
status?: "upgrade_pending";
version?: string;
manifest?: PaperclipPluginManifestV1;
},
@@ -210,6 +212,11 @@ export function pluginRegistryService(db: Db) {
updatedAt: new Date(),
};
if (data.packageName !== undefined) setClause.packageName = data.packageName;
if (data.packagePath !== undefined) setClause.packagePath = data.packagePath;
if (data.status !== undefined) {
setClause.status = data.status;
setClause.lastError = null;
}
if (data.version !== undefined) setClause.version = data.version;
if (data.manifest !== undefined) {
setClause.manifestJson = data.manifest;
+46 -15
View File
@@ -5,7 +5,17 @@
// reloads parked tabs onto the fresh bundle. Left as the literal placeholder in
// dev, where HMR (not the worker) drives refreshes.
const BUILD_ID = "__PAPERCLIP_BUILD_ID__";
const CACHE_NAME = `paperclip-${BUILD_ID}`;
// Separate this allowlisted cache from older workers that cached arbitrary URLs.
const CACHE_NAME = `paperclip-public-assets-${BUILD_ID}`;
const privateRequests = new Set();
const privateCacheControl = /(?:^|,)\s*(?:no-store|private)(?:\s*(?:,|=)|\s*$)/i;
async function evictRequest(request) {
await Promise.all((await caches.keys()).map(async (key) => {
const cache = await caches.open(key);
await cache.delete(request, { ignoreVary: true });
}));
}
self.addEventListener("install", () => {
self.skipWaiting();
@@ -23,32 +33,53 @@ self.addEventListener("activate", (event) => {
self.addEventListener("fetch", (event) => {
const { request } = event;
const url = new URL(request.url);
// Only immutable Vite build assets have a public offline-cache contract.
// Never infer that application/extension responses are public from absent
// headers, or from an in-memory classification lost when this worker restarts.
const publicAsset = url.origin === self.location.origin && !url.search &&
/^\/assets\/[^/]+-[a-zA-Z0-9_-]{8,}\.[a-zA-Z0-9.]+$/.test(url.pathname);
// Skip non-GET requests and API calls
// Explicitly private requests must bypass BOTH cache writes and offline
// fallback, including extension endpoints outside the host /api namespace.
if (request.method !== "GET" || url.pathname.startsWith("/api")) {
return;
}
if (request.cache === "no-store") {
privateRequests.add(request.url);
event.waitUntil(evictRequest(request).catch(() => {}));
return;
}
// Network-first for everything — cache is only an offline fallback
// Network-first; only public build assets can use an offline fallback.
event.respondWith(
fetch(request)
.then((response) => {
if (response.ok && url.origin === self.location.origin) {
.then(async (response) => {
const cacheControl = response.headers.get("cache-control") ?? "";
if (privateCacheControl.test(cacheControl)) {
// Revoke earlier cacheable responses too. Keep an in-memory denylist
// if storage is unavailable so offline fallback still fails closed.
privateRequests.add(request.url);
await evictRequest(request).catch(() => {});
} else if (response.ok && publicAsset && !privateRequests.has(request.url)) {
const clone = response.clone();
caches.open(CACHE_NAME).then((cache) => cache.put(request, clone));
await caches.open(CACHE_NAME).then(async (cache) => {
await cache.put(request, clone);
// A concurrent response may have revoked this URL during put().
if (privateRequests.has(request.url)) await cache.delete(request, { ignoreVary: true });
}).catch(() => {});
}
return response;
})
.catch(async () => {
// caches.match() resolves undefined on a miss (and the promise itself
// is always truthy, so `||` can never supply a fallback). respondWith
// must always receive a real Response — resolving undefined breaks
// the navigation with "Failed to convert value to 'Response'" instead
// of showing anything.
if (request.mode === "navigate") {
return (await caches.match("/")) ?? new Response("Offline", { status: 503 });
}
return (await caches.match(request)) ?? Response.error();
if (privateRequests.has(request.url)) return Response.error();
if (!publicAsset) return request.mode === "navigate" ? new Response("Offline", { status: 503 }) : Response.error();
// Restrict lookup to this policy's cache; old arbitrary-response caches
// must not become fallback candidates if activation cleanup fails.
try {
const cached = await (await caches.open(CACHE_NAME)).match(request);
if (cached && !privateCacheControl.test(cached.headers.get("cache-control") ?? "")) return cached;
} catch { /* Unavailable cache storage is an offline miss. */ }
return Response.error();
})
);
});
+2
View File
@@ -1,4 +1,5 @@
import { ChatSetupSidebarProvider } from "@/context/ChatSetupSidebarContext";
import { PluginAppShellOverlays } from "./PluginAppShellOverlays";
import {
useCallback,
useEffect,
@@ -785,6 +786,7 @@ export function Layout() {
onOpenChange={setShortcutsOpen}
/>
<ToastViewport />
<PluginAppShellOverlays localTrusted={health?.deploymentMode === "local_trusted"} />
</div>
</GeneralSettingsProvider>
</ChatSetupSidebarProvider>
+6
View File
@@ -117,6 +117,12 @@ vi.mock("./PropertiesPanel", () => ({
PropertiesPanel: () => null,
}));
// Overlay account/company lifecycle has its own integration test. These tests
// exercise route navigation with intentionally minimal context providers.
vi.mock("./PluginAppShellOverlays", () => ({
PluginAppShellOverlays: () => null,
}));
vi.mock("./CommandPalette", () => ({
CommandPalette: () => null,
}));
+2
View File
@@ -21,6 +21,7 @@ import { NewAgentDialog } from "./NewAgentDialog";
import { KeyboardShortcutsCheatsheet } from "./KeyboardShortcutsCheatsheet";
import { ToastViewport } from "./ToastViewport";
import { AnnouncementWell } from "./AnnouncementWell";
import { PluginAppShellOverlays } from "./PluginAppShellOverlays";
import { MobileBottomNav } from "./MobileBottomNav";
import { WorktreeBanner } from "./WorktreeBanner";
import { DevRestartBanner } from "./DevRestartBanner";
@@ -788,6 +789,7 @@ export function Layout({ sidebarSections }: { sidebarSections?: ReactNode }) {
<KeyboardShortcutsCheatsheet open={shortcutsOpen} onOpenChange={setShortcutsOpen} />
<ToastViewport />
<AnnouncementWell health={health} />
<PluginAppShellOverlays localTrusted={health?.deploymentMode === "local_trusted"} />
</div>
</GeneralSettingsProvider>
</ChatSetupSidebarProvider>
@@ -0,0 +1,68 @@
// @vitest-environment jsdom
import { useEffect, useState } from "react";
import { createRoot, type Root } from "react-dom/client";
import { flushSync } from "react-dom";
import { afterEach, describe, expect, it, vi } from "vitest";
import { PluginAppShellOverlays } from "./PluginAppShellOverlays";
const state = vi.hoisted(() => ({ userId: "alice" as string | null, settled: true, company: "first", onboarding: false, dismissed: false, pathname: "/ACME/issues", context: {} as Record<string, unknown>, failed: false, mounts: 0, disposals: 0 }));
vi.mock("@/api/companies-query", () => ({ useAccountIdentity: () => ({ userId: state.userId, settled: state.settled }) }));
vi.mock("@/context/CompanyContext", () => ({ useCompany: () => ({ selectedCompanyId: state.company, selectedCompany: { issuePrefix: "ACME" }, loading: false }) }));
vi.mock("@/context/DialogContext", () => ({ useDialogState: () => ({ onboardingOpen: state.onboarding, onboardingRouteDismissed: state.dismissed }) }));
vi.mock("@/lib/router", () => ({ useLocation: () => ({ pathname: state.pathname }) }));
vi.mock("@/plugins/slots", () => ({
usePluginSlots: () => ({ errorMessage: state.failed ? "unavailable" : null, slots: [{ pluginId: "fixture", pluginVersion: "1.0.0", id: "overlay" }] }),
PluginSlotMount: ({ context }: { context: Record<string, unknown> }) => {
state.context = context;
const [draft, setDraft] = useState("");
useEffect(() => { state.mounts++; return () => { state.disposals++; }; }, []);
return <button onClick={() => setDraft("private draft")}>{draft || "empty"}</button>;
},
}));
let root: Root | undefined;
let container: HTMLDivElement;
function render(localTrusted = false) {
if (!root) { container = document.createElement("div"); document.body.append(container); root = createRoot(container); }
flushSync(() => root!.render(<PluginAppShellOverlays localTrusted={localTrusted} />));
}
afterEach(() => {
if (root) flushSync(() => root!.unmount());
root = undefined; container?.remove();
Object.assign(state, { userId: "alice", settled: true, company: "first", onboarding: false, dismissed: false, pathname: "/ACME/issues", context: {}, failed: false, mounts: 0, disposals: 0 });
});
describe("persistent app-shell plugin lifecycle", () => {
it("passes the complete host context promised by PluginWidgetProps", () => {
render();
expect(state.context).toEqual({ companyId: "first", companyPrefix: "ACME", projectId: null, entityId: null, entityType: null, parentEntityId: null, userId: "alice" });
});
it("disposes drafts on route-driven onboarding and remounts after dismissal", () => {
render(); flushSync(() => container.querySelector("button")!.click());
state.pathname = "/ACME/onboarding"; render();
expect(state.onboarding).toBe(false); expect(container.textContent).toBe(""); expect(state.disposals).toBe(1);
state.dismissed = true; render(); expect(container.textContent).toBe("empty");
});
it("keeps a draft during shell rerenders and clears it on account/company transitions", () => {
render(); flushSync(() => container.querySelector("button")!.click());
render(); expect(container.textContent).toBe("private draft"); expect(state.mounts).toBe(1);
state.userId = "bob"; render(); expect(container.textContent).toBe("empty"); expect(state.disposals).toBe(1);
flushSync(() => container.querySelector("button")!.click());
state.company = "second"; render(); expect(container.textContent).toBe("empty"); expect(state.disposals).toBe(2);
});
it("disposes private UI on sign-out and waits for a settled identity", () => {
render(); state.userId = null; render(); expect(container.textContent).toBe(""); expect(state.disposals).toBe(1);
state.userId = "bob"; state.settled = false; render(); expect(state.mounts).toBe(1);
state.settled = true; render(); expect(state.mounts).toBe(2);
});
it("does not render during onboarding or contribution errors", () => {
state.onboarding = true; render(); expect(state.mounts).toBe(0);
state.onboarding = false; state.failed = true; render(); expect(container.textContent).toBe("");
});
it("clears account state when returning to the sessionless local board", () => {
render(true); flushSync(() => container.querySelector("button")!.click());
state.settled = false; render(true); expect(container.textContent).toBe(""); expect(state.disposals).toBe(1);
state.userId = null; state.settled = true; render(true);
expect(container.textContent).toBe("empty"); expect(state.mounts).toBe(2);
flushSync(() => container.querySelector("button")!.click());
state.userId = "bob"; render(true); expect(container.textContent).toBe("empty"); expect(state.disposals).toBe(2);
});
});
@@ -0,0 +1,59 @@
import { useAccountIdentity } from "@/api/companies-query";
import { useCompany } from "@/context/CompanyContext";
import { useDialogState } from "@/context/DialogContext";
import { useLocation } from "@/lib/router";
import { isOnboardingPath } from "@/lib/onboarding-route";
import type { PluginHostContext } from "@/plugins/bridge";
import { PluginSlotMount, usePluginSlots, type PluginSlotContext } from "@/plugins/slots";
function AppShellEntries({ context }: { context: PluginSlotContext }) {
const { slots, errorMessage } = usePluginSlots({
slotTypes: ["appShellOverlay"],
companyId: context.companyId,
});
// Optional extensions must not replace the application's normal error UI.
if (errorMessage || slots.length === 0) return null;
return (
<aside className="plugin-app-shell-overlays" aria-label="Application extensions">
{slots.map((slot) => (
<PluginSlotMount
key={`${slot.pluginId}:${slot.pluginVersion}:${slot.id}`}
slot={slot}
context={context}
className="plugin-app-shell-overlay"
/>
))}
</aside>
);
}
/**
* One persistent mount in each application shell. Navigation preserves the
* plugin tree; changing account/company, signing out, or entering onboarding
* disposes it. Plugins must cancel their requests/subscriptions on disposal.
* Host context is display context, never proof of server authorization.
*/
export function PluginAppShellOverlays({ localTrusted = false }: { localTrusted?: boolean }) {
const { userId, settled } = useAccountIdentity();
const { selectedCompanyId, selectedCompany, loading } = useCompany();
const { onboardingOpen, onboardingRouteDismissed } = useDialogState();
const { pathname } = useLocation();
// Local-trusted instances intentionally have no login requirement. Still
// prefer any real account and wait for identity resolution so account changes
// cannot reuse the prior account's in-memory plugin state.
const identity = settled ? userId ?? (localTrusted ? "local-board" : null) : null;
const onboardingVisible = onboardingOpen || (!onboardingRouteDismissed && isOnboardingPath(pathname));
if (!identity || loading || onboardingVisible) return null;
const context: PluginSlotContext & PluginHostContext = {
companyId: selectedCompanyId,
companyPrefix: selectedCompany?.issuePrefix ?? null,
projectId: null, entityId: null, entityType: null, parentEntityId: null,
userId,
};
return (
<AppShellEntries
key={JSON.stringify([identity, selectedCompanyId])}
context={context}
/>
);
}
+24
View File
@@ -3085,3 +3085,27 @@ span.paperclip-mention-chip[data-mention-kind="external-object"] {
--agent-cap-v1-muted-dream-a: #a6aaad;
--agent-cap-v1-muted-dream-b: #44464a;
}
/* Persistent plugin surface. The host reserves mobile navigation/safe-area
space; each extension owns its accessible panel and focus lifecycle. */
:root {
--plugin-shell-gap: calc(var(--spacing) * 3);
--plugin-shell-inset: calc(var(--spacing) * 4);
--plugin-shell-mobile-bottom: calc(var(--sz-calc-14) + var(--sz-safe-bottom) + var(--plugin-shell-inset));
}
.plugin-app-shell-overlays {
position: fixed;
inset-inline-end: max(var(--plugin-shell-inset), env(safe-area-inset-right));
bottom: max(var(--plugin-shell-inset), var(--sz-safe-bottom));
z-index: var(--z-20);
display: flex;
flex-direction: column;
align-items: flex-end;
gap: var(--plugin-shell-gap);
max-width: calc(100vw - var(--plugin-shell-inset) * 2);
pointer-events: none;
}
.plugin-app-shell-overlay { pointer-events: auto; }
@media (max-width: 767px) {
.plugin-app-shell-overlays { bottom: var(--plugin-shell-mobile-bottom); }
}
@@ -0,0 +1,87 @@
// @vitest-environment node
import { readFileSync } from "node:fs";
import vm from "node:vm";
import { describe, expect, it, vi } from "vitest";
function worker() {
const handlers = new Map<string, (event: unknown) => void>();
const put = vi.fn();
const match = vi.fn();
const remove = vi.fn().mockResolvedValue(true);
const fetch = vi.fn().mockResolvedValue(new Response("public asset"));
vm.runInNewContext(readFileSync(new URL("../../public/sw.js", import.meta.url), "utf8"), {
self: { location: { origin: "https://example.test" }, addEventListener: (type: string, fn: (event: unknown) => void) => handlers.set(type, fn) },
URL, Response, fetch, caches: { keys: async () => ["paperclip-old", "paperclip-current"], open: async () => ({ put, delete: remove, match }), match },
});
const request = (cache: RequestCache = "default", pathname = "/extension/history") => {
const respondWith = vi.fn();
handlers.get("fetch")!({ request: new Request(`https://example.test${pathname}`, { cache }), respondWith, waitUntil: vi.fn() });
return respondWith;
};
return { request, fetch, put, match, remove };
}
describe("service worker privacy boundaries", () => {
it("bypasses both caching and offline fallback for a no-store request outside /api", () => {
const w = worker();
expect(w.request("no-store")).not.toHaveBeenCalled();
expect(w.fetch).not.toHaveBeenCalled();
expect(w.match).not.toHaveBeenCalled();
expect(w.put).not.toHaveBeenCalled();
});
it.each(["no-store", "max-age=0, no-store", "private", 'private="Set-Cookie"', "PRIVATE, max-age=60"])("never caches a response marked %s", async directive => {
const w = worker();
w.fetch.mockResolvedValue(new Response("personal content", { headers: { "cache-control": directive } }));
const response = await w.request().mock.calls[0]![0];
expect(await response.text()).toBe("personal content");
expect(w.put).not.toHaveBeenCalled();
});
it("keeps public asset offline caching", async () => {
const w = worker();
await w.request("default", "/assets/index-AbCd1234.js").mock.calls[0]![0];
expect(w.put).toHaveBeenCalledOnce();
w.fetch.mockRejectedValue(new Error("offline"));
w.match.mockResolvedValue(new Response("cached build asset"));
expect(await (await w.request("default", "/assets/index-AbCd1234.js").mock.calls[0]![0]).text()).toBe("cached build asset");
});
it.each(["private", "no-store"])("evicts stale entries when a response becomes %s and blocks offline reuse", async directive => {
const w = worker();
w.match.mockResolvedValue(new Response("stale personal content"));
w.fetch.mockResolvedValue(new Response("fresh", { headers: { "cache-control": directive } }));
await w.request().mock.calls[0]![0];
expect(w.remove).toHaveBeenCalledTimes(2);
expect(w.remove).toHaveBeenCalledWith(expect.any(Request), { ignoreVary: true });
w.fetch.mockRejectedValue(new Error("offline"));
const offline = await w.request().mock.calls[0]![0];
expect(offline.type).toBe("error"); expect(w.match).not.toHaveBeenCalled();
});
it("does not serve stale data when cache eviction itself fails", async () => {
const w = worker(); w.remove.mockRejectedValue(new Error("cache unavailable"));
w.fetch.mockResolvedValue(new Response("fresh", { headers: { "cache-control": "private" } }));
expect(await (await w.request().mock.calls[0]![0]).text()).toBe("fresh");
w.fetch.mockRejectedValue(new Error("offline"));
expect((await w.request().mock.calls[0]![0]).type).toBe("error");
expect(w.match).not.toHaveBeenCalled();
});
it.each(["/extension/history", "/extensions/support/messages", "/", "/assets/avatar.png", "/assets/index-AbCd1234.js?user=1"])("never caches or falls back to uncertain resource %s after a worker restart", async pathname => {
const previous = worker();
previous.remove.mockRejectedValue(new Error("storage unavailable"));
previous.fetch.mockResolvedValue(new Response("personal content", { headers: { "cache-control": "private" } }));
await previous.request("default", pathname).mock.calls[0]![0];
const restarted = worker();
restarted.match.mockResolvedValue(new Response("stale personal content"));
await restarted.request("default", pathname).mock.calls[0]![0];
expect(restarted.put).not.toHaveBeenCalled();
restarted.fetch.mockRejectedValue(new Error("offline"));
expect((await restarted.request("default", pathname).mock.calls[0]![0]).type).toBe("error");
expect(restarted.match).not.toHaveBeenCalled();
});
it("rejects a cached asset explicitly marked private even after restart", async () => {
const w = worker();
w.match.mockResolvedValue(new Response("personal content", { headers: { "cache-control": "private" } }));
w.fetch.mockRejectedValue(new Error("offline"));
expect((await w.request("default", "/assets/index-AbCd1234.js").mock.calls[0]![0]).type).toBe("error");
});
});
+3 -2
View File
@@ -76,7 +76,7 @@ describe("sw.js offline fallback", () => {
expect(await response!.text()).toBe("Offline");
});
it("serves the cached shell for a failed navigation when one exists", async () => {
it("does not replay a legacy cached shell for a failed navigation", async () => {
const shell = new Response("<html>app shell</html>", { status: 200 });
const listener = loadServiceWorkerFetchListener({
fetch: () => Promise.reject(new TypeError("network down")),
@@ -89,7 +89,8 @@ describe("sw.js offline fallback", () => {
mode: "navigate",
});
expect(response).toBe(shell);
expect(response!.status).toBe(503);
expect(await response!.text()).toBe("Offline");
});
it("returns a network-error Response for a failed asset with no cache entry", async () => {