mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-02 02:07:25 +08:00
feat(plugins): support image catalogs and persistent application overlays (#13646)
## Thinking Path
> - Paperclip is the open source app people use to manage AI agents for
work.
> - Plugins extend the application without adding each integration to
Core.
> - A downstream image needs a way to supply prebuilt plugins.
> - Some plugin UI must stay mounted as users move between pages.
> - This change adds an image catalog and a persistent application slot.
> - Operators can upgrade or remove these plugins through their image
and configuration.
## Linked Issues or Issue Description
**Subsystem affected**
Plugin packaging, activation and application UI.
**Problem or motivation**
The built-in plugin catalog is fixed in Core source. Downstream images
cannot add entries through an explicit catalog. Existing page slots also
cannot preserve a small application overlay across route changes.
**Proposed solution**
Read a bounded catalog of prebuilt plugins from the image. Verify its
files before importing manifests. Use the existing managed selection and
plugin lifecycle. Add an `appShellOverlay` slot with account and company
cleanup.
**Alternatives considered**
A downstream fork adds merge work. Script injection provides no plugin
lifecycle. A separate runtime download system adds a second distribution
channel.
**Roadmap alignment**
This extends the existing plugin system. Related PR #9006 covers runtime
install replication; this change covers immutable image contents. PR
#12555 covers CLI scaffolding. Neither provides this catalog or
application slot. The maintainer requested this work directly.
## What Changed
- Validate catalog identities, confined paths, package versions and
bundle hashes before importing code.
- Apply image selection to persisted plugin installs, including removal
and rollback. Adopt the verified image path from existing npm/local
installs and bind runtime worker/UI entrypoints to verified package
declarations.
- Mount application overlays in both UI shells. Preserve route state and
clear it on account, company and onboarding changes.
- Restrict service-worker offline storage/fallback to hashed public
assets in a separate cache namespace; exclude application HTML and
extension/API data, including after worker restart.
- Document the packaging contract, trust model and rollback
requirements.
## Verification
- Passed `pnpm -r typecheck`, `pnpm build`, and `pnpm
check:token-gates`. Affected server/UI typechecks and builds, plus token
gates, passed again after rebasing onto current master; the 124 focused
tests also passed after rebase.
- Latest focused verification: 124 tests in nine files passed for
catalog/reconciliation/loader, overlay lifecycle, Layout and
service-worker policy. The broader UI/shared/SDK run passed 7,204 tests
in 690 files with canonical `TMPDIR`.
- Real disposable Core/PostgreSQL: catalog install, selection removal,
0.1.0→0.1.1→0.1.0, same-version npm/legacy-path adoption, and
preservation of disabled status passed. Added permissions entered
`upgrade_pending`, withheld UI across restart, and activated only after
explicit operator enable.
- Real Chromium: desktop/mobile layout, route draft retention and
Escape/focus passed with mocked extension responses. A persistent
browser restart retained public hashed-asset offline fallback while
refusing seeded legacy/current private entries and legacy HTML.
- Full `pnpm test:run`: 12,539 passed; 17 failed across six existing
files, stopping later phases. macOS read-only directory renames fail in
runtime-skill-cache and company-skills-service; email tests require an
absent local AgentMail fixture. Native runner/comment-redaction passed
in isolation after temporary Rust setup; agent-conversations also passed
in isolation. No unrelated source was changed to hide failures.
- After rebase, two unchanged chat timing tests failed in CI and passed
locally in isolation. Their CI shard passed on its single retry. All
other current-head CI jobs passed on the initial run; review is 5/5 with
no unresolved threads.
- No live deployment or external plugin service was used.
## Risks
- Plugins are trusted code. The catalog detects packaging errors; it
does not authenticate an untrusted image builder.
- Invalid catalogs fail startup. Images must contain the catalog and
bundles together, with stable directories.
- A host older than this contract lacks the activation guard. Disable
added plugins and remove their configuration keys before reverting to
it.
- Offline navigation now returns 503 instead of replaying cached
application HTML. Only public build assets have offline fallback.
- Rolling back an unapproved permission change retains the approval
gate; review the current manifest and explicitly enable it. A reduced
permission set cannot establish prior approval or prior enabled status.
- Plugin data migrations need their own rollback policy. This change
retains installed records and does not reverse migrations.
## Model Used
- OpenAI GPT-6 (Codex), model ID `gpt-6`, with repository inspection,
code execution and browser verification. The runtime does not expose an
exact context-window size.
## Checklist
- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass (relevant suites; broad
macOS server-run exceptions are documented above)
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green (fresh run on 488b3754ae; chat
shard passed its single retry)
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
(fresh review on 488b3754ae)
- [x] I will address all Greptile and reviewer comments before
requesting merge
---------
Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
@@ -0,0 +1,111 @@
|
||||
# Plugins supplied by an application distribution
|
||||
|
||||
A downstream image can add prebuilt plugins without changing Paperclip's
|
||||
built-in catalog. The operator owns the image and trusts its plugin code.
|
||||
This is packaging and activation policy, not a sandbox or entitlement system.
|
||||
Ordinary self-hosted images need no catalog and keep their existing behavior.
|
||||
|
||||
## Image layout
|
||||
|
||||
Use `distribution/catalog.json` beneath `PAPERCLIP_BUNDLED_PLUGIN_ROOT`
|
||||
(default `/app/packages/plugins`). Each plugin has a stable directory below
|
||||
`distribution/`, containing its `package.json`, compiled manifest, worker and
|
||||
optional UI. Bundle runtime dependencies; startup never installs them.
|
||||
|
||||
```json
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"plugins": [{
|
||||
"key": "example-extension",
|
||||
"pluginKey": "example.extension",
|
||||
"version": "1.0.0",
|
||||
"directory": "example-extension",
|
||||
"digest": "sha256:<64 lowercase hex characters>"
|
||||
}]
|
||||
}
|
||||
```
|
||||
|
||||
The digest covers a sorted depth-first file inventory. Each entry is
|
||||
`[relativePosixPath, "sha256:" + sha256(fileBytes), permissionBits & 0777]`.
|
||||
Hash the UTF-8 JSON serialization of the inventory and prefix it with
|
||||
`sha256:`. The server's `distributionBundleDigest` implements this contract.
|
||||
There are no symbolic links or special files. A bundle is limited to 10,000
|
||||
files, 256 MiB and 32 directory levels. Catalog keys, plugin IDs and directory
|
||||
names must be unique; a distribution cannot replace a built-in key or ID.
|
||||
|
||||
On startup, the host validates the catalog, hashes the bundle before importing
|
||||
its executable manifest, and validates package version and confined prebuilt
|
||||
entrypoints. Malformed catalogs and integrity failures stop startup. Deploy
|
||||
the catalog and bundles atomically as part of the image; keep them read-only
|
||||
in operation. This detects packaging errors but does not authenticate an
|
||||
untrusted image builder. Image provenance and signatures remain deployment
|
||||
responsibilities.
|
||||
|
||||
## Selection, upgrades and rollback
|
||||
|
||||
Managed instances select a distribution key through the existing
|
||||
`plugins.autoInstall` list. Existing install, capability validation, API
|
||||
compatibility, worker and health mechanisms apply. A worker or install failure
|
||||
is recorded as a plugin error without taking down the application.
|
||||
|
||||
The catalog alone does not auto-enable plugins on self-hosted instances.
|
||||
Operators can explicitly install catalog entries through the normal plugin
|
||||
CLI. The package's manifest ID and version must match the catalog.
|
||||
The manifest's worker and optional UI entrypoints must match the verified
|
||||
`package.json` declarations and stay inside the bundle.
|
||||
|
||||
At boot, selected distribution entries adopt the current image's package path
|
||||
even when a previous npm or local install has the same version. Reconciliation
|
||||
keeps the registry ID, configuration and stored state. With unchanged permissions,
|
||||
operator-disabled status is retained. A replacement that adds capabilities is
|
||||
saved atomically in `upgrade_pending`, even for same-version bundles. It cannot
|
||||
activate until an operator reviews the manifest and enables it through the normal
|
||||
plugin lifecycle. Invalid capability declarations are rejected before persistence.
|
||||
Runtime refreshes also reject unapproved capability additions before starting code.
|
||||
Rolling back an unapproved replacement refreshes the displayed manifest but
|
||||
retains `upgrade_pending`. Review the rollback manifest and explicitly enable it
|
||||
to resume. A smaller capability set alone cannot prove prior approval: it may
|
||||
retain an unapproved permission, and the plugin may originally have been disabled.
|
||||
Ordinary upgrades/downgrades of an approved, ready plugin continue automatically.
|
||||
|
||||
Keep each key's directory stable across releases. The activation guard also
|
||||
covers persisted installs: a plugin removed from the image catalog, or no
|
||||
longer selected in managed configuration, cannot activate on restart. Its
|
||||
stored image path remains the source marker if the directory disappears; package
|
||||
resolution cannot substitute an npm copy. Keep the catalog root stable as well.
|
||||
An explicit operator reinstall changes a package's source; editing database rows
|
||||
or replacing the catalog root is outside this image-selection contract. Plugin
|
||||
database records remain for rollback. Plugin data migrations must themselves
|
||||
support the intended rollback window; removing a bundle does not undo them.
|
||||
|
||||
A deployment controller must generate `plugins.autoInstall` from the **target
|
||||
image's** catalog. A union of catalogs from different releases is insufficient:
|
||||
an older image rejects a key it does not know. Before reverting to a host
|
||||
version that predates this catalog contract, disable the distribution plugins
|
||||
and remove their keys from configuration. Such older hosts do not have the
|
||||
new activation guard.
|
||||
|
||||
## Persistent application UI
|
||||
|
||||
The `appShellOverlay` slot requires `ui.action.register`. It receives the usual
|
||||
`PluginWidgetProps` context. It mounts once in both application shells and
|
||||
survives route navigation. It is disposed when the account or selected company
|
||||
changes, during onboarding, and on sign-out. It is not mounted on login pages.
|
||||
Local-trusted mode has no login requirement: its sessionless board may mount
|
||||
overlays, but transitions to or from an account still dispose the prior state.
|
||||
|
||||
The host positions contributions above the mobile navigation and stacks them
|
||||
at the bottom right. Each plugin owns its launcher, panel, keyboard handling,
|
||||
focus restoration, accessible labels and request cancellation. Use a bounded,
|
||||
responsive panel. This slot is not a launcher placement zone and does not
|
||||
replace modal/launcher APIs. Errors remain inside the existing plugin mount
|
||||
error boundary.
|
||||
|
||||
UI code is trusted browser code. Host context is display context, never proof
|
||||
of server authorization. A distribution backend must independently validate
|
||||
the signed-in session and enforce company, tenant and user access rules for
|
||||
every read and mutation. Keep provider secrets out of plugin UI and manifests.
|
||||
The service worker's offline cache accepts only same-origin, hashed build assets
|
||||
under `/assets/`. It does not store or replay application HTML, extension routes
|
||||
or API data. This policy remains in effect after worker restarts and does not read
|
||||
the arbitrary-response caches created by older workers.
|
||||
@@ -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",
|
||||
|
||||
@@ -215,12 +215,13 @@ type LooseRow = {
|
||||
version?: string;
|
||||
manifestJson?: Record<string, unknown>;
|
||||
lastError?: string | null;
|
||||
packagePath?: string | null;
|
||||
};
|
||||
|
||||
// Build a minimal manifest for a persisted row or a shipped bundle. The reconcile
|
||||
// step compares the bundle version with the persisted version.
|
||||
function makeManifest(pluginKey: string, version: string) {
|
||||
return { id: pluginKey, apiVersion: 1, version } as unknown as import("@paperclipai/shared").PaperclipPluginManifestV1;
|
||||
return { id: pluginKey, apiVersion: 1, version, capabilities: [] } as unknown as import("@paperclipai/shared").PaperclipPluginManifestV1;
|
||||
}
|
||||
|
||||
function makeDeps(overrides?: {
|
||||
@@ -402,6 +403,112 @@ describe("ensureBundledPlugins", () => {
|
||||
expect(update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([null, "/old/plugins/widget"])("adopts a selected distribution path from %s even at the same version", async (packagePath) => {
|
||||
const localPath = path.join(CATALOG_ROOT, "distribution/widget");
|
||||
const distribution = { key: "widget", pluginKey: "acme.widget", version: "0.1.0", directory: "widget", digest: `sha256:${"a".repeat(64)}`, localPath, entrypoints: { worker: "dist/worker.js" } };
|
||||
for (const status of ["ready", "disabled", "error"]) {
|
||||
const { deps, loadManifest, update, installPlugin, updateStatus } = makeDeps({
|
||||
rows: { "acme.widget": { id: "row-widget", pluginKey: "acme.widget", status, version: "0.1.0", packagePath } },
|
||||
// Distribution packages may declare a manifest outside dist/manifest.js.
|
||||
bundleManifestExists: () => false,
|
||||
});
|
||||
const manifest = makeManifest("acme.widget", "0.1.0");
|
||||
loadManifest.mockResolvedValue(manifest);
|
||||
await ensureBundledPlugins([{ ...distribution, distribution }], deps, { reinstallUninstalled: true });
|
||||
// Same row: retain config/state and operator-disabled status; only error
|
||||
// gets the existing one-shot boot retry. No reinstall/capability reset.
|
||||
expect(update).toHaveBeenCalledExactlyOnceWith("row-widget", { packagePath: localPath, version: "0.1.0", manifest });
|
||||
expect(installPlugin).not.toHaveBeenCalled();
|
||||
expect(updateStatus).toHaveBeenCalledTimes(status === "error" ? 1 : 0);
|
||||
expect(loadManifest).toHaveBeenCalledExactlyOnceWith(localPath);
|
||||
expect(deps.logger.error).not.toHaveBeenCalled();
|
||||
}
|
||||
});
|
||||
|
||||
it("does not rebind a distribution install whose new manifest fails validation", async () => {
|
||||
const localPath = path.join(CATALOG_ROOT, "distribution/widget");
|
||||
const distribution = { key: "widget", pluginKey: "acme.widget", version: "0.1.0", directory: "widget", digest: `sha256:${"a".repeat(64)}`, localPath, entrypoints: { worker: "dist/worker.js" } };
|
||||
const { deps, loadManifest, update, updateStatus } = makeDeps({
|
||||
rows: { "acme.widget": { id: "row-widget", pluginKey: "acme.widget", status: "error", packagePath: "/old/widget" } },
|
||||
});
|
||||
loadManifest.mockRejectedValue(new Error("Distribution manifest entrypoints do not match the verified package"));
|
||||
await ensureBundledPlugins([{ ...distribution, distribution }], deps, { reinstallUninstalled: true });
|
||||
expect(update).not.toHaveBeenCalled();
|
||||
expect(updateStatus).not.toHaveBeenCalled();
|
||||
expect(deps.logger.error).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each(["ready", "error", "disabled"])("gates same-version distribution capability additions from %s atomically", async (status) => {
|
||||
const localPath = path.join(CATALOG_ROOT, "distribution/widget");
|
||||
const distribution = { key: "widget", pluginKey: "acme.widget", version: "0.1.0", directory: "widget", digest: `sha256:${"a".repeat(64)}`, localPath, entrypoints: { worker: "dist/worker.js" } };
|
||||
const oldManifest = makeManifest("acme.widget", "0.1.0");
|
||||
const { deps, loadManifest, update, updateStatus, installPlugin } = makeDeps({
|
||||
rows: { "acme.widget": { id: "row-widget", pluginKey: "acme.widget", status, packagePath: localPath, manifestJson: { ...oldManifest } } },
|
||||
});
|
||||
const replacement = { ...oldManifest, capabilities: ["issues.read" as const] };
|
||||
loadManifest.mockResolvedValue(replacement);
|
||||
await ensureBundledPlugins([{ ...distribution, distribution }], deps, { reinstallUninstalled: true });
|
||||
expect(update).toHaveBeenCalledExactlyOnceWith("row-widget", { version: "0.1.0", manifest: replacement, status: "upgrade_pending" });
|
||||
expect(updateStatus).not.toHaveBeenCalled();
|
||||
expect(installPlugin).not.toHaveBeenCalled();
|
||||
expect(deps.lifecycle.load).not.toHaveBeenCalled();
|
||||
|
||||
// A later boot must leave the approval gate in place.
|
||||
vi.mocked(deps.registry.getByKey).mockResolvedValue({ id: "row-widget", pluginKey: "acme.widget", status: "upgrade_pending", version: "0.1.0", packagePath: localPath, manifestJson: replacement });
|
||||
await ensureBundledPlugins([{ ...distribution, distribution }], deps, { reinstallUninstalled: true });
|
||||
expect(update).toHaveBeenCalledTimes(1);
|
||||
expect(updateStatus).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not save an inconsistent distribution manifest", async () => {
|
||||
const localPath = path.join(CATALOG_ROOT, "distribution/widget");
|
||||
const distribution = { key: "widget", pluginKey: "acme.widget", version: "0.1.0", directory: "widget", digest: `sha256:${"a".repeat(64)}`, localPath, entrypoints: { worker: "dist/worker.js" } };
|
||||
const { deps, loadManifest, update } = makeDeps({ rows: { "acme.widget": { id: "row-widget", pluginKey: "acme.widget", status: "ready", packagePath: localPath } } });
|
||||
const manifest = makeManifest("acme.widget", "0.1.0");
|
||||
manifest.ui = { slots: [{ type: "appShellOverlay", id: "overlay", displayName: "Overlay", exportName: "Overlay" }] };
|
||||
loadManifest.mockResolvedValue(manifest);
|
||||
await ensureBundledPlugins([{ ...distribution, distribution }], deps, { reinstallUninstalled: true });
|
||||
expect(update).not.toHaveBeenCalled();
|
||||
expect(deps.logger.error).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not replace an operator uninstall with a distribution approval request", async () => {
|
||||
const localPath = path.join(CATALOG_ROOT, "distribution/widget");
|
||||
const distribution = { key: "widget", pluginKey: "acme.widget", version: "0.1.0", directory: "widget", digest: `sha256:${"a".repeat(64)}`, localPath, entrypoints: { worker: "dist/worker.js" } };
|
||||
const { deps, loadManifest, update, updateStatus, installPlugin } = makeDeps({ rows: { "acme.widget": { id: "row-widget", pluginKey: "acme.widget", status: "uninstalled" } } });
|
||||
loadManifest.mockResolvedValue({ ...makeManifest("acme.widget", "0.1.0"), capabilities: ["issues.read"] });
|
||||
await ensureBundledPlugins([{ ...distribution, distribution }], deps, { reinstallUninstalled: false });
|
||||
expect(update).not.toHaveBeenCalled();
|
||||
expect(updateStatus).not.toHaveBeenCalled();
|
||||
expect(installPlugin).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("refreshes a same-version rollback while retaining the pending operator decision", async () => {
|
||||
const localPath = path.join(CATALOG_ROOT, "distribution/widget");
|
||||
const distribution = { key: "widget", pluginKey: "acme.widget", version: "0.1.0", directory: "widget", digest: `sha256:${"a".repeat(64)}`, localPath, entrypoints: { worker: "dist/worker.js" } };
|
||||
const pending = { ...makeManifest("acme.widget", "0.1.0"), capabilities: ["issues.read", "issues.update"] };
|
||||
const { deps, loadManifest, update, updateStatus } = makeDeps({ rows: { "acme.widget": { id: "row-widget", pluginKey: "acme.widget", status: "upgrade_pending", packagePath: localPath, manifestJson: pending } } });
|
||||
// A smaller capability set is not proof that every remaining capability
|
||||
// was approved, or that the plugin was enabled before the pending upgrade.
|
||||
const rollback = { ...makeManifest("acme.widget", "0.1.0"), capabilities: ["issues.read" as const] };
|
||||
loadManifest.mockResolvedValue(rollback);
|
||||
await ensureBundledPlugins([{ ...distribution, distribution }], deps, { reinstallUninstalled: true });
|
||||
expect(update).toHaveBeenCalledExactlyOnceWith("row-widget", { version: "0.1.0", manifest: rollback });
|
||||
expect(updateStatus).not.toHaveBeenCalled();
|
||||
expect(deps.lifecycle.load).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("ignores object key order when comparing a distribution manifest read from JSONB", async () => {
|
||||
const localPath = path.join(CATALOG_ROOT, "distribution/widget");
|
||||
const distribution = { key: "widget", pluginKey: "acme.widget", version: "0.1.0", directory: "widget", digest: `sha256:${"a".repeat(64)}`, localPath, entrypoints: { worker: "dist/worker.js" } };
|
||||
const manifest = { ...makeManifest("acme.widget", "0.1.0"), entrypoints: { worker: "dist/worker.js", ui: "dist/ui" } };
|
||||
const reordered = { entrypoints: { ui: "dist/ui", worker: "dist/worker.js" }, capabilities: [], version: "0.1.0", apiVersion: 1, id: "acme.widget" };
|
||||
const { deps, loadManifest, update } = makeDeps({ rows: { "acme.widget": { id: "row-widget", pluginKey: "acme.widget", status: "ready", packagePath: localPath, manifestJson: reordered } } });
|
||||
loadManifest.mockResolvedValue(manifest);
|
||||
await ensureBundledPlugins([{ ...distribution, distribution }], deps, { reinstallUninstalled: true });
|
||||
expect(update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("swallows a reconcile error and continues boot", async () => {
|
||||
const { deps, update } = makeDeps({
|
||||
rows: {
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
import { mkdtempSync, mkdirSync, rmSync, writeFileSync, symlinkSync, realpathSync } from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import type { PaperclipPluginManifestV1 } from "@paperclipai/shared";
|
||||
import { distributionBundleDigest, distributionPluginActivationGuard, readDistributionPluginCatalog } from "../services/distribution-plugin-catalog.js";
|
||||
import { BUNDLED_PLUGIN_CATALOG, resolveBundledPluginInstalls } from "../services/bundled-plugins.js";
|
||||
|
||||
const roots: string[] = [];
|
||||
afterEach(() => { for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); });
|
||||
function fixture() {
|
||||
const root = realpathSync(mkdtempSync(path.join(os.tmpdir(), "distribution-plugin-"))); roots.push(root);
|
||||
const localPath = path.join(root, "distribution", "acme.widget");
|
||||
mkdirSync(path.join(localPath, "dist", "ui"), { recursive: true });
|
||||
writeFileSync(path.join(localPath, "package.json"), JSON.stringify({ name: "@acme/plugin-widget", version: "1.0.0", paperclipPlugin: { manifest: "./dist/manifest.js", worker: "./dist/worker.js", ui: "./dist/ui/" } }));
|
||||
writeFileSync(path.join(localPath, "dist", "manifest.js"), "export default {};");
|
||||
writeFileSync(path.join(localPath, "dist", "worker.js"), "export default {};");
|
||||
const entry = { key: "acme.widget", pluginKey: "acme.widget", version: "1.0.0", directory: "acme.widget", digest: distributionBundleDigest(localPath) };
|
||||
const save = (plugins: unknown[] = [entry]) => writeFileSync(path.join(root, "distribution", "catalog.json"), JSON.stringify({ schemaVersion: 1, plugins }));
|
||||
save(); return { root, localPath, entry, save };
|
||||
}
|
||||
describe("image-owned plugin catalogs", () => {
|
||||
it("preserves images without a distribution catalog", () => {
|
||||
expect(readDistributionPluginCatalog("/nonexistent/catalog", BUNDLED_PLUGIN_CATALOG)).toEqual([]);
|
||||
});
|
||||
it("resolves selected private bundles alongside built-ins after verifying their bytes", () => {
|
||||
const { root, localPath } = fixture();
|
||||
const installs = resolveBundledPluginInstalls(["daytona", "acme.widget"], { catalogRoot: root, env: {}, enforceCatalogRoot: true });
|
||||
expect(installs.map(({ key }) => key)).toEqual(["daytona", "acme.widget"]);
|
||||
expect(installs[1]?.localPath).toBe(localPath);
|
||||
});
|
||||
it("rejects tampered bundle bytes before importing manifest code", () => {
|
||||
const { root, localPath } = fixture();
|
||||
writeFileSync(path.join(localPath, "dist", "manifest.js"), "throw new Error('must never execute');");
|
||||
expect(() => readDistributionPluginCatalog(root, BUNDLED_PLUGIN_CATALOG)).toThrow(/digest mismatch/);
|
||||
});
|
||||
it("rejects duplicate keys, plugin identities and built-in replacement", () => {
|
||||
const { root, entry, save } = fixture();
|
||||
for (const plugins of [[entry, entry], [{ ...entry, key: "daytona" }], [{ ...entry, pluginKey: "paperclip.daytona-sandbox-provider" }]]) {
|
||||
save(plugins);
|
||||
expect(() => readDistributionPluginCatalog(root, BUNDLED_PLUGIN_CATALOG)).toThrow(/Duplicate or built-in/);
|
||||
}
|
||||
});
|
||||
it("rejects traversal, symlinked artifacts and unbuilt entrypoints", () => {
|
||||
const { root, entry, localPath, save } = fixture();
|
||||
save([{ ...entry, directory: "../elsewhere" }]);
|
||||
expect(() => readDistributionPluginCatalog(root, BUNDLED_PLUGIN_CATALOG)).toThrow();
|
||||
save(); symlinkSync(os.tmpdir(), path.join(localPath, "outside"));
|
||||
expect(() => readDistributionPluginCatalog(root, BUNDLED_PLUGIN_CATALOG)).toThrow(/symlinks/);
|
||||
rmSync(path.join(localPath, "outside")); rmSync(path.join(localPath, "dist", "worker.js"));
|
||||
save([{ ...entry, digest: distributionBundleDigest(localPath) }]);
|
||||
expect(() => readDistributionPluginCatalog(root, BUNDLED_PLUGIN_CATALOG)).toThrow();
|
||||
});
|
||||
it("blocks a persisted plugin after deselection/removal while preserving ordinary plugins", () => {
|
||||
const { root, localPath } = fixture();
|
||||
const entries = readDistributionPluginCatalog(root, BUNDLED_PLUGIN_CATALOG);
|
||||
const input = { pluginKey: "acme.widget", packageRoot: localPath };
|
||||
expect(() => distributionPluginActivationGuard(root, entries, ["acme.widget"])(input)).not.toThrow();
|
||||
expect(() => distributionPluginActivationGuard(root, entries, ["acme.widget"])({ packageRoot: localPath })).not.toThrow();
|
||||
expect(() => distributionPluginActivationGuard(root, entries, [])({ packageRoot: localPath })).toThrow(/not selected/);
|
||||
expect(() => distributionPluginActivationGuard(root, entries, [])(input)).toThrow(/not selected/);
|
||||
expect(() => distributionPluginActivationGuard(root, [], [])(input)).toThrow(/absent/);
|
||||
expect(() => distributionPluginActivationGuard(root, [], [])({ ...input, packageRoot: path.join(root, "node_modules/acme"), installedPackagePath: localPath })).toThrow(/absent/);
|
||||
expect(() => distributionPluginActivationGuard(root, [], [])({ pluginKey: "ordinary.plugin", packageRoot: path.join(root, "ordinary") })).not.toThrow();
|
||||
});
|
||||
it("rejects dangling catalog symlinks instead of treating them as absent", () => {
|
||||
const { root } = fixture();
|
||||
const file = path.join(root, "distribution", "catalog.json");
|
||||
rmSync(file); symlinkSync(path.join(root, "missing"), file);
|
||||
expect(() => readDistributionPluginCatalog(root, BUNDLED_PLUGIN_CATALOG)).toThrow(/Invalid distribution catalog/);
|
||||
});
|
||||
it("requires catalog identity and version before activating imported manifests", () => {
|
||||
const { root, localPath } = fixture();
|
||||
const entries = readDistributionPluginCatalog(root, BUNDLED_PLUGIN_CATALOG);
|
||||
const guard = distributionPluginActivationGuard(root, entries, ["acme.widget"]);
|
||||
for (const manifest of [{ id: "other.plugin", version: "1.0.0" }, { id: "acme.widget", version: "2.0.0" }]) {
|
||||
expect(() => guard({ pluginKey: "acme.widget", packageRoot: localPath, manifest: manifest as PaperclipPluginManifestV1 })).toThrow(/identity\/version/);
|
||||
}
|
||||
});
|
||||
|
||||
it("binds runtime worker and UI entrypoints to the digest-verified package declarations", () => {
|
||||
const { root, localPath } = fixture();
|
||||
const entries = readDistributionPluginCatalog(root, BUNDLED_PLUGIN_CATALOG);
|
||||
const guard = distributionPluginActivationGuard(root, entries, ["acme.widget"]);
|
||||
const manifest = { id: "acme.widget", version: "1.0.0", capabilities: [], entrypoints: { worker: "dist/worker.js", ui: "./dist/ui" } } as unknown as PaperclipPluginManifestV1;
|
||||
expect(() => guard({ packageRoot: localPath, manifest })).not.toThrow();
|
||||
for (const name of ["worker", "ui"] as const) {
|
||||
for (const value of ["/outside/worker.js", "../outside", "dist/../worker.js", "C:/outside", "dist\\worker.js", "dist/other", "", undefined]) {
|
||||
const invalid = { ...manifest, entrypoints: { ...manifest.entrypoints, [name]: value } } as PaperclipPluginManifestV1;
|
||||
expect(() => guard({ packageRoot: localPath, manifest: invalid })).toThrow(/entrypoint/);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("accepts worker-only bundles but rejects a UI path absent from verified metadata", () => {
|
||||
const { root, localPath, entry, save } = fixture();
|
||||
writeFileSync(path.join(localPath, "package.json"), JSON.stringify({ version: "1.0.0", paperclipPlugin: { manifest: "./dist/manifest.js", worker: "./dist/worker.js" } }));
|
||||
save([{ ...entry, digest: distributionBundleDigest(localPath) }]);
|
||||
const guard = distributionPluginActivationGuard(root, readDistributionPluginCatalog(root, BUNDLED_PLUGIN_CATALOG), null);
|
||||
const manifest = { id: "acme.widget", version: "1.0.0", capabilities: [], entrypoints: { worker: "./dist/worker.js" } } as unknown as PaperclipPluginManifestV1;
|
||||
expect(() => guard({ packageRoot: localPath, manifest })).not.toThrow();
|
||||
manifest.entrypoints.ui = "./dist/ui";
|
||||
expect(() => guard({ packageRoot: localPath, manifest })).toThrow(/verified package/);
|
||||
});
|
||||
|
||||
it("rejects inconsistent capabilities and unapproved runtime refreshes", () => {
|
||||
const { root, localPath } = fixture();
|
||||
const guard = distributionPluginActivationGuard(root, readDistributionPluginCatalog(root, BUNDLED_PLUGIN_CATALOG), null);
|
||||
const previousManifest = { id: "acme.widget", version: "1.0.0", capabilities: [], entrypoints: { worker: "./dist/worker.js", ui: "./dist/ui" } } as unknown as PaperclipPluginManifestV1;
|
||||
const manifest = { ...previousManifest, capabilities: ["issues.read" as const] };
|
||||
expect(() => guard({ packageRoot: localPath, manifest, previousManifest })).toThrow(/require approval/);
|
||||
expect(() => guard({ packageRoot: localPath, manifest, previousManifest: manifest })).not.toThrow();
|
||||
previousManifest.ui = { slots: [{ type: "appShellOverlay", id: "overlay", displayName: "Overlay", exportName: "Overlay" }] };
|
||||
expect(() => guard({ packageRoot: localPath, manifest: previousManifest })).toThrow(/missing required capabilities: ui.action.register/);
|
||||
});
|
||||
});
|
||||
@@ -12,6 +12,10 @@
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import { mkdtempSync, mkdirSync, writeFileSync, rmSync, realpathSync } from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { distributionBundleDigest, distributionPluginActivationGuard, readDistributionPluginCatalog } from "../services/distribution-plugin-catalog.js";
|
||||
|
||||
const mockRegistry = vi.hoisted(() => ({
|
||||
getById: vi.fn(),
|
||||
@@ -148,4 +152,59 @@ describe("pluginLoader.loadAll error retry", () => {
|
||||
|
||||
expect(result).toEqual({ total: 0, succeeded: 0, failed: 0, results: [] });
|
||||
});
|
||||
|
||||
it("rejects distribution capability escalation before saving a runtime refresh or starting a worker", async () => {
|
||||
const root = realpathSync(mkdtempSync(path.join(os.tmpdir(), "distribution-refresh-")));
|
||||
try {
|
||||
const packageRoot = path.join(root, "distribution", "example");
|
||||
mkdirSync(path.join(packageRoot, "dist"), { recursive: true });
|
||||
const plugin = createPluginRecord({ status: "ready", packagePath: packageRoot });
|
||||
const replacement = { ...plugin.manifestJson, categories: ["ui"], capabilities: ["issues.read"] };
|
||||
writeFileSync(path.join(packageRoot, "package.json"), JSON.stringify({ name: plugin.packageName, version: "1.0.0", type: "module", paperclipPlugin: { manifest: "dist/manifest.js", worker: "dist/worker.js" } }));
|
||||
writeFileSync(path.join(packageRoot, "dist/manifest.js"), `export default ${JSON.stringify(replacement)};`);
|
||||
writeFileSync(path.join(packageRoot, "dist/worker.js"), "throw new Error('unapproved worker must not start');");
|
||||
writeFileSync(path.join(root, "distribution/catalog.json"), JSON.stringify({ schemaVersion: 1, plugins: [{ key: "example", pluginKey: plugin.pluginKey, version: "1.0.0", directory: "example", digest: distributionBundleDigest(packageRoot) }] }));
|
||||
const runtime = createRuntimeServices();
|
||||
const startWorker = vi.fn();
|
||||
runtime.workerManager.startWorker = startWorker;
|
||||
mockRegistry.getById.mockResolvedValue(plugin);
|
||||
const loader = pluginLoader({} as Db, {
|
||||
localPluginDir: root,
|
||||
assertPackageActivation: distributionPluginActivationGuard(root, readDistributionPluginCatalog(root, []), ["example"]),
|
||||
}, runtime);
|
||||
const result = await loader.loadSingle(plugin.id);
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain("capabilities require approval: issues.read");
|
||||
expect(mockRegistry.update).not.toHaveBeenCalled();
|
||||
expect(startWorker).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("does not import an npm fallback for a removed distribution install", async () => {
|
||||
const root = realpathSync(mkdtempSync(path.join(os.tmpdir(), "distribution-removal-")));
|
||||
try {
|
||||
const packageRoot = path.join(root, "node_modules/@example/broken-plugin");
|
||||
mkdirSync(packageRoot, { recursive: true });
|
||||
const plugin = createPluginRecord({ status: "ready", packagePath: path.join(root, "distribution/removed") });
|
||||
writeFileSync(path.join(packageRoot, "package.json"), JSON.stringify({ name: plugin.packageName, type: "module", paperclipPlugin: { manifest: "manifest.js" } }));
|
||||
writeFileSync(path.join(packageRoot, "manifest.js"), "throw new Error('fallback manifest must never import');");
|
||||
const runtime = createRuntimeServices();
|
||||
const startWorker = vi.fn();
|
||||
runtime.workerManager.startWorker = startWorker;
|
||||
mockRegistry.getById.mockResolvedValue(plugin);
|
||||
const loader = pluginLoader({} as Db, {
|
||||
localPluginDir: root,
|
||||
assertPackageActivation: distributionPluginActivationGuard(root, [], []),
|
||||
}, runtime);
|
||||
const result = await loader.loadSingle(plugin.id);
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain("Distribution plugin is absent or not selected");
|
||||
expect(mockRegistry.update).not.toHaveBeenCalled();
|
||||
expect(startWorker).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
+7
-1
@@ -132,10 +132,12 @@ import {
|
||||
} from "./services/plugin-loader.js";
|
||||
import {
|
||||
SELF_HOSTED_AUTO_INSTALL_KEYS,
|
||||
BUNDLED_PLUGIN_CATALOG,
|
||||
ensureBundledPlugins,
|
||||
resolveBundledCatalogRoot,
|
||||
resolveBundledPluginInstalls,
|
||||
} from "./services/bundled-plugins.js";
|
||||
import { readDistributionPluginCatalog, distributionPluginActivationGuard } from "./services/distribution-plugin-catalog.js";
|
||||
import {
|
||||
createPluginWorkerManager,
|
||||
type PluginWorkerManager,
|
||||
@@ -596,12 +598,14 @@ export async function createApp(
|
||||
const managedAutoInstallKeys = opts.managedPluginAutoInstall ?? null;
|
||||
const bundledCatalogRoot =
|
||||
opts.bundledPluginCatalogRoot ?? resolveBundledCatalogRoot(process.env);
|
||||
const distributionPlugins = readDistributionPluginCatalog(bundledCatalogRoot, BUNDLED_PLUGIN_CATALOG);
|
||||
const bundledPluginInstalls = resolveBundledPluginInstalls(
|
||||
managedAutoInstallKeys ?? SELF_HOSTED_AUTO_INSTALL_KEYS,
|
||||
{
|
||||
catalogRoot: bundledCatalogRoot,
|
||||
env: process.env,
|
||||
enforceCatalogRoot: managedAutoInstallKeys !== null,
|
||||
distributionPlugins,
|
||||
},
|
||||
);
|
||||
const managedBundledPluginKeys =
|
||||
@@ -869,6 +873,7 @@ export async function createApp(
|
||||
{
|
||||
localPluginDir: opts.localPluginDir ?? DEFAULT_LOCAL_PLUGIN_DIR,
|
||||
migrationDb: opts.pluginMigrationDb,
|
||||
assertPackageActivation: distributionPluginActivationGuard(bundledCatalogRoot, distributionPlugins, managedAutoInstallKeys),
|
||||
},
|
||||
{
|
||||
workerManager,
|
||||
@@ -1267,7 +1272,8 @@ export async function createApp(
|
||||
{ registry: pluginRegistry, loader, lifecycle, logger },
|
||||
// Managed mode reinstalls soft-uninstalled bundles (the control plane
|
||||
// owns provisioning); self-hosted leaves an operator's uninstall alone.
|
||||
// Operator-DISABLED plugins are never touched in either mode.
|
||||
// Disabled plugins never start automatically. Added distribution permissions
|
||||
// still enter upgrade_pending so enabling them requires an operator decision.
|
||||
{ reinstallUninstalled: managedAutoInstallKeys !== null },
|
||||
)
|
||||
.then(() => loader.loadAll())
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import path from "node:path";
|
||||
import fs from "node:fs";
|
||||
import { isDeepStrictEqual } from "node:util";
|
||||
import type { PaperclipPluginManifestV1 } from "@paperclipai/shared";
|
||||
import { assertDistributionManifestCapabilities, readDistributionPluginCatalog, type DistributionPlugin } from "./distribution-plugin-catalog.js";
|
||||
|
||||
/**
|
||||
* Bundled plugin auto-provisioning.
|
||||
@@ -123,6 +125,8 @@ export interface ResolvedBundledPlugin {
|
||||
pluginKey: string;
|
||||
/** Absolute path handed to `loader.installPlugin({ localPath })`. */
|
||||
localPath: string;
|
||||
/** Image-owned entries require exact manifest identity/version matching. */
|
||||
distribution?: DistributionPlugin;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -161,16 +165,23 @@ export function resolveBundledPluginInstalls(
|
||||
catalogRoot: string;
|
||||
env: Record<string, string | undefined>;
|
||||
enforceCatalogRoot: boolean;
|
||||
distributionPlugins?: readonly DistributionPlugin[];
|
||||
},
|
||||
): ResolvedBundledPlugin[] {
|
||||
const resolved: ResolvedBundledPlugin[] = [];
|
||||
const seen = new Set<string>();
|
||||
const canonicalRoot = canonicalize(opts.catalogRoot);
|
||||
const distributionPlugins = opts.distributionPlugins ?? readDistributionPluginCatalog(opts.catalogRoot, BUNDLED_PLUGIN_CATALOG);
|
||||
for (const key of keys) {
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
const entry = BUNDLED_PLUGIN_CATALOG.find((candidate) => candidate.key === key);
|
||||
if (!entry) {
|
||||
const distribution = distributionPlugins.find((candidate) => candidate.key === key);
|
||||
if (distribution) {
|
||||
resolved.push({ key, pluginKey: distribution.pluginKey, localPath: distribution.localPath, distribution });
|
||||
continue;
|
||||
}
|
||||
const known = BUNDLED_PLUGIN_CATALOG.map((candidate) => candidate.key).join(", ");
|
||||
throw new Error(
|
||||
`bundled plugin auto-install key "${key}" is not in the bundled catalog (known keys: ${known}); refusing to start`,
|
||||
@@ -198,6 +209,7 @@ interface RegistryPluginRow {
|
||||
status: string;
|
||||
version: string;
|
||||
manifestJson: PaperclipPluginManifestV1;
|
||||
packagePath?: string | null;
|
||||
lastError?: string | null;
|
||||
}
|
||||
|
||||
@@ -206,7 +218,7 @@ export interface BundledPluginProvisionerDeps {
|
||||
getByKey(pluginKey: string): Promise<RegistryPluginRow | null>;
|
||||
update(
|
||||
id: string,
|
||||
data: { version?: string; manifest?: PaperclipPluginManifestV1 },
|
||||
data: { version?: string; manifest?: PaperclipPluginManifestV1; packagePath?: string; status?: "upgrade_pending" },
|
||||
): Promise<unknown>;
|
||||
updateStatus(id: string, input: { status: "ready"; lastError: string | null }): Promise<unknown>;
|
||||
};
|
||||
@@ -236,7 +248,9 @@ function defaultBundleManifestExists(localPath: string): boolean {
|
||||
* Reconcile a present bundled plugin's persisted manifest with the shipped
|
||||
* bundle. The bundle is part of the release image, so its manifest is the
|
||||
* source of truth. When the bundle declares a version that differs from the
|
||||
* persisted version, update the stored manifest and version. This propagates
|
||||
* persisted version, update the stored manifest and version. Distribution
|
||||
* entries also adopt the image's package path, including same-version installs.
|
||||
* This propagates
|
||||
* a manifest change (for example a new driver capability) to an existing
|
||||
* install that the auto-install path skips.
|
||||
*
|
||||
@@ -249,24 +263,42 @@ async function reconcileBundledPluginManifest(
|
||||
install: ResolvedBundledPlugin,
|
||||
deps: BundledPluginProvisionerDeps,
|
||||
bundleManifestExists: (localPath: string) => boolean,
|
||||
): Promise<void> {
|
||||
verifiedManifest?: PaperclipPluginManifestV1,
|
||||
): Promise<"upgrade_pending" | undefined> {
|
||||
try {
|
||||
if (!bundleManifestExists(install.localPath)) return;
|
||||
const bundleManifest = await deps.loader.loadManifest(install.localPath);
|
||||
// Managed reinstalls take the install path instead; this branch means the
|
||||
// operator's uninstall must be retained, including its status.
|
||||
if (install.distribution && existing.status === "uninstalled") return;
|
||||
if (!verifiedManifest && !bundleManifestExists(install.localPath)) return;
|
||||
const bundleManifest = verifiedManifest ?? await deps.loader.loadManifest(install.localPath);
|
||||
if (!bundleManifest) return;
|
||||
if (bundleManifest.version === existing.version) return;
|
||||
let requiresApproval = false;
|
||||
if (install.distribution) {
|
||||
assertDistributionManifestCapabilities(bundleManifest);
|
||||
const approved = new Set(existing.manifestJson.capabilities ?? []);
|
||||
requiresApproval = bundleManifest.capabilities.some((capability) => !approved.has(capability));
|
||||
}
|
||||
const rebindPackage = install.distribution && existing.packagePath !== install.localPath && existing.status !== "uninstalled";
|
||||
const refreshDistribution = install.distribution && !isDeepStrictEqual(bundleManifest, existing.manifestJson);
|
||||
if (bundleManifest.version === existing.version && !rebindPackage && !requiresApproval && !refreshDistribution) return;
|
||||
await deps.registry.update(existing.id, {
|
||||
version: bundleManifest.version,
|
||||
manifest: bundleManifest,
|
||||
...(rebindPackage ? { packagePath: install.localPath } : {}),
|
||||
// Persist the replacement and its approval gate in one write. A crash
|
||||
// between separate manifest/status updates must never grant capabilities.
|
||||
...(requiresApproval ? { status: "upgrade_pending" as const } : {}),
|
||||
});
|
||||
deps.logger.info(
|
||||
{
|
||||
pluginKey: install.pluginKey,
|
||||
fromVersion: existing.version,
|
||||
toVersion: bundleManifest.version,
|
||||
...(rebindPackage ? { packagePath: install.localPath } : {}),
|
||||
},
|
||||
"reconciled bundled plugin manifest to the shipped bundle version",
|
||||
);
|
||||
if (requiresApproval) return "upgrade_pending";
|
||||
} catch (err) {
|
||||
deps.logger.error(
|
||||
{ err, pluginKey: install.pluginKey },
|
||||
@@ -357,16 +389,25 @@ export async function ensureBundledPlugins(
|
||||
const bundleManifestExists = deps.bundleManifestExists ?? defaultBundleManifestExists;
|
||||
for (const install of installs) {
|
||||
try {
|
||||
let verifiedManifest: PaperclipPluginManifestV1 | undefined;
|
||||
if (install.distribution) {
|
||||
const manifest = await deps.loader.loadManifest(install.localPath);
|
||||
if (manifest?.id !== install.pluginKey || manifest.version !== install.distribution.version) {
|
||||
throw new Error("Distribution manifest does not match its catalog identity/version");
|
||||
}
|
||||
verifiedManifest = manifest;
|
||||
}
|
||||
const existing = await deps.registry.getByKey(install.pluginKey);
|
||||
if (existing && (existing.status !== "uninstalled" || !opts.reinstallUninstalled)) {
|
||||
// The bundle ships with the release image, so its manifest is the
|
||||
// source of truth for a present plugin. Reconcile the persisted
|
||||
// manifest when the shipped bundle declares a newer version. Without
|
||||
// manifest when the shipped bundle declares a different version. Without
|
||||
// this step a manifest capability added to a bundle never reaches an
|
||||
// existing install, because the auto-install below skips a present
|
||||
// plugin. The reconcile updates only the stored manifest row; the
|
||||
// running worker already runs the shipped code.
|
||||
await reconcileBundledPluginManifest(existing, install, deps, bundleManifestExists);
|
||||
// plugin. Distribution entries also replace a legacy/npm package path
|
||||
// before loadAll resolves the worker. Configuration and status stay put.
|
||||
const reconciledStatus = await reconcileBundledPluginManifest(existing, install, deps, bundleManifestExists, verifiedManifest);
|
||||
if (reconciledStatus === "upgrade_pending") continue;
|
||||
if (existing.status === "error") {
|
||||
await reenableErroredBundledPlugin(existing, install, deps);
|
||||
continue;
|
||||
@@ -379,7 +420,7 @@ export async function ensureBundledPlugins(
|
||||
}
|
||||
// Skip silently when the bundle is absent (e.g. local dev or an image
|
||||
// built without the plugin). Not an error condition.
|
||||
if (!bundleManifestExists(install.localPath)) {
|
||||
if (!verifiedManifest && !bundleManifestExists(install.localPath)) {
|
||||
deps.logger.info(
|
||||
{ pluginKey: install.pluginKey, pluginPath: install.localPath },
|
||||
"bundled plugin bundle not present; skipping auto-install",
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { createHash } from "node:crypto";
|
||||
import { z } from "zod";
|
||||
import type { PaperclipPluginManifestV1 } from "@paperclipai/shared";
|
||||
import { pluginCapabilityValidator } from "./plugin-capability-validator.js";
|
||||
|
||||
const segment = z.string().regex(/^[a-z][a-z0-9.-]{0,99}$/);
|
||||
export const distributionPluginCatalogSchema = z.object({
|
||||
schemaVersion: z.literal(1),
|
||||
plugins: z.array(z.object({
|
||||
key: segment,
|
||||
pluginKey: segment,
|
||||
version: z.string().regex(/^\d+\.\d+\.\d+(?:-[a-zA-Z0-9.-]+)?(?:\+[a-zA-Z0-9.-]+)?$/),
|
||||
directory: segment,
|
||||
digest: z.string().regex(/^sha256:[a-f0-9]{64}$/),
|
||||
}).strict()).max(100),
|
||||
}).strict();
|
||||
|
||||
export type DistributionPlugin = z.infer<typeof distributionPluginCatalogSchema>["plugins"][number] & {
|
||||
localPath: string;
|
||||
/** Normalized paths from the digest-verified package metadata. */
|
||||
entrypoints: { worker: string; ui?: string };
|
||||
};
|
||||
|
||||
function bundleEntrypoint(declared: unknown): string {
|
||||
const relative = typeof declared === "string" ? declared.replace(/^\.\//, "").replace(/\/$/, "") : "";
|
||||
if (!relative || path.posix.isAbsolute(relative) || path.win32.isAbsolute(relative) || relative.includes("\\") || relative.split("/").some((part) => !part || part === "." || part === "..")) {
|
||||
throw new Error("Distribution entrypoint must stay inside its bundle");
|
||||
}
|
||||
return relative;
|
||||
}
|
||||
|
||||
export function assertDistributionManifestCapabilities(manifest: PaperclipPluginManifestV1): void {
|
||||
const result = pluginCapabilityValidator().validateManifestCapabilities(manifest);
|
||||
if (!result.allowed) {
|
||||
throw new Error(`Distribution manifest is missing required capabilities: ${result.missing.join(", ")}`);
|
||||
}
|
||||
}
|
||||
|
||||
export function distributionPluginsRoot(catalogRoot: string): string {
|
||||
// Match canonical paths persisted by local-path installs (for example,
|
||||
// macOS /tmp -> /private/tmp), without permitting a symlinked catalog itself.
|
||||
try { return path.join(fs.realpathSync(catalogRoot), "distribution"); }
|
||||
catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
||||
return path.resolve(catalogRoot, "distribution");
|
||||
}
|
||||
}
|
||||
|
||||
/** Guard all activation paths, including persisted installs after a rollback. */
|
||||
export function distributionPluginActivationGuard(
|
||||
catalogRoot: string,
|
||||
entries: readonly DistributionPlugin[],
|
||||
selectedKeys: readonly string[] | null,
|
||||
) {
|
||||
const root = distributionPluginsRoot(catalogRoot);
|
||||
return (input: { pluginKey?: string; packageRoot: string; installedPackagePath?: string | null; manifest?: PaperclipPluginManifestV1; previousManifest?: PaperclipPluginManifestV1 }) => {
|
||||
let packageRoot: string;
|
||||
try { packageRoot = fs.realpathSync(input.packageRoot); }
|
||||
catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
||||
packageRoot = path.resolve(input.packageRoot);
|
||||
}
|
||||
const entry = entries.find((candidate) => input.pluginKey ? candidate.pluginKey === input.pluginKey : candidate.localPath === packageRoot);
|
||||
const relative = path.relative(root, packageRoot);
|
||||
const inside = relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
|
||||
// A missing image directory can make package resolution fall back to npm.
|
||||
// Retain the persisted image-path provenance even when that directory no
|
||||
// longer exists; the resolved fallback is not an ordinary installation.
|
||||
const installedRelative = input.installedPackagePath ? path.relative(root, path.resolve(input.installedPackagePath)) : null;
|
||||
const installedInside = installedRelative !== null && (installedRelative === "" || (!installedRelative.startsWith("..") && !path.isAbsolute(installedRelative)));
|
||||
if (!inside && !installedInside && !entry) return;
|
||||
if (!entry || (selectedKeys !== null && !selectedKeys.includes(entry.key)) || packageRoot !== entry.localPath) {
|
||||
throw new Error("Distribution plugin is absent or not selected in this deployment");
|
||||
}
|
||||
if (input.manifest && (input.manifest.id !== entry.pluginKey || input.manifest.version !== entry.version)) {
|
||||
throw new Error("Distribution manifest does not match its catalog identity/version");
|
||||
}
|
||||
if (input.manifest) {
|
||||
assertDistributionManifestCapabilities(input.manifest);
|
||||
if (input.previousManifest) {
|
||||
const approved = new Set(input.previousManifest.capabilities);
|
||||
const added = input.manifest.capabilities.filter((capability) => !approved.has(capability));
|
||||
if (added.length) throw new Error(`Distribution plugin capabilities require approval: ${added.join(", ")}`);
|
||||
}
|
||||
const worker = bundleEntrypoint(input.manifest.entrypoints.worker);
|
||||
const ui = input.manifest.entrypoints.ui === undefined ? undefined : bundleEntrypoint(input.manifest.entrypoints.ui);
|
||||
if (worker !== entry.entrypoints.worker || ui !== entry.entrypoints.ui) {
|
||||
throw new Error("Distribution manifest entrypoints do not match the verified package");
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/** Same portable file inventory used by image builders: path, SHA-256, mode. */
|
||||
export function distributionBundleDigest(root: string): string {
|
||||
const inventory: Array<[string, string, number]> = [];
|
||||
let bytesRead = 0;
|
||||
const hash = (value: Buffer | string) => `sha256:${createHash("sha256").update(value).digest("hex")}`;
|
||||
function walk(directory: string, relative = "") {
|
||||
if (relative.split("/").length > 32) throw new Error("Distribution bundle exceeds directory depth limit");
|
||||
for (const name of fs.readdirSync(directory).sort()) {
|
||||
const relativePath = relative ? `${relative}/${name}` : name;
|
||||
const file = path.join(directory, name);
|
||||
const stat = fs.lstatSync(file);
|
||||
if (stat.isSymbolicLink()) throw new Error("Distribution bundles cannot contain symlinks");
|
||||
if (stat.isDirectory()) walk(file, relativePath);
|
||||
else if (stat.isFile()) {
|
||||
bytesRead += stat.size;
|
||||
if (bytesRead > 256 * 1024 * 1024 || inventory.length >= 10_000) throw new Error("Distribution bundle exceeds verification limits");
|
||||
inventory.push([relativePath, hash(fs.readFileSync(file)), stat.mode & 0o777]);
|
||||
} else throw new Error("Distribution bundles contain only regular files and directories");
|
||||
}
|
||||
}
|
||||
walk(root);
|
||||
if (!inventory.length) throw new Error("Distribution bundle is empty");
|
||||
return hash(JSON.stringify(inventory));
|
||||
}
|
||||
|
||||
/**
|
||||
* Optional, image-owned extension catalog. No URLs, executable configuration,
|
||||
* runtime dependency installation, or replacement of a built-in entry.
|
||||
* Validate bytes before importing any manifest code.
|
||||
*/
|
||||
export function readDistributionPluginCatalog(
|
||||
catalogRoot: string,
|
||||
builtins: readonly { key: string; pluginKey: string }[],
|
||||
): DistributionPlugin[] {
|
||||
const root = distributionPluginsRoot(catalogRoot);
|
||||
const file = path.join(root, "catalog.json");
|
||||
const rootStat = fs.lstatSync(root, { throwIfNoEntry: false });
|
||||
if (!rootStat) return [];
|
||||
if (rootStat.isSymbolicLink() || !rootStat.isDirectory()) throw new Error("Distribution catalog root must be a regular directory, not a symlink");
|
||||
const stat = fs.lstatSync(file, { throwIfNoEntry: false });
|
||||
if (!stat) return [];
|
||||
if (!stat.isFile() || stat.isSymbolicLink() || stat.size > 128 * 1024) throw new Error("Invalid distribution catalog file");
|
||||
const catalog = distributionPluginCatalogSchema.parse(JSON.parse(fs.readFileSync(file, "utf8")));
|
||||
const keys = new Set(builtins.map((entry) => entry.key));
|
||||
const pluginKeys = new Set(builtins.map((entry) => entry.pluginKey));
|
||||
const directories = new Set<string>();
|
||||
return catalog.plugins.map((entry) => {
|
||||
if (keys.has(entry.key) || pluginKeys.has(entry.pluginKey) || directories.has(entry.directory)) {
|
||||
throw new Error("Duplicate or built-in distribution plugin identity");
|
||||
}
|
||||
keys.add(entry.key); pluginKeys.add(entry.pluginKey); directories.add(entry.directory);
|
||||
const localPath = path.join(root, entry.directory);
|
||||
const info = fs.lstatSync(localPath);
|
||||
if (!info.isDirectory() || info.isSymbolicLink()) throw new Error("Invalid distribution bundle directory");
|
||||
if (distributionBundleDigest(localPath) !== entry.digest) throw new Error(`Distribution bundle digest mismatch: ${entry.key}`);
|
||||
const pkg = JSON.parse(fs.readFileSync(path.join(localPath, "package.json"), "utf8"));
|
||||
if (pkg.version !== entry.version || !pkg.paperclipPlugin) throw new Error("Distribution package version or entrypoints missing");
|
||||
for (const name of ["manifest", "worker", "ui"] as const) {
|
||||
const declared = pkg.paperclipPlugin[name];
|
||||
if (name === "ui" && declared === undefined) continue;
|
||||
const relative = bundleEntrypoint(declared);
|
||||
const target = fs.statSync(path.join(localPath, relative));
|
||||
if (name === "ui" ? !target.isDirectory() : !target.isFile()) throw new Error("Distribution entrypoint is not prebuilt");
|
||||
}
|
||||
return { ...entry, localPath, entrypoints: {
|
||||
worker: bundleEntrypoint(pkg.paperclipPlugin.worker),
|
||||
...(pkg.paperclipPlugin.ui === undefined ? {} : { ui: bundleEntrypoint(pkg.paperclipPlugin.ui) }),
|
||||
} };
|
||||
});
|
||||
}
|
||||
@@ -163,6 +163,7 @@ const UI_SLOT_CAPABILITIES: Record<PluginUiSlotType, PluginCapability> = {
|
||||
taskDetailView: "ui.detailTab.register",
|
||||
dashboardWidget: "ui.dashboardWidget.register",
|
||||
globalToolbarButton: "ui.action.register",
|
||||
appShellOverlay: "ui.action.register",
|
||||
toolbarButton: "ui.action.register",
|
||||
contextMenuItem: "ui.action.register",
|
||||
commentAnnotation: "ui.commentAnnotation.register",
|
||||
|
||||
@@ -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,16 @@ function getDeclaredPageRoutePaths(manifest: PaperclipPluginManifestV1): string[
|
||||
* Options for the plugin loader service.
|
||||
*/
|
||||
export interface PluginLoaderOptions {
|
||||
/** Image-owned deployment policy, checked before importing code and starting workers. */
|
||||
assertPackageActivation?: (input: {
|
||||
pluginKey?: string;
|
||||
packageRoot: string;
|
||||
/** Persisted source path, even when package resolution used a fallback. */
|
||||
installedPackagePath?: string | null;
|
||||
manifest?: PaperclipPluginManifestV1;
|
||||
/** Persisted grants, supplied before a runtime manifest refresh is saved. */
|
||||
previousManifest?: PaperclipPluginManifestV1;
|
||||
}) => void;
|
||||
/**
|
||||
* Path to the local plugin directory to scan.
|
||||
* Defaults to ~/.paperclip/plugins/
|
||||
@@ -1144,6 +1154,7 @@ export function pluginLoader(
|
||||
migrationDb = db,
|
||||
enableLocalFilesystem = true,
|
||||
enableNpmDiscovery = true,
|
||||
assertPackageActivation,
|
||||
} = options;
|
||||
|
||||
const registry = pluginRegistryService(db);
|
||||
@@ -1269,6 +1280,7 @@ export function pluginLoader(
|
||||
|
||||
// Step 3: Read and validate plugin manifest
|
||||
// Note: this.loadManifest (used via current context)
|
||||
assertPackageActivation?.({ packageRoot: resolvedPackagePath });
|
||||
const pkgJson = await readPackageJson(resolvedPackagePath);
|
||||
if (!pkgJson) throw new Error(`Missing package.json at ${resolvedPackagePath}`);
|
||||
|
||||
@@ -1287,6 +1299,7 @@ export function pluginLoader(
|
||||
}
|
||||
|
||||
const manifest = await loadManifestFromPath(manifestPath);
|
||||
assertPackageActivation?.({ packageRoot: resolvedPackagePath, pluginKey: manifest.id, manifest });
|
||||
|
||||
// Step 4: Reject incompatible plugin API versions
|
||||
if (!manifestValidator.getSupportedVersions().includes(manifest.apiVersion)) {
|
||||
@@ -1382,6 +1395,7 @@ export function pluginLoader(
|
||||
);
|
||||
}
|
||||
|
||||
assertPackageActivation?.({ packageRoot, installedPackagePath: plugin.packagePath, pluginKey: plugin.pluginKey, manifest, previousManifest: plugin.manifestJson });
|
||||
if (JSON.stringify(manifest) === JSON.stringify(plugin.manifestJson)) {
|
||||
return plugin;
|
||||
}
|
||||
@@ -1409,6 +1423,7 @@ export function pluginLoader(
|
||||
packagePath: string,
|
||||
source: PluginSource,
|
||||
): Promise<DiscoveredPlugin | null> {
|
||||
assertPackageActivation?.({ packageRoot: packagePath });
|
||||
const pkgJson = await readPackageJson(packagePath);
|
||||
if (!pkgJson) return null;
|
||||
|
||||
@@ -1438,6 +1453,7 @@ export function pluginLoader(
|
||||
|
||||
try {
|
||||
const manifest = await loadManifestFromPath(manifestPath);
|
||||
assertPackageActivation?.({ packageRoot: packagePath, pluginKey: manifest.id, manifest });
|
||||
return {
|
||||
packagePath,
|
||||
packageName,
|
||||
@@ -1690,6 +1706,7 @@ export function pluginLoader(
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
async loadManifest(packagePath: string): Promise<PaperclipPluginManifestV1 | null> {
|
||||
assertPackageActivation?.({ packageRoot: packagePath });
|
||||
const pkgJson = await readPackageJson(packagePath);
|
||||
if (!pkgJson) return null;
|
||||
|
||||
@@ -1704,7 +1721,9 @@ export function pluginLoader(
|
||||
const manifestPath = resolveManifestPath(packagePath, pkgJson);
|
||||
if (!manifestPath || !existsSync(manifestPath)) return null;
|
||||
|
||||
return loadManifestFromPath(manifestPath);
|
||||
const manifest = await loadManifestFromPath(manifestPath);
|
||||
assertPackageActivation?.({ packageRoot: packagePath, pluginKey: manifest.id, manifest });
|
||||
return manifest;
|
||||
},
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
@@ -2246,8 +2265,10 @@ export function pluginLoader(
|
||||
// 1. Resolve worker entrypoint
|
||||
// ------------------------------------------------------------------
|
||||
const packageRoot = resolvePluginPackageRoot(activePlugin, localPluginDir);
|
||||
assertPackageActivation?.({ pluginKey, packageRoot, installedPackagePath: activePlugin.packagePath });
|
||||
activePlugin = await refreshPluginManifestFromPackage(activePlugin, packageRoot);
|
||||
manifest = activePlugin.manifestJson;
|
||||
assertPackageActivation?.({ pluginKey, packageRoot, installedPackagePath: activePlugin.packagePath, manifest });
|
||||
const workerEntrypoint = resolveWorkerEntrypoint(activePlugin, localPluginDir);
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
@@ -199,6 +199,8 @@ export function pluginRegistryService(db: Db) {
|
||||
id: string,
|
||||
data: {
|
||||
packageName?: string;
|
||||
packagePath?: string;
|
||||
status?: "upgrade_pending";
|
||||
version?: string;
|
||||
manifest?: PaperclipPluginManifestV1;
|
||||
},
|
||||
@@ -210,6 +212,11 @@ export function pluginRegistryService(db: Db) {
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
if (data.packageName !== undefined) setClause.packageName = data.packageName;
|
||||
if (data.packagePath !== undefined) setClause.packagePath = data.packagePath;
|
||||
if (data.status !== undefined) {
|
||||
setClause.status = data.status;
|
||||
setClause.lastError = null;
|
||||
}
|
||||
if (data.version !== undefined) setClause.version = data.version;
|
||||
if (data.manifest !== undefined) {
|
||||
setClause.manifestJson = data.manifest;
|
||||
|
||||
+46
-15
@@ -5,7 +5,17 @@
|
||||
// reloads parked tabs onto the fresh bundle. Left as the literal placeholder in
|
||||
// dev, where HMR (not the worker) drives refreshes.
|
||||
const BUILD_ID = "__PAPERCLIP_BUILD_ID__";
|
||||
const CACHE_NAME = `paperclip-${BUILD_ID}`;
|
||||
// Separate this allowlisted cache from older workers that cached arbitrary URLs.
|
||||
const CACHE_NAME = `paperclip-public-assets-${BUILD_ID}`;
|
||||
const privateRequests = new Set();
|
||||
const privateCacheControl = /(?:^|,)\s*(?:no-store|private)(?:\s*(?:,|=)|\s*$)/i;
|
||||
|
||||
async function evictRequest(request) {
|
||||
await Promise.all((await caches.keys()).map(async (key) => {
|
||||
const cache = await caches.open(key);
|
||||
await cache.delete(request, { ignoreVary: true });
|
||||
}));
|
||||
}
|
||||
|
||||
self.addEventListener("install", () => {
|
||||
self.skipWaiting();
|
||||
@@ -23,32 +33,53 @@ self.addEventListener("activate", (event) => {
|
||||
self.addEventListener("fetch", (event) => {
|
||||
const { request } = event;
|
||||
const url = new URL(request.url);
|
||||
// Only immutable Vite build assets have a public offline-cache contract.
|
||||
// Never infer that application/extension responses are public from absent
|
||||
// headers, or from an in-memory classification lost when this worker restarts.
|
||||
const publicAsset = url.origin === self.location.origin && !url.search &&
|
||||
/^\/assets\/[^/]+-[a-zA-Z0-9_-]{8,}\.[a-zA-Z0-9.]+$/.test(url.pathname);
|
||||
|
||||
// Skip non-GET requests and API calls
|
||||
// Explicitly private requests must bypass BOTH cache writes and offline
|
||||
// fallback, including extension endpoints outside the host /api namespace.
|
||||
if (request.method !== "GET" || url.pathname.startsWith("/api")) {
|
||||
return;
|
||||
}
|
||||
if (request.cache === "no-store") {
|
||||
privateRequests.add(request.url);
|
||||
event.waitUntil(evictRequest(request).catch(() => {}));
|
||||
return;
|
||||
}
|
||||
|
||||
// Network-first for everything — cache is only an offline fallback
|
||||
// Network-first; only public build assets can use an offline fallback.
|
||||
event.respondWith(
|
||||
fetch(request)
|
||||
.then((response) => {
|
||||
if (response.ok && url.origin === self.location.origin) {
|
||||
.then(async (response) => {
|
||||
const cacheControl = response.headers.get("cache-control") ?? "";
|
||||
if (privateCacheControl.test(cacheControl)) {
|
||||
// Revoke earlier cacheable responses too. Keep an in-memory denylist
|
||||
// if storage is unavailable so offline fallback still fails closed.
|
||||
privateRequests.add(request.url);
|
||||
await evictRequest(request).catch(() => {});
|
||||
} else if (response.ok && publicAsset && !privateRequests.has(request.url)) {
|
||||
const clone = response.clone();
|
||||
caches.open(CACHE_NAME).then((cache) => cache.put(request, clone));
|
||||
await caches.open(CACHE_NAME).then(async (cache) => {
|
||||
await cache.put(request, clone);
|
||||
// A concurrent response may have revoked this URL during put().
|
||||
if (privateRequests.has(request.url)) await cache.delete(request, { ignoreVary: true });
|
||||
}).catch(() => {});
|
||||
}
|
||||
return response;
|
||||
})
|
||||
.catch(async () => {
|
||||
// caches.match() resolves undefined on a miss (and the promise itself
|
||||
// is always truthy, so `||` can never supply a fallback). respondWith
|
||||
// must always receive a real Response — resolving undefined breaks
|
||||
// the navigation with "Failed to convert value to 'Response'" instead
|
||||
// of showing anything.
|
||||
if (request.mode === "navigate") {
|
||||
return (await caches.match("/")) ?? new Response("Offline", { status: 503 });
|
||||
}
|
||||
return (await caches.match(request)) ?? Response.error();
|
||||
if (privateRequests.has(request.url)) return Response.error();
|
||||
if (!publicAsset) return request.mode === "navigate" ? new Response("Offline", { status: 503 }) : Response.error();
|
||||
// Restrict lookup to this policy's cache; old arbitrary-response caches
|
||||
// must not become fallback candidates if activation cleanup fails.
|
||||
try {
|
||||
const cached = await (await caches.open(CACHE_NAME)).match(request);
|
||||
if (cached && !privateCacheControl.test(cached.headers.get("cache-control") ?? "")) return cached;
|
||||
} catch { /* Unavailable cache storage is an offline miss. */ }
|
||||
return Response.error();
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -117,6 +117,12 @@ vi.mock("./PropertiesPanel", () => ({
|
||||
PropertiesPanel: () => null,
|
||||
}));
|
||||
|
||||
// Overlay account/company lifecycle has its own integration test. These tests
|
||||
// exercise route navigation with intentionally minimal context providers.
|
||||
vi.mock("./PluginAppShellOverlays", () => ({
|
||||
PluginAppShellOverlays: () => null,
|
||||
}));
|
||||
|
||||
vi.mock("./CommandPalette", () => ({
|
||||
CommandPalette: () => null,
|
||||
}));
|
||||
|
||||
@@ -21,6 +21,7 @@ import { NewAgentDialog } from "./NewAgentDialog";
|
||||
import { KeyboardShortcutsCheatsheet } from "./KeyboardShortcutsCheatsheet";
|
||||
import { ToastViewport } from "./ToastViewport";
|
||||
import { AnnouncementWell } from "./AnnouncementWell";
|
||||
import { PluginAppShellOverlays } from "./PluginAppShellOverlays";
|
||||
import { MobileBottomNav } from "./MobileBottomNav";
|
||||
import { WorktreeBanner } from "./WorktreeBanner";
|
||||
import { DevRestartBanner } from "./DevRestartBanner";
|
||||
@@ -788,6 +789,7 @@ export function Layout({ sidebarSections }: { sidebarSections?: ReactNode }) {
|
||||
<KeyboardShortcutsCheatsheet open={shortcutsOpen} onOpenChange={setShortcutsOpen} />
|
||||
<ToastViewport />
|
||||
<AnnouncementWell health={health} />
|
||||
<PluginAppShellOverlays localTrusted={health?.deploymentMode === "local_trusted"} />
|
||||
</div>
|
||||
</GeneralSettingsProvider>
|
||||
</ChatSetupSidebarProvider>
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
// @vitest-environment jsdom
|
||||
import { useEffect, useState } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { flushSync } from "react-dom";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { PluginAppShellOverlays } from "./PluginAppShellOverlays";
|
||||
|
||||
const state = vi.hoisted(() => ({ userId: "alice" as string | null, settled: true, company: "first", onboarding: false, dismissed: false, pathname: "/ACME/issues", context: {} as Record<string, unknown>, failed: false, mounts: 0, disposals: 0 }));
|
||||
vi.mock("@/api/companies-query", () => ({ useAccountIdentity: () => ({ userId: state.userId, settled: state.settled }) }));
|
||||
vi.mock("@/context/CompanyContext", () => ({ useCompany: () => ({ selectedCompanyId: state.company, selectedCompany: { issuePrefix: "ACME" }, loading: false }) }));
|
||||
vi.mock("@/context/DialogContext", () => ({ useDialogState: () => ({ onboardingOpen: state.onboarding, onboardingRouteDismissed: state.dismissed }) }));
|
||||
vi.mock("@/lib/router", () => ({ useLocation: () => ({ pathname: state.pathname }) }));
|
||||
vi.mock("@/plugins/slots", () => ({
|
||||
usePluginSlots: () => ({ errorMessage: state.failed ? "unavailable" : null, slots: [{ pluginId: "fixture", pluginVersion: "1.0.0", id: "overlay" }] }),
|
||||
PluginSlotMount: ({ context }: { context: Record<string, unknown> }) => {
|
||||
state.context = context;
|
||||
const [draft, setDraft] = useState("");
|
||||
useEffect(() => { state.mounts++; return () => { state.disposals++; }; }, []);
|
||||
return <button onClick={() => setDraft("private draft")}>{draft || "empty"}</button>;
|
||||
},
|
||||
}));
|
||||
let root: Root | undefined;
|
||||
let container: HTMLDivElement;
|
||||
function render(localTrusted = false) {
|
||||
if (!root) { container = document.createElement("div"); document.body.append(container); root = createRoot(container); }
|
||||
flushSync(() => root!.render(<PluginAppShellOverlays localTrusted={localTrusted} />));
|
||||
}
|
||||
afterEach(() => {
|
||||
if (root) flushSync(() => root!.unmount());
|
||||
root = undefined; container?.remove();
|
||||
Object.assign(state, { userId: "alice", settled: true, company: "first", onboarding: false, dismissed: false, pathname: "/ACME/issues", context: {}, failed: false, mounts: 0, disposals: 0 });
|
||||
});
|
||||
describe("persistent app-shell plugin lifecycle", () => {
|
||||
it("passes the complete host context promised by PluginWidgetProps", () => {
|
||||
render();
|
||||
expect(state.context).toEqual({ companyId: "first", companyPrefix: "ACME", projectId: null, entityId: null, entityType: null, parentEntityId: null, userId: "alice" });
|
||||
});
|
||||
it("disposes drafts on route-driven onboarding and remounts after dismissal", () => {
|
||||
render(); flushSync(() => container.querySelector("button")!.click());
|
||||
state.pathname = "/ACME/onboarding"; render();
|
||||
expect(state.onboarding).toBe(false); expect(container.textContent).toBe(""); expect(state.disposals).toBe(1);
|
||||
state.dismissed = true; render(); expect(container.textContent).toBe("empty");
|
||||
});
|
||||
it("keeps a draft during shell rerenders and clears it on account/company transitions", () => {
|
||||
render(); flushSync(() => container.querySelector("button")!.click());
|
||||
render(); expect(container.textContent).toBe("private draft"); expect(state.mounts).toBe(1);
|
||||
state.userId = "bob"; render(); expect(container.textContent).toBe("empty"); expect(state.disposals).toBe(1);
|
||||
flushSync(() => container.querySelector("button")!.click());
|
||||
state.company = "second"; render(); expect(container.textContent).toBe("empty"); expect(state.disposals).toBe(2);
|
||||
});
|
||||
it("disposes private UI on sign-out and waits for a settled identity", () => {
|
||||
render(); state.userId = null; render(); expect(container.textContent).toBe(""); expect(state.disposals).toBe(1);
|
||||
state.userId = "bob"; state.settled = false; render(); expect(state.mounts).toBe(1);
|
||||
state.settled = true; render(); expect(state.mounts).toBe(2);
|
||||
});
|
||||
it("does not render during onboarding or contribution errors", () => {
|
||||
state.onboarding = true; render(); expect(state.mounts).toBe(0);
|
||||
state.onboarding = false; state.failed = true; render(); expect(container.textContent).toBe("");
|
||||
});
|
||||
it("clears account state when returning to the sessionless local board", () => {
|
||||
render(true); flushSync(() => container.querySelector("button")!.click());
|
||||
state.settled = false; render(true); expect(container.textContent).toBe(""); expect(state.disposals).toBe(1);
|
||||
state.userId = null; state.settled = true; render(true);
|
||||
expect(container.textContent).toBe("empty"); expect(state.mounts).toBe(2);
|
||||
flushSync(() => container.querySelector("button")!.click());
|
||||
state.userId = "bob"; render(true); expect(container.textContent).toBe("empty"); expect(state.disposals).toBe(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,59 @@
|
||||
import { useAccountIdentity } from "@/api/companies-query";
|
||||
import { useCompany } from "@/context/CompanyContext";
|
||||
import { useDialogState } from "@/context/DialogContext";
|
||||
import { useLocation } from "@/lib/router";
|
||||
import { isOnboardingPath } from "@/lib/onboarding-route";
|
||||
import type { PluginHostContext } from "@/plugins/bridge";
|
||||
import { PluginSlotMount, usePluginSlots, type PluginSlotContext } from "@/plugins/slots";
|
||||
|
||||
function AppShellEntries({ context }: { context: PluginSlotContext }) {
|
||||
const { slots, errorMessage } = usePluginSlots({
|
||||
slotTypes: ["appShellOverlay"],
|
||||
companyId: context.companyId,
|
||||
});
|
||||
// Optional extensions must not replace the application's normal error UI.
|
||||
if (errorMessage || slots.length === 0) return null;
|
||||
return (
|
||||
<aside className="plugin-app-shell-overlays" aria-label="Application extensions">
|
||||
{slots.map((slot) => (
|
||||
<PluginSlotMount
|
||||
key={`${slot.pluginId}:${slot.pluginVersion}:${slot.id}`}
|
||||
slot={slot}
|
||||
context={context}
|
||||
className="plugin-app-shell-overlay"
|
||||
/>
|
||||
))}
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* One persistent mount in each application shell. Navigation preserves the
|
||||
* plugin tree; changing account/company, signing out, or entering onboarding
|
||||
* disposes it. Plugins must cancel their requests/subscriptions on disposal.
|
||||
* Host context is display context, never proof of server authorization.
|
||||
*/
|
||||
export function PluginAppShellOverlays({ localTrusted = false }: { localTrusted?: boolean }) {
|
||||
const { userId, settled } = useAccountIdentity();
|
||||
const { selectedCompanyId, selectedCompany, loading } = useCompany();
|
||||
const { onboardingOpen, onboardingRouteDismissed } = useDialogState();
|
||||
const { pathname } = useLocation();
|
||||
// Local-trusted instances intentionally have no login requirement. Still
|
||||
// prefer any real account and wait for identity resolution so account changes
|
||||
// cannot reuse the prior account's in-memory plugin state.
|
||||
const identity = settled ? userId ?? (localTrusted ? "local-board" : null) : null;
|
||||
const onboardingVisible = onboardingOpen || (!onboardingRouteDismissed && isOnboardingPath(pathname));
|
||||
if (!identity || loading || onboardingVisible) return null;
|
||||
const context: PluginSlotContext & PluginHostContext = {
|
||||
companyId: selectedCompanyId,
|
||||
companyPrefix: selectedCompany?.issuePrefix ?? null,
|
||||
projectId: null, entityId: null, entityType: null, parentEntityId: null,
|
||||
userId,
|
||||
};
|
||||
return (
|
||||
<AppShellEntries
|
||||
key={JSON.stringify([identity, selectedCompanyId])}
|
||||
context={context}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -3085,3 +3085,27 @@ span.paperclip-mention-chip[data-mention-kind="external-object"] {
|
||||
--agent-cap-v1-muted-dream-a: #a6aaad;
|
||||
--agent-cap-v1-muted-dream-b: #44464a;
|
||||
}
|
||||
|
||||
/* Persistent plugin surface. The host reserves mobile navigation/safe-area
|
||||
space; each extension owns its accessible panel and focus lifecycle. */
|
||||
:root {
|
||||
--plugin-shell-gap: calc(var(--spacing) * 3);
|
||||
--plugin-shell-inset: calc(var(--spacing) * 4);
|
||||
--plugin-shell-mobile-bottom: calc(var(--sz-calc-14) + var(--sz-safe-bottom) + var(--plugin-shell-inset));
|
||||
}
|
||||
.plugin-app-shell-overlays {
|
||||
position: fixed;
|
||||
inset-inline-end: max(var(--plugin-shell-inset), env(safe-area-inset-right));
|
||||
bottom: max(var(--plugin-shell-inset), var(--sz-safe-bottom));
|
||||
z-index: var(--z-20);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: var(--plugin-shell-gap);
|
||||
max-width: calc(100vw - var(--plugin-shell-inset) * 2);
|
||||
pointer-events: none;
|
||||
}
|
||||
.plugin-app-shell-overlay { pointer-events: auto; }
|
||||
@media (max-width: 767px) {
|
||||
.plugin-app-shell-overlays { bottom: var(--plugin-shell-mobile-bottom); }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
// @vitest-environment node
|
||||
import { readFileSync } from "node:fs";
|
||||
import vm from "node:vm";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
function worker() {
|
||||
const handlers = new Map<string, (event: unknown) => void>();
|
||||
const put = vi.fn();
|
||||
const match = vi.fn();
|
||||
const remove = vi.fn().mockResolvedValue(true);
|
||||
const fetch = vi.fn().mockResolvedValue(new Response("public asset"));
|
||||
vm.runInNewContext(readFileSync(new URL("../../public/sw.js", import.meta.url), "utf8"), {
|
||||
self: { location: { origin: "https://example.test" }, addEventListener: (type: string, fn: (event: unknown) => void) => handlers.set(type, fn) },
|
||||
URL, Response, fetch, caches: { keys: async () => ["paperclip-old", "paperclip-current"], open: async () => ({ put, delete: remove, match }), match },
|
||||
});
|
||||
const request = (cache: RequestCache = "default", pathname = "/extension/history") => {
|
||||
const respondWith = vi.fn();
|
||||
handlers.get("fetch")!({ request: new Request(`https://example.test${pathname}`, { cache }), respondWith, waitUntil: vi.fn() });
|
||||
return respondWith;
|
||||
};
|
||||
return { request, fetch, put, match, remove };
|
||||
}
|
||||
|
||||
describe("service worker privacy boundaries", () => {
|
||||
it("bypasses both caching and offline fallback for a no-store request outside /api", () => {
|
||||
const w = worker();
|
||||
expect(w.request("no-store")).not.toHaveBeenCalled();
|
||||
expect(w.fetch).not.toHaveBeenCalled();
|
||||
expect(w.match).not.toHaveBeenCalled();
|
||||
expect(w.put).not.toHaveBeenCalled();
|
||||
});
|
||||
it.each(["no-store", "max-age=0, no-store", "private", 'private="Set-Cookie"', "PRIVATE, max-age=60"])("never caches a response marked %s", async directive => {
|
||||
const w = worker();
|
||||
w.fetch.mockResolvedValue(new Response("personal content", { headers: { "cache-control": directive } }));
|
||||
const response = await w.request().mock.calls[0]![0];
|
||||
expect(await response.text()).toBe("personal content");
|
||||
expect(w.put).not.toHaveBeenCalled();
|
||||
});
|
||||
it("keeps public asset offline caching", async () => {
|
||||
const w = worker();
|
||||
await w.request("default", "/assets/index-AbCd1234.js").mock.calls[0]![0];
|
||||
expect(w.put).toHaveBeenCalledOnce();
|
||||
w.fetch.mockRejectedValue(new Error("offline"));
|
||||
w.match.mockResolvedValue(new Response("cached build asset"));
|
||||
expect(await (await w.request("default", "/assets/index-AbCd1234.js").mock.calls[0]![0]).text()).toBe("cached build asset");
|
||||
});
|
||||
it.each(["private", "no-store"])("evicts stale entries when a response becomes %s and blocks offline reuse", async directive => {
|
||||
const w = worker();
|
||||
w.match.mockResolvedValue(new Response("stale personal content"));
|
||||
w.fetch.mockResolvedValue(new Response("fresh", { headers: { "cache-control": directive } }));
|
||||
await w.request().mock.calls[0]![0];
|
||||
expect(w.remove).toHaveBeenCalledTimes(2);
|
||||
expect(w.remove).toHaveBeenCalledWith(expect.any(Request), { ignoreVary: true });
|
||||
w.fetch.mockRejectedValue(new Error("offline"));
|
||||
const offline = await w.request().mock.calls[0]![0];
|
||||
expect(offline.type).toBe("error"); expect(w.match).not.toHaveBeenCalled();
|
||||
});
|
||||
it("does not serve stale data when cache eviction itself fails", async () => {
|
||||
const w = worker(); w.remove.mockRejectedValue(new Error("cache unavailable"));
|
||||
w.fetch.mockResolvedValue(new Response("fresh", { headers: { "cache-control": "private" } }));
|
||||
expect(await (await w.request().mock.calls[0]![0]).text()).toBe("fresh");
|
||||
w.fetch.mockRejectedValue(new Error("offline"));
|
||||
expect((await w.request().mock.calls[0]![0]).type).toBe("error");
|
||||
expect(w.match).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each(["/extension/history", "/extensions/support/messages", "/", "/assets/avatar.png", "/assets/index-AbCd1234.js?user=1"])("never caches or falls back to uncertain resource %s after a worker restart", async pathname => {
|
||||
const previous = worker();
|
||||
previous.remove.mockRejectedValue(new Error("storage unavailable"));
|
||||
previous.fetch.mockResolvedValue(new Response("personal content", { headers: { "cache-control": "private" } }));
|
||||
await previous.request("default", pathname).mock.calls[0]![0];
|
||||
const restarted = worker();
|
||||
restarted.match.mockResolvedValue(new Response("stale personal content"));
|
||||
await restarted.request("default", pathname).mock.calls[0]![0];
|
||||
expect(restarted.put).not.toHaveBeenCalled();
|
||||
restarted.fetch.mockRejectedValue(new Error("offline"));
|
||||
expect((await restarted.request("default", pathname).mock.calls[0]![0]).type).toBe("error");
|
||||
expect(restarted.match).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects a cached asset explicitly marked private even after restart", async () => {
|
||||
const w = worker();
|
||||
w.match.mockResolvedValue(new Response("personal content", { headers: { "cache-control": "private" } }));
|
||||
w.fetch.mockRejectedValue(new Error("offline"));
|
||||
expect((await w.request("default", "/assets/index-AbCd1234.js").mock.calls[0]![0]).type).toBe("error");
|
||||
});
|
||||
});
|
||||
@@ -76,7 +76,7 @@ describe("sw.js offline fallback", () => {
|
||||
expect(await response!.text()).toBe("Offline");
|
||||
});
|
||||
|
||||
it("serves the cached shell for a failed navigation when one exists", async () => {
|
||||
it("does not replay a legacy cached shell for a failed navigation", async () => {
|
||||
const shell = new Response("<html>app shell</html>", { status: 200 });
|
||||
const listener = loadServiceWorkerFetchListener({
|
||||
fetch: () => Promise.reject(new TypeError("network down")),
|
||||
@@ -89,7 +89,8 @@ describe("sw.js offline fallback", () => {
|
||||
mode: "navigate",
|
||||
});
|
||||
|
||||
expect(response).toBe(shell);
|
||||
expect(response!.status).toBe(503);
|
||||
expect(await response!.text()).toBe("Offline");
|
||||
});
|
||||
|
||||
it("returns a network-error Response for a failed asset with no cache entry", async () => {
|
||||
|
||||
Reference in New Issue
Block a user