feat(plugins): load image-owned catalogs and persistent app overlays

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Devin FoleyandPaperclip committed 2026-09-19 08:57:49 -07:00
1 parent 64895f187b
commit be9c775d9e
17 files changed
+470 -2

No files matched your search

+85
View File
@@ -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.
+4
View File
@@ -368,6 +368,7 @@ Mount surfaces currently wired in the host include:
- `taskDetailView`
- `projectSidebarItem`
- `globalToolbarButton`
- `appShellOverlay` (persistent, signed-in application shell)
- `toolbarButton`
- `contextMenuItem`
- `commentAnnotation`
@@ -613,3 +614,6 @@ pnpm -r typecheck
pnpm test:run
pnpm build
```
For image-supplied plugins and the persistent shell lifecycle, see
[Distribution plugins](DISTRIBUTION-PLUGINS.md).
+1
View File
@@ -360,6 +360,7 @@ export interface PaperclipPluginManifestV1 {
| "sidebarPanel"
| "projectSidebarItem"
| "globalToolbarButton"
| "appShellOverlay"
| "toolbarButton"
| "contextMenuItem"
| "commentAnnotation"
+8
View File
@@ -214,6 +214,7 @@ Slot types describe where a component mounts. Most values also exist as launcher
| `settingsPage` | Global | — |
| `dashboardWidget` | Global | — |
| `globalToolbarButton` | Global | — |
| `appShellOverlay` (slot only) | Signed-in application shell | — |
| `detailTab` | Entity | `project`, `issue`, `agent`, `goal`, `run` |
| `taskDetailView` | Entity | (task/issue context) |
| `commentAnnotation` | Entity | `comment` |
@@ -1297,3 +1298,10 @@ const server = await startPluginDevServer({ rootDir: process.cwd() });
Dev server endpoints:
- `GET /__paperclip__/health` returns `{ ok, rootDir, uiDir }`
- `GET /__paperclip__/events` streams `reload` SSE events on UI build changes
### Persistent application shell contributions
An `appShellOverlay` slot uses `ui.action.register` and the standard
`PluginWidgetProps` context. It survives navigation and unmounts on account or
company changes, sign-out and onboarding. Plugins own panel accessibility and
request cleanup. See [the distribution and lifecycle contract](../../../doc/plugins/DISTRIBUTION-PLUGINS.md).
+1
View File
@@ -1482,6 +1482,7 @@ export const PLUGIN_UI_SLOT_TYPES = [
"sidebarPanel",
"projectSidebarItem",
"globalToolbarButton",
"appShellOverlay",
"toolbarButton",
"contextMenuItem",
"commentAnnotation",
@@ -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/);
}
});
});
+5
View File
@@ -132,10 +132,12 @@ import {
} from "./services/plugin-loader.js";
import {
SELF_HOSTED_AUTO_INSTALL_KEYS,
BUNDLED_PLUGIN_CATALOG,
ensureBundledPlugins,
resolveBundledCatalogRoot,
resolveBundledPluginInstalls,
} from "./services/bundled-plugins.js";
import { readDistributionPluginCatalog, distributionPluginActivationGuard } from "./services/distribution-plugin-catalog.js";
import {
createPluginWorkerManager,
type PluginWorkerManager,
@@ -596,12 +598,14 @@ export async function createApp(
const managedAutoInstallKeys = opts.managedPluginAutoInstall ?? null;
const bundledCatalogRoot =
opts.bundledPluginCatalogRoot ?? resolveBundledCatalogRoot(process.env);
const distributionPlugins = readDistributionPluginCatalog(bundledCatalogRoot, BUNDLED_PLUGIN_CATALOG);
const bundledPluginInstalls = resolveBundledPluginInstalls(
managedAutoInstallKeys ?? SELF_HOSTED_AUTO_INSTALL_KEYS,
{
catalogRoot: bundledCatalogRoot,
env: process.env,
enforceCatalogRoot: managedAutoInstallKeys !== null,
distributionPlugins,
},
);
const managedBundledPluginKeys =
@@ -869,6 +873,7 @@ export async function createApp(
{
localPluginDir: opts.localPluginDir ?? DEFAULT_LOCAL_PLUGIN_DIR,
migrationDb: opts.pluginMigrationDb,
assertPackageActivation: distributionPluginActivationGuard(bundledCatalogRoot, distributionPlugins, managedAutoInstallKeys),
},
{
workerManager,
+16
View File
@@ -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<string, string | undefined>;
enforceCatalogRoot: boolean;
distributionPlugins?: readonly DistributionPlugin[];
},
): ResolvedBundledPlugin[] {
const resolved: ResolvedBundledPlugin[] = [];
const seen = new Set<string>();
const canonicalRoot = canonicalize(opts.catalogRoot);
const distributionPlugins = opts.distributionPlugins ?? readDistributionPluginCatalog(opts.catalogRoot, BUNDLED_PLUGIN_CATALOG);
for (const key of keys) {
if (seen.has(key)) continue;
seen.add(key);
const entry = BUNDLED_PLUGIN_CATALOG.find((candidate) => candidate.key === key);
if (!entry) {
const distribution = distributionPlugins.find((candidate) => candidate.key === key);
if (distribution) {
resolved.push({ key, pluginKey: distribution.pluginKey, localPath: distribution.localPath, distribution });
continue;
}
const known = BUNDLED_PLUGIN_CATALOG.map((candidate) => candidate.key).join(", ");
throw new Error(
`bundled plugin auto-install key "${key}" is not in the bundled catalog (known keys: ${known}); refusing to start`,
@@ -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
@@ -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<typeof distributionPluginCatalogSchema>["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<string>();
return catalog.plugins.map((entry) => {
if (keys.has(entry.key) || pluginKeys.has(entry.pluginKey) || directories.has(entry.directory)) {
throw new Error("Duplicate or built-in distribution plugin identity");
}
keys.add(entry.key); pluginKeys.add(entry.pluginKey); directories.add(entry.directory);
const localPath = path.join(root, entry.directory);
const info = fs.lstatSync(localPath);
if (!info.isDirectory() || info.isSymbolicLink()) throw new Error("Invalid distribution bundle directory");
if (distributionBundleDigest(localPath) !== entry.digest) throw new Error(`Distribution bundle digest mismatch: ${entry.key}`);
const pkg = JSON.parse(fs.readFileSync(path.join(localPath, "package.json"), "utf8"));
if (pkg.version !== entry.version || !pkg.paperclipPlugin) throw new Error("Distribution package version or entrypoints missing");
for (const name of ["manifest", "worker", "ui"] as const) {
const declared = pkg.paperclipPlugin[name];
if (name === "ui" && declared === undefined) continue;
const relative = 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 };
});
}
@@ -163,6 +163,7 @@ const UI_SLOT_CAPABILITIES: Record<PluginUiSlotType, PluginCapability> = {
taskDetailView: "ui.detailTab.register",
dashboardWidget: "ui.dashboardWidget.register",
globalToolbarButton: "ui.action.register",
appShellOverlay: "ui.action.register",
toolbarButton: "ui.action.register",
contextMenuItem: "ui.action.register",
commentAnnotation: "ui.commentAnnotation.register",
+2 -1
View File
@@ -27,6 +27,7 @@
import { realpath, stat } from "node:fs/promises";
import path from "node:path";
import { BUNDLED_LOCAL_PLUGIN_ROOT } from "./plugin-loader.js";
import { BUNDLED_CATALOG_ROOT_ENV_VAR } from "./bundled-plugins.js";
export { isCloudManagedInstance } from "./cloud-instance.js";
@@ -93,7 +94,7 @@ export async function isWithinBundledPluginRoot(
canonicalPath: string,
bundledRootOverride?: string,
): Promise<boolean> {
const bundledRoot = bundledRootOverride ?? BUNDLED_LOCAL_PLUGIN_ROOT;
const bundledRoot = bundledRootOverride ?? (process.env[BUNDLED_CATALOG_ROOT_ENV_VAR]?.trim() || BUNDLED_LOCAL_PLUGIN_ROOT);
let canonicalRoot: string;
try {
+18 -1
View File
@@ -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<DiscoveredPlugin | null> {
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<PaperclipPluginManifestV1 | null> {
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);
// ------------------------------------------------------------------
+2
View File
@@ -1,4 +1,5 @@
import { ChatSetupSidebarProvider } from "@/context/ChatSetupSidebarContext";
import { PluginAppShellOverlays } from "./PluginAppShellOverlays";
import {
useCallback,
useEffect,
@@ -785,6 +786,7 @@ export function Layout() {
onOpenChange={setShortcutsOpen}
/>
<ToastViewport />
<PluginAppShellOverlays localTrusted={health?.deploymentMode === "local_trusted"} />
</div>
</GeneralSettingsProvider>
</ChatSetupSidebarProvider>
+2
View File
@@ -21,6 +21,7 @@ import { NewAgentDialog } from "./NewAgentDialog";
import { KeyboardShortcutsCheatsheet } from "./KeyboardShortcutsCheatsheet";
import { ToastViewport } from "./ToastViewport";
import { AnnouncementWell } from "./AnnouncementWell";
import { PluginAppShellOverlays } from "./PluginAppShellOverlays";
import { MobileBottomNav } from "./MobileBottomNav";
import { WorktreeBanner } from "./WorktreeBanner";
import { DevRestartBanner } from "./DevRestartBanner";
@@ -788,6 +789,7 @@ export function Layout({ sidebarSections }: { sidebarSections?: ReactNode }) {
<KeyboardShortcutsCheatsheet open={shortcutsOpen} onOpenChange={setShortcutsOpen} />
<ToastViewport />
<AnnouncementWell health={health} />
<PluginAppShellOverlays localTrusted={health?.deploymentMode === "local_trusted"} />
</div>
</GeneralSettingsProvider>
</ChatSetupSidebarProvider>
@@ -0,0 +1,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 <button onClick={() => setDraft("private draft")}>{draft || "empty"}</button>;
},
}));
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(<PluginAppShellOverlays />));
}
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("");
});
});
@@ -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 (
<aside className="plugin-app-shell-overlays" aria-label="Application extensions">
{slots.map((slot) => (
<PluginSlotMount
key={`${slot.pluginId}:${slot.pluginVersion}:${slot.id}`}
slot={slot}
context={context}
className="plugin-app-shell-overlay"
/>
))}
</aside>
);
}
/**
* One persistent mount in each application shell. Navigation preserves the
* plugin tree; changing account/company, signing out, or entering onboarding
* disposes it. Plugins must cancel their requests/subscriptions on disposal.
* Host context is display context, never proof of server authorization.
*/
export function PluginAppShellOverlays({ localTrusted = false }: { localTrusted?: boolean }) {
const { userId, settled } = useAccountIdentity();
const { selectedCompanyId, selectedCompany, loading } = useCompany();
const { onboardingOpen } = useDialogState();
const identity = localTrusted ? "local-board" : settled ? userId : null;
if (!identity || loading || onboardingOpen) return null;
return (
<AppShellEntries
key={JSON.stringify([identity, selectedCompanyId])}
context={{ companyId: selectedCompanyId, companyPrefix: selectedCompany?.issuePrefix ?? null }}
/>
);
}
+24
View File
@@ -3085,3 +3085,27 @@ span.paperclip-mention-chip[data-mention-kind="external-object"] {
--agent-cap-v1-muted-dream-a: #a6aaad;
--agent-cap-v1-muted-dream-b: #44464a;
}
/* Persistent plugin surface. The host reserves mobile navigation/safe-area
space; each extension owns its accessible panel and focus lifecycle. */
:root {
--plugin-shell-gap: calc(var(--spacing) * 3);
--plugin-shell-inset: calc(var(--spacing) * 4);
--plugin-shell-mobile-bottom: calc(var(--sz-calc-14) + var(--sz-safe-bottom) + var(--plugin-shell-inset));
}
.plugin-app-shell-overlays {
position: fixed;
inset-inline-end: max(var(--plugin-shell-inset), env(safe-area-inset-right));
bottom: max(var(--plugin-shell-inset), var(--sz-safe-bottom));
z-index: var(--z-20);
display: flex;
flex-direction: column;
align-items: flex-end;
gap: var(--plugin-shell-gap);
max-width: calc(100vw - var(--plugin-shell-inset) * 2);
pointer-events: none;
}
.plugin-app-shell-overlay { pointer-events: auto; }
@media (max-width: 767px) {
.plugin-app-shell-overlays { bottom: var(--plugin-shell-mobile-bottom); }
}