From be9c775d9e7d00d58e8d1ea33489bb3f259a2dd4 Mon Sep 17 00:00:00 2001 From: Devin Foley Date: Fri, 18 Sep 2026 17:46:13 -0700 Subject: [PATCH] feat(plugins): load image-owned catalogs and persistent app overlays Co-Authored-By: Paperclip --- doc/plugins/DISTRIBUTION-PLUGINS.md | 85 ++++++++++++ doc/plugins/PLUGIN_AUTHORING_GUIDE.md | 4 + doc/plugins/PLUGIN_SPEC.md | 1 + packages/plugins/sdk/README.md | 8 ++ packages/shared/src/constants.ts | 1 + .../distribution-plugin-catalog.test.ts | 79 +++++++++++ server/src/app.ts | 5 + server/src/services/bundled-plugins.ts | 16 +++ .../services/distribution-plugin-catalog.ts | 129 ++++++++++++++++++ .../services/plugin-capability-validator.ts | 1 + server/src/services/plugin-install-guard.ts | 3 +- server/src/services/plugin-loader.ts | 19 ++- ui/src/components/Layout.production.tsx | 2 + ui/src/components/Layout.tsx | 2 + .../PluginAppShellOverlays.test.tsx | 48 +++++++ ui/src/components/PluginAppShellOverlays.tsx | 45 ++++++ ui/src/index.css | 24 ++++ 17 files changed, 470 insertions(+), 2 deletions(-) create mode 100644 doc/plugins/DISTRIBUTION-PLUGINS.md create mode 100644 server/src/__tests__/distribution-plugin-catalog.test.ts create mode 100644 server/src/services/distribution-plugin-catalog.ts create mode 100644 ui/src/components/PluginAppShellOverlays.test.tsx create mode 100644 ui/src/components/PluginAppShellOverlays.tsx diff --git a/doc/plugins/DISTRIBUTION-PLUGINS.md b/doc/plugins/DISTRIBUTION-PLUGINS.md new file mode 100644 index 0000000000..193e8d42ce --- /dev/null +++ b/doc/plugins/DISTRIBUTION-PLUGINS.md @@ -0,0 +1,85 @@ +# 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. + +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 +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. + +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. 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__/distribution-plugin-catalog.test.ts b/server/src/__tests__/distribution-plugin-catalog.test.ts new file mode 100644 index 0000000000..c3e5ec2514 --- /dev/null +++ b/server/src/__tests__/distribution-plugin-catalog.test.ts @@ -0,0 +1,79 @@ +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, [], [])({ 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/); + } + }); +}); diff --git a/server/src/app.ts b/server/src/app.ts index 10c02d5c0b..3d941c06cf 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, diff --git a/server/src/services/bundled-plugins.ts b/server/src/services/bundled-plugins.ts index b4c5ab9bf3..96d4930b8e 100644 --- a/server/src/services/bundled-plugins.ts +++ b/server/src/services/bundled-plugins.ts @@ -1,6 +1,7 @@ import path from "node:path"; import fs from "node:fs"; import type { PaperclipPluginManifestV1 } from "@paperclipai/shared"; +import { readDistributionPluginCatalog, type DistributionPlugin } from "./distribution-plugin-catalog.js"; /** * Bundled plugin auto-provisioning. @@ -123,6 +124,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 +164,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`, @@ -357,6 +367,12 @@ export async function ensureBundledPlugins( const bundleManifestExists = deps.bundleManifestExists ?? defaultBundleManifestExists; for (const install of installs) { try { + 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"); + } + } 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 diff --git a/server/src/services/distribution-plugin-catalog.ts b/server/src/services/distribution-plugin-catalog.ts new file mode 100644 index 0000000000..a5a6e54793 --- /dev/null +++ b/server/src/services/distribution-plugin-catalog.ts @@ -0,0 +1,129 @@ +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"; + +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; +}; + +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; manifest?: 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)); + if (!inside && !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"); + } + }; +} + +/** 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 = typeof declared === "string" ? declared.replace(/^\.\//, "").replace(/\/$/, "") : ""; + if (!relative || relative.startsWith("/") || relative.includes("\\") || relative.split("/").some((part: string) => !part || part === "." || part === "..")) { + throw new Error("Distribution entrypoint must stay inside its bundle"); + } + 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 }; + }); +} 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..56e95590af 100644 --- a/server/src/services/plugin-loader.ts +++ b/server/src/services/plugin-loader.ts @@ -269,6 +269,12 @@ 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; + manifest?: PaperclipPluginManifestV1; + }) => void; /** * Path to the local plugin directory to scan. * Defaults to ~/.paperclip/plugins/ @@ -1144,6 +1150,7 @@ export function pluginLoader( migrationDb = db, enableLocalFilesystem = true, enableNpmDiscovery = true, + assertPackageActivation, } = options; const registry = pluginRegistryService(db); @@ -1269,6 +1276,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 +1295,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 +1391,7 @@ export function pluginLoader( ); } + assertPackageActivation?.({ packageRoot, pluginKey: plugin.pluginKey, manifest }); if (JSON.stringify(manifest) === JSON.stringify(plugin.manifestJson)) { return plugin; } @@ -1409,6 +1419,7 @@ export function pluginLoader( packagePath: string, source: PluginSource, ): Promise { + assertPackageActivation?.({ packageRoot: packagePath }); const pkgJson = await readPackageJson(packagePath); if (!pkgJson) return null; @@ -1438,6 +1449,7 @@ export function pluginLoader( try { const manifest = await loadManifestFromPath(manifestPath); + assertPackageActivation?.({ packageRoot: packagePath, pluginKey: manifest.id, manifest }); return { packagePath, packageName, @@ -1690,6 +1702,7 @@ export function pluginLoader( // ----------------------------------------------------------------------- async loadManifest(packagePath: string): Promise { + assertPackageActivation?.({ packageRoot: packagePath }); const pkgJson = await readPackageJson(packagePath); if (!pkgJson) return null; @@ -1704,7 +1717,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 +2261,10 @@ export function pluginLoader( // 1. Resolve worker entrypoint // ------------------------------------------------------------------ const packageRoot = resolvePluginPackageRoot(activePlugin, localPluginDir); + assertPackageActivation?.({ pluginKey, packageRoot }); activePlugin = await refreshPluginManifestFromPackage(activePlugin, packageRoot); manifest = activePlugin.manifestJson; + assertPackageActivation?.({ pluginKey, packageRoot, manifest }); const workerEntrypoint = resolveWorkerEntrypoint(activePlugin, localPluginDir); // ------------------------------------------------------------------ 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.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..1144ea053b --- /dev/null +++ b/ui/src/components/PluginAppShellOverlays.test.tsx @@ -0,0 +1,48 @@ +// @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, 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 }) })); +vi.mock("@/plugins/slots", () => ({ + usePluginSlots: () => ({ errorMessage: state.failed ? "unavailable" : null, slots: [{ pluginId: "fixture", pluginVersion: "1.0.0", id: "overlay" }] }), + PluginSlotMount: () => { + const [draft, setDraft] = useState(""); + useEffect(() => { state.mounts++; return () => { state.disposals++; }; }, []); + return ; + }, +})); +let root: Root | undefined; +let container: HTMLDivElement; +function render() { + 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, failed: false, mounts: 0, disposals: 0 }); +}); +describe("persistent app-shell plugin lifecycle", () => { + 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(""); + }); +}); diff --git a/ui/src/components/PluginAppShellOverlays.tsx b/ui/src/components/PluginAppShellOverlays.tsx new file mode 100644 index 0000000000..e69af101ce --- /dev/null +++ b/ui/src/components/PluginAppShellOverlays.tsx @@ -0,0 +1,45 @@ +import { useAccountIdentity } from "@/api/companies-query"; +import { useCompany } from "@/context/CompanyContext"; +import { useDialogState } from "@/context/DialogContext"; +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 } = useDialogState(); + const identity = localTrusted ? "local-board" : settled ? userId : null; + if (!identity || loading || onboardingOpen) return null; + 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); } +}