diff --git a/doc/plugins/DISTRIBUTION-PLUGINS.md b/doc/plugins/DISTRIBUTION-PLUGINS.md new file mode 100644 index 0000000000..4e58c36326 --- /dev/null +++ b/doc/plugins/DISTRIBUTION-PLUGINS.md @@ -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. diff --git a/doc/plugins/PLUGIN_AUTHORING_GUIDE.md b/doc/plugins/PLUGIN_AUTHORING_GUIDE.md index e4d2670364..00d6cc01bf 100644 --- a/doc/plugins/PLUGIN_AUTHORING_GUIDE.md +++ b/doc/plugins/PLUGIN_AUTHORING_GUIDE.md @@ -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). diff --git a/doc/plugins/PLUGIN_SPEC.md b/doc/plugins/PLUGIN_SPEC.md index 08b859c135..8c0292b4dc 100644 --- a/doc/plugins/PLUGIN_SPEC.md +++ b/doc/plugins/PLUGIN_SPEC.md @@ -360,6 +360,7 @@ export interface PaperclipPluginManifestV1 { | "sidebarPanel" | "projectSidebarItem" | "globalToolbarButton" + | "appShellOverlay" | "toolbarButton" | "contextMenuItem" | "commentAnnotation" diff --git a/packages/plugins/sdk/README.md b/packages/plugins/sdk/README.md index f352df1d90..23f4a0311b 100644 --- a/packages/plugins/sdk/README.md +++ b/packages/plugins/sdk/README.md @@ -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). diff --git a/packages/shared/src/constants.ts b/packages/shared/src/constants.ts index 28e18d3dfd..33d1e61e8e 100644 --- a/packages/shared/src/constants.ts +++ b/packages/shared/src/constants.ts @@ -1482,6 +1482,7 @@ export const PLUGIN_UI_SLOT_TYPES = [ "sidebarPanel", "projectSidebarItem", "globalToolbarButton", + "appShellOverlay", "toolbarButton", "contextMenuItem", "commentAnnotation", diff --git a/server/src/__tests__/bundled-plugins.test.ts b/server/src/__tests__/bundled-plugins.test.ts index 0015010b7e..d4369ebb8b 100644 --- a/server/src/__tests__/bundled-plugins.test.ts +++ b/server/src/__tests__/bundled-plugins.test.ts @@ -215,12 +215,13 @@ type LooseRow = { version?: string; manifestJson?: Record; 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: { diff --git a/server/src/__tests__/distribution-plugin-catalog.test.ts b/server/src/__tests__/distribution-plugin-catalog.test.ts new file mode 100644 index 0000000000..58f8cf338a --- /dev/null +++ b/server/src/__tests__/distribution-plugin-catalog.test.ts @@ -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/); + }); +}); diff --git a/server/src/__tests__/plugin-loader-error-retry.test.ts b/server/src/__tests__/plugin-loader-error-retry.test.ts index bcde5be425..9de7d849b1 100644 --- a/server/src/__tests__/plugin-loader-error-retry.test.ts +++ b/server/src/__tests__/plugin-loader-error-retry.test.ts @@ -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 }); + } + }); }); diff --git a/server/src/app.ts b/server/src/app.ts index 10c02d5c0b..a1d603c5b3 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -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()) diff --git a/server/src/services/bundled-plugins.ts b/server/src/services/bundled-plugins.ts index b4c5ab9bf3..d4e859259d 100644 --- a/server/src/services/bundled-plugins.ts +++ b/server/src/services/bundled-plugins.ts @@ -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; enforceCatalogRoot: boolean; + distributionPlugins?: readonly DistributionPlugin[]; }, ): ResolvedBundledPlugin[] { const resolved: ResolvedBundledPlugin[] = []; const seen = new Set(); 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; update( id: string, - data: { version?: string; manifest?: PaperclipPluginManifestV1 }, + data: { version?: string; manifest?: PaperclipPluginManifestV1; packagePath?: string; status?: "upgrade_pending" }, ): Promise; updateStatus(id: string, input: { status: "ready"; lastError: string | null }): Promise; }; @@ -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 { + 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", diff --git a/server/src/services/distribution-plugin-catalog.ts b/server/src/services/distribution-plugin-catalog.ts new file mode 100644 index 0000000000..7f7275149f --- /dev/null +++ b/server/src/services/distribution-plugin-catalog.ts @@ -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["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(); + 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) }), + } }; + }); +} diff --git a/server/src/services/plugin-capability-validator.ts b/server/src/services/plugin-capability-validator.ts index 7f03126234..eb0b83d3f9 100644 --- a/server/src/services/plugin-capability-validator.ts +++ b/server/src/services/plugin-capability-validator.ts @@ -163,6 +163,7 @@ const UI_SLOT_CAPABILITIES: Record = { 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", diff --git a/server/src/services/plugin-install-guard.ts b/server/src/services/plugin-install-guard.ts index 470ccbd6eb..8dd60755aa 100644 --- a/server/src/services/plugin-install-guard.ts +++ b/server/src/services/plugin-install-guard.ts @@ -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 { - 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 { diff --git a/server/src/services/plugin-loader.ts b/server/src/services/plugin-loader.ts index 163abaebd5..c07e48ac2e 100644 --- a/server/src/services/plugin-loader.ts +++ b/server/src/services/plugin-loader.ts @@ -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 { + 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 { + 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); // ------------------------------------------------------------------ diff --git a/server/src/services/plugin-registry.ts b/server/src/services/plugin-registry.ts index 9e2da31954..f86a550b94 100644 --- a/server/src/services/plugin-registry.ts +++ b/server/src/services/plugin-registry.ts @@ -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; diff --git a/ui/public/sw.js b/ui/public/sw.js index 9a9d1a7ee6..c5f43d34ae 100644 --- a/ui/public/sw.js +++ b/ui/public/sw.js @@ -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(); }) ); }); diff --git a/ui/src/components/Layout.production.tsx b/ui/src/components/Layout.production.tsx index 5b1b646d81..170b7bd549 100644 --- a/ui/src/components/Layout.production.tsx +++ b/ui/src/components/Layout.production.tsx @@ -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} /> + diff --git a/ui/src/components/Layout.test.tsx b/ui/src/components/Layout.test.tsx index 3807c93241..7522e6ff3b 100644 --- a/ui/src/components/Layout.test.tsx +++ b/ui/src/components/Layout.test.tsx @@ -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, })); diff --git a/ui/src/components/Layout.tsx b/ui/src/components/Layout.tsx index f2ee35141a..6ad9e58f71 100644 --- a/ui/src/components/Layout.tsx +++ b/ui/src/components/Layout.tsx @@ -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 }) { + diff --git a/ui/src/components/PluginAppShellOverlays.test.tsx b/ui/src/components/PluginAppShellOverlays.test.tsx new file mode 100644 index 0000000000..3174a30606 --- /dev/null +++ b/ui/src/components/PluginAppShellOverlays.test.tsx @@ -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, 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 }) => { + state.context = context; + const [draft, setDraft] = useState(""); + useEffect(() => { state.mounts++; return () => { state.disposals++; }; }, []); + return ; + }, +})); +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()); +} +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); + }); +}); diff --git a/ui/src/components/PluginAppShellOverlays.tsx b/ui/src/components/PluginAppShellOverlays.tsx new file mode 100644 index 0000000000..b53a529adb --- /dev/null +++ b/ui/src/components/PluginAppShellOverlays.tsx @@ -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 ( + + ); +} + +/** + * 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 ( + + ); +} diff --git a/ui/src/index.css b/ui/src/index.css index c73c103d07..04b3313e55 100644 --- a/ui/src/index.css +++ b/ui/src/index.css @@ -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); } +} diff --git a/ui/src/lib/service-worker-cache-policy.test.ts b/ui/src/lib/service-worker-cache-policy.test.ts new file mode 100644 index 0000000000..9211696648 --- /dev/null +++ b/ui/src/lib/service-worker-cache-policy.test.ts @@ -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 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"); + }); +}); diff --git a/ui/src/lib/sw-offline-fallback.test.ts b/ui/src/lib/sw-offline-fallback.test.ts index ab2b86ca09..917e7658cc 100644 --- a/ui/src/lib/sw-offline-fallback.test.ts +++ b/ui/src/lib/sw-offline-fallback.test.ts @@ -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("app shell", { 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 () => {