mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-07 16:11:46 +02:00
feat(plugins): load image-owned catalogs and persistent app overlays
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
64895f187b
commit
be9c775d9e
17 files changed
+470
-2
No files matched your search
@@ -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.
|
||||
@@ -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).
|
||||
@@ -360,6 +360,7 @@ export interface PaperclipPluginManifestV1 {
|
||||
| "sidebarPanel"
|
||||
| "projectSidebarItem"
|
||||
| "globalToolbarButton"
|
||||
| "appShellOverlay"
|
||||
| "toolbarButton"
|
||||
| "contextMenuItem"
|
||||
| "commentAnnotation"
|
||||
|
||||
@@ -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).
|
||||
@@ -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/);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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); }
|
||||
}
|
||||
Reference in new issue
Block a user