mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-22 00:30:19 +02: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:
1 parent
64895f187b
commit
b70641f23f
24 files changed
+961
-32
No files matched your search
@@ -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`
|
- `taskDetailView`
|
||||||
- `projectSidebarItem`
|
- `projectSidebarItem`
|
||||||
- `globalToolbarButton`
|
- `globalToolbarButton`
|
||||||
|
- `appShellOverlay` (persistent, signed-in application shell)
|
||||||
- `toolbarButton`
|
- `toolbarButton`
|
||||||
- `contextMenuItem`
|
- `contextMenuItem`
|
||||||
- `commentAnnotation`
|
- `commentAnnotation`
|
||||||
@@ -613,3 +614,6 @@ pnpm -r typecheck
|
|||||||
pnpm test:run
|
pnpm test:run
|
||||||
pnpm build
|
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"
|
| "sidebarPanel"
|
||||||
| "projectSidebarItem"
|
| "projectSidebarItem"
|
||||||
| "globalToolbarButton"
|
| "globalToolbarButton"
|
||||||
|
| "appShellOverlay"
|
||||||
| "toolbarButton"
|
| "toolbarButton"
|
||||||
| "contextMenuItem"
|
| "contextMenuItem"
|
||||||
| "commentAnnotation"
|
| "commentAnnotation"
|
||||||
|
|||||||
@@ -214,6 +214,7 @@ Slot types describe where a component mounts. Most values also exist as launcher
|
|||||||
| `settingsPage` | Global | — |
|
| `settingsPage` | Global | — |
|
||||||
| `dashboardWidget` | Global | — |
|
| `dashboardWidget` | Global | — |
|
||||||
| `globalToolbarButton` | Global | — |
|
| `globalToolbarButton` | Global | — |
|
||||||
|
| `appShellOverlay` (slot only) | Signed-in application shell | — |
|
||||||
| `detailTab` | Entity | `project`, `issue`, `agent`, `goal`, `run` |
|
| `detailTab` | Entity | `project`, `issue`, `agent`, `goal`, `run` |
|
||||||
| `taskDetailView` | Entity | (task/issue context) |
|
| `taskDetailView` | Entity | (task/issue context) |
|
||||||
| `commentAnnotation` | Entity | `comment` |
|
| `commentAnnotation` | Entity | `comment` |
|
||||||
@@ -1297,3 +1298,10 @@ const server = await startPluginDevServer({ rootDir: process.cwd() });
|
|||||||
Dev server endpoints:
|
Dev server endpoints:
|
||||||
- `GET /__paperclip__/health` returns `{ ok, rootDir, uiDir }`
|
- `GET /__paperclip__/health` returns `{ ok, rootDir, uiDir }`
|
||||||
- `GET /__paperclip__/events` streams `reload` SSE events on UI build changes
|
- `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",
|
"sidebarPanel",
|
||||||
"projectSidebarItem",
|
"projectSidebarItem",
|
||||||
"globalToolbarButton",
|
"globalToolbarButton",
|
||||||
|
"appShellOverlay",
|
||||||
"toolbarButton",
|
"toolbarButton",
|
||||||
"contextMenuItem",
|
"contextMenuItem",
|
||||||
"commentAnnotation",
|
"commentAnnotation",
|
||||||
|
|||||||
@@ -215,12 +215,13 @@ type LooseRow = {
|
|||||||
version?: string;
|
version?: string;
|
||||||
manifestJson?: Record<string, unknown>;
|
manifestJson?: Record<string, unknown>;
|
||||||
lastError?: string | null;
|
lastError?: string | null;
|
||||||
|
packagePath?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Build a minimal manifest for a persisted row or a shipped bundle. The reconcile
|
// Build a minimal manifest for a persisted row or a shipped bundle. The reconcile
|
||||||
// step compares the bundle version with the persisted version.
|
// step compares the bundle version with the persisted version.
|
||||||
function makeManifest(pluginKey: string, version: string) {
|
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?: {
|
function makeDeps(overrides?: {
|
||||||
@@ -402,6 +403,112 @@ describe("ensureBundledPlugins", () => {
|
|||||||
expect(update).not.toHaveBeenCalled();
|
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 () => {
|
it("swallows a reconcile error and continues boot", async () => {
|
||||||
const { deps, update } = makeDeps({
|
const { deps, update } = makeDeps({
|
||||||
rows: {
|
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 { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import type { Db } from "@paperclipai/db";
|
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(() => ({
|
const mockRegistry = vi.hoisted(() => ({
|
||||||
getById: vi.fn(),
|
getById: vi.fn(),
|
||||||
@@ -148,4 +152,59 @@ describe("pluginLoader.loadAll error retry", () => {
|
|||||||
|
|
||||||
expect(result).toEqual({ total: 0, succeeded: 0, failed: 0, results: [] });
|
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";
|
} from "./services/plugin-loader.js";
|
||||||
import {
|
import {
|
||||||
SELF_HOSTED_AUTO_INSTALL_KEYS,
|
SELF_HOSTED_AUTO_INSTALL_KEYS,
|
||||||
|
BUNDLED_PLUGIN_CATALOG,
|
||||||
ensureBundledPlugins,
|
ensureBundledPlugins,
|
||||||
resolveBundledCatalogRoot,
|
resolveBundledCatalogRoot,
|
||||||
resolveBundledPluginInstalls,
|
resolveBundledPluginInstalls,
|
||||||
} from "./services/bundled-plugins.js";
|
} from "./services/bundled-plugins.js";
|
||||||
|
import { readDistributionPluginCatalog, distributionPluginActivationGuard } from "./services/distribution-plugin-catalog.js";
|
||||||
import {
|
import {
|
||||||
createPluginWorkerManager,
|
createPluginWorkerManager,
|
||||||
type PluginWorkerManager,
|
type PluginWorkerManager,
|
||||||
@@ -596,12 +598,14 @@ export async function createApp(
|
|||||||
const managedAutoInstallKeys = opts.managedPluginAutoInstall ?? null;
|
const managedAutoInstallKeys = opts.managedPluginAutoInstall ?? null;
|
||||||
const bundledCatalogRoot =
|
const bundledCatalogRoot =
|
||||||
opts.bundledPluginCatalogRoot ?? resolveBundledCatalogRoot(process.env);
|
opts.bundledPluginCatalogRoot ?? resolveBundledCatalogRoot(process.env);
|
||||||
|
const distributionPlugins = readDistributionPluginCatalog(bundledCatalogRoot, BUNDLED_PLUGIN_CATALOG);
|
||||||
const bundledPluginInstalls = resolveBundledPluginInstalls(
|
const bundledPluginInstalls = resolveBundledPluginInstalls(
|
||||||
managedAutoInstallKeys ?? SELF_HOSTED_AUTO_INSTALL_KEYS,
|
managedAutoInstallKeys ?? SELF_HOSTED_AUTO_INSTALL_KEYS,
|
||||||
{
|
{
|
||||||
catalogRoot: bundledCatalogRoot,
|
catalogRoot: bundledCatalogRoot,
|
||||||
env: process.env,
|
env: process.env,
|
||||||
enforceCatalogRoot: managedAutoInstallKeys !== null,
|
enforceCatalogRoot: managedAutoInstallKeys !== null,
|
||||||
|
distributionPlugins,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
const managedBundledPluginKeys =
|
const managedBundledPluginKeys =
|
||||||
@@ -869,6 +873,7 @@ export async function createApp(
|
|||||||
{
|
{
|
||||||
localPluginDir: opts.localPluginDir ?? DEFAULT_LOCAL_PLUGIN_DIR,
|
localPluginDir: opts.localPluginDir ?? DEFAULT_LOCAL_PLUGIN_DIR,
|
||||||
migrationDb: opts.pluginMigrationDb,
|
migrationDb: opts.pluginMigrationDb,
|
||||||
|
assertPackageActivation: distributionPluginActivationGuard(bundledCatalogRoot, distributionPlugins, managedAutoInstallKeys),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
workerManager,
|
workerManager,
|
||||||
@@ -1267,7 +1272,8 @@ export async function createApp(
|
|||||||
{ registry: pluginRegistry, loader, lifecycle, logger },
|
{ registry: pluginRegistry, loader, lifecycle, logger },
|
||||||
// Managed mode reinstalls soft-uninstalled bundles (the control plane
|
// Managed mode reinstalls soft-uninstalled bundles (the control plane
|
||||||
// owns provisioning); self-hosted leaves an operator's uninstall alone.
|
// 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 },
|
{ reinstallUninstalled: managedAutoInstallKeys !== null },
|
||||||
)
|
)
|
||||||
.then(() => loader.loadAll())
|
.then(() => loader.loadAll())
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import fs from "node:fs";
|
import fs from "node:fs";
|
||||||
|
import { isDeepStrictEqual } from "node:util";
|
||||||
import type { PaperclipPluginManifestV1 } from "@paperclipai/shared";
|
import type { PaperclipPluginManifestV1 } from "@paperclipai/shared";
|
||||||
|
import { assertDistributionManifestCapabilities, readDistributionPluginCatalog, type DistributionPlugin } from "./distribution-plugin-catalog.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Bundled plugin auto-provisioning.
|
* Bundled plugin auto-provisioning.
|
||||||
@@ -123,6 +125,8 @@ export interface ResolvedBundledPlugin {
|
|||||||
pluginKey: string;
|
pluginKey: string;
|
||||||
/** Absolute path handed to `loader.installPlugin({ localPath })`. */
|
/** Absolute path handed to `loader.installPlugin({ localPath })`. */
|
||||||
localPath: string;
|
localPath: string;
|
||||||
|
/** Image-owned entries require exact manifest identity/version matching. */
|
||||||
|
distribution?: DistributionPlugin;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -161,16 +165,23 @@ export function resolveBundledPluginInstalls(
|
|||||||
catalogRoot: string;
|
catalogRoot: string;
|
||||||
env: Record<string, string | undefined>;
|
env: Record<string, string | undefined>;
|
||||||
enforceCatalogRoot: boolean;
|
enforceCatalogRoot: boolean;
|
||||||
|
distributionPlugins?: readonly DistributionPlugin[];
|
||||||
},
|
},
|
||||||
): ResolvedBundledPlugin[] {
|
): ResolvedBundledPlugin[] {
|
||||||
const resolved: ResolvedBundledPlugin[] = [];
|
const resolved: ResolvedBundledPlugin[] = [];
|
||||||
const seen = new Set<string>();
|
const seen = new Set<string>();
|
||||||
const canonicalRoot = canonicalize(opts.catalogRoot);
|
const canonicalRoot = canonicalize(opts.catalogRoot);
|
||||||
|
const distributionPlugins = opts.distributionPlugins ?? readDistributionPluginCatalog(opts.catalogRoot, BUNDLED_PLUGIN_CATALOG);
|
||||||
for (const key of keys) {
|
for (const key of keys) {
|
||||||
if (seen.has(key)) continue;
|
if (seen.has(key)) continue;
|
||||||
seen.add(key);
|
seen.add(key);
|
||||||
const entry = BUNDLED_PLUGIN_CATALOG.find((candidate) => candidate.key === key);
|
const entry = BUNDLED_PLUGIN_CATALOG.find((candidate) => candidate.key === key);
|
||||||
if (!entry) {
|
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(", ");
|
const known = BUNDLED_PLUGIN_CATALOG.map((candidate) => candidate.key).join(", ");
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`bundled plugin auto-install key "${key}" is not in the bundled catalog (known keys: ${known}); refusing to start`,
|
`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;
|
status: string;
|
||||||
version: string;
|
version: string;
|
||||||
manifestJson: PaperclipPluginManifestV1;
|
manifestJson: PaperclipPluginManifestV1;
|
||||||
|
packagePath?: string | null;
|
||||||
lastError?: string | null;
|
lastError?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -206,7 +218,7 @@ export interface BundledPluginProvisionerDeps {
|
|||||||
getByKey(pluginKey: string): Promise<RegistryPluginRow | null>;
|
getByKey(pluginKey: string): Promise<RegistryPluginRow | null>;
|
||||||
update(
|
update(
|
||||||
id: string,
|
id: string,
|
||||||
data: { version?: string; manifest?: PaperclipPluginManifestV1 },
|
data: { version?: string; manifest?: PaperclipPluginManifestV1; packagePath?: string; status?: "upgrade_pending" },
|
||||||
): Promise<unknown>;
|
): Promise<unknown>;
|
||||||
updateStatus(id: string, input: { status: "ready"; lastError: string | null }): 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
|
* 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
|
* 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
|
* 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
|
* a manifest change (for example a new driver capability) to an existing
|
||||||
* install that the auto-install path skips.
|
* install that the auto-install path skips.
|
||||||
*
|
*
|
||||||
@@ -249,24 +263,42 @@ async function reconcileBundledPluginManifest(
|
|||||||
install: ResolvedBundledPlugin,
|
install: ResolvedBundledPlugin,
|
||||||
deps: BundledPluginProvisionerDeps,
|
deps: BundledPluginProvisionerDeps,
|
||||||
bundleManifestExists: (localPath: string) => boolean,
|
bundleManifestExists: (localPath: string) => boolean,
|
||||||
): Promise<void> {
|
verifiedManifest?: PaperclipPluginManifestV1,
|
||||||
|
): Promise<"upgrade_pending" | undefined> {
|
||||||
try {
|
try {
|
||||||
if (!bundleManifestExists(install.localPath)) return;
|
// Managed reinstalls take the install path instead; this branch means the
|
||||||
const bundleManifest = await deps.loader.loadManifest(install.localPath);
|
// 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) 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, {
|
await deps.registry.update(existing.id, {
|
||||||
version: bundleManifest.version,
|
version: bundleManifest.version,
|
||||||
manifest: bundleManifest,
|
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(
|
deps.logger.info(
|
||||||
{
|
{
|
||||||
pluginKey: install.pluginKey,
|
pluginKey: install.pluginKey,
|
||||||
fromVersion: existing.version,
|
fromVersion: existing.version,
|
||||||
toVersion: bundleManifest.version,
|
toVersion: bundleManifest.version,
|
||||||
|
...(rebindPackage ? { packagePath: install.localPath } : {}),
|
||||||
},
|
},
|
||||||
"reconciled bundled plugin manifest to the shipped bundle version",
|
"reconciled bundled plugin manifest to the shipped bundle version",
|
||||||
);
|
);
|
||||||
|
if (requiresApproval) return "upgrade_pending";
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
deps.logger.error(
|
deps.logger.error(
|
||||||
{ err, pluginKey: install.pluginKey },
|
{ err, pluginKey: install.pluginKey },
|
||||||
@@ -357,16 +389,25 @@ export async function ensureBundledPlugins(
|
|||||||
const bundleManifestExists = deps.bundleManifestExists ?? defaultBundleManifestExists;
|
const bundleManifestExists = deps.bundleManifestExists ?? defaultBundleManifestExists;
|
||||||
for (const install of installs) {
|
for (const install of installs) {
|
||||||
try {
|
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);
|
const existing = await deps.registry.getByKey(install.pluginKey);
|
||||||
if (existing && (existing.status !== "uninstalled" || !opts.reinstallUninstalled)) {
|
if (existing && (existing.status !== "uninstalled" || !opts.reinstallUninstalled)) {
|
||||||
// The bundle ships with the release image, so its manifest is the
|
// The bundle ships with the release image, so its manifest is the
|
||||||
// source of truth for a present plugin. Reconcile the persisted
|
// 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
|
// this step a manifest capability added to a bundle never reaches an
|
||||||
// existing install, because the auto-install below skips a present
|
// existing install, because the auto-install below skips a present
|
||||||
// plugin. The reconcile updates only the stored manifest row; the
|
// plugin. Distribution entries also replace a legacy/npm package path
|
||||||
// running worker already runs the shipped code.
|
// before loadAll resolves the worker. Configuration and status stay put.
|
||||||
await reconcileBundledPluginManifest(existing, install, deps, bundleManifestExists);
|
const reconciledStatus = await reconcileBundledPluginManifest(existing, install, deps, bundleManifestExists, verifiedManifest);
|
||||||
|
if (reconciledStatus === "upgrade_pending") continue;
|
||||||
if (existing.status === "error") {
|
if (existing.status === "error") {
|
||||||
await reenableErroredBundledPlugin(existing, install, deps);
|
await reenableErroredBundledPlugin(existing, install, deps);
|
||||||
continue;
|
continue;
|
||||||
@@ -379,7 +420,7 @@ export async function ensureBundledPlugins(
|
|||||||
}
|
}
|
||||||
// Skip silently when the bundle is absent (e.g. local dev or an image
|
// Skip silently when the bundle is absent (e.g. local dev or an image
|
||||||
// built without the plugin). Not an error condition.
|
// built without the plugin). Not an error condition.
|
||||||
if (!bundleManifestExists(install.localPath)) {
|
if (!verifiedManifest && !bundleManifestExists(install.localPath)) {
|
||||||
deps.logger.info(
|
deps.logger.info(
|
||||||
{ pluginKey: install.pluginKey, pluginPath: install.localPath },
|
{ pluginKey: install.pluginKey, pluginPath: install.localPath },
|
||||||
"bundled plugin bundle not present; skipping auto-install",
|
"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",
|
taskDetailView: "ui.detailTab.register",
|
||||||
dashboardWidget: "ui.dashboardWidget.register",
|
dashboardWidget: "ui.dashboardWidget.register",
|
||||||
globalToolbarButton: "ui.action.register",
|
globalToolbarButton: "ui.action.register",
|
||||||
|
appShellOverlay: "ui.action.register",
|
||||||
toolbarButton: "ui.action.register",
|
toolbarButton: "ui.action.register",
|
||||||
contextMenuItem: "ui.action.register",
|
contextMenuItem: "ui.action.register",
|
||||||
commentAnnotation: "ui.commentAnnotation.register",
|
commentAnnotation: "ui.commentAnnotation.register",
|
||||||
|
|||||||
@@ -27,6 +27,7 @@
|
|||||||
import { realpath, stat } from "node:fs/promises";
|
import { realpath, stat } from "node:fs/promises";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { BUNDLED_LOCAL_PLUGIN_ROOT } from "./plugin-loader.js";
|
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";
|
export { isCloudManagedInstance } from "./cloud-instance.js";
|
||||||
|
|
||||||
@@ -93,7 +94,7 @@ export async function isWithinBundledPluginRoot(
|
|||||||
canonicalPath: string,
|
canonicalPath: string,
|
||||||
bundledRootOverride?: string,
|
bundledRootOverride?: string,
|
||||||
): Promise<boolean> {
|
): 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;
|
let canonicalRoot: string;
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -269,6 +269,16 @@ function getDeclaredPageRoutePaths(manifest: PaperclipPluginManifestV1): string[
|
|||||||
* Options for the plugin loader service.
|
* Options for the plugin loader service.
|
||||||
*/
|
*/
|
||||||
export interface PluginLoaderOptions {
|
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.
|
* Path to the local plugin directory to scan.
|
||||||
* Defaults to ~/.paperclip/plugins/
|
* Defaults to ~/.paperclip/plugins/
|
||||||
@@ -1144,6 +1154,7 @@ export function pluginLoader(
|
|||||||
migrationDb = db,
|
migrationDb = db,
|
||||||
enableLocalFilesystem = true,
|
enableLocalFilesystem = true,
|
||||||
enableNpmDiscovery = true,
|
enableNpmDiscovery = true,
|
||||||
|
assertPackageActivation,
|
||||||
} = options;
|
} = options;
|
||||||
|
|
||||||
const registry = pluginRegistryService(db);
|
const registry = pluginRegistryService(db);
|
||||||
@@ -1269,6 +1280,7 @@ export function pluginLoader(
|
|||||||
|
|
||||||
// Step 3: Read and validate plugin manifest
|
// Step 3: Read and validate plugin manifest
|
||||||
// Note: this.loadManifest (used via current context)
|
// Note: this.loadManifest (used via current context)
|
||||||
|
assertPackageActivation?.({ packageRoot: resolvedPackagePath });
|
||||||
const pkgJson = await readPackageJson(resolvedPackagePath);
|
const pkgJson = await readPackageJson(resolvedPackagePath);
|
||||||
if (!pkgJson) throw new Error(`Missing package.json at ${resolvedPackagePath}`);
|
if (!pkgJson) throw new Error(`Missing package.json at ${resolvedPackagePath}`);
|
||||||
|
|
||||||
@@ -1287,6 +1299,7 @@ export function pluginLoader(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const manifest = await loadManifestFromPath(manifestPath);
|
const manifest = await loadManifestFromPath(manifestPath);
|
||||||
|
assertPackageActivation?.({ packageRoot: resolvedPackagePath, pluginKey: manifest.id, manifest });
|
||||||
|
|
||||||
// Step 4: Reject incompatible plugin API versions
|
// Step 4: Reject incompatible plugin API versions
|
||||||
if (!manifestValidator.getSupportedVersions().includes(manifest.apiVersion)) {
|
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)) {
|
if (JSON.stringify(manifest) === JSON.stringify(plugin.manifestJson)) {
|
||||||
return plugin;
|
return plugin;
|
||||||
}
|
}
|
||||||
@@ -1409,6 +1423,7 @@ export function pluginLoader(
|
|||||||
packagePath: string,
|
packagePath: string,
|
||||||
source: PluginSource,
|
source: PluginSource,
|
||||||
): Promise<DiscoveredPlugin | null> {
|
): Promise<DiscoveredPlugin | null> {
|
||||||
|
assertPackageActivation?.({ packageRoot: packagePath });
|
||||||
const pkgJson = await readPackageJson(packagePath);
|
const pkgJson = await readPackageJson(packagePath);
|
||||||
if (!pkgJson) return null;
|
if (!pkgJson) return null;
|
||||||
|
|
||||||
@@ -1438,6 +1453,7 @@ export function pluginLoader(
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const manifest = await loadManifestFromPath(manifestPath);
|
const manifest = await loadManifestFromPath(manifestPath);
|
||||||
|
assertPackageActivation?.({ packageRoot: packagePath, pluginKey: manifest.id, manifest });
|
||||||
return {
|
return {
|
||||||
packagePath,
|
packagePath,
|
||||||
packageName,
|
packageName,
|
||||||
@@ -1690,6 +1706,7 @@ export function pluginLoader(
|
|||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
async loadManifest(packagePath: string): Promise<PaperclipPluginManifestV1 | null> {
|
async loadManifest(packagePath: string): Promise<PaperclipPluginManifestV1 | null> {
|
||||||
|
assertPackageActivation?.({ packageRoot: packagePath });
|
||||||
const pkgJson = await readPackageJson(packagePath);
|
const pkgJson = await readPackageJson(packagePath);
|
||||||
if (!pkgJson) return null;
|
if (!pkgJson) return null;
|
||||||
|
|
||||||
@@ -1704,7 +1721,9 @@ export function pluginLoader(
|
|||||||
const manifestPath = resolveManifestPath(packagePath, pkgJson);
|
const manifestPath = resolveManifestPath(packagePath, pkgJson);
|
||||||
if (!manifestPath || !existsSync(manifestPath)) return null;
|
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
|
// 1. Resolve worker entrypoint
|
||||||
// ------------------------------------------------------------------
|
// ------------------------------------------------------------------
|
||||||
const packageRoot = resolvePluginPackageRoot(activePlugin, localPluginDir);
|
const packageRoot = resolvePluginPackageRoot(activePlugin, localPluginDir);
|
||||||
|
assertPackageActivation?.({ pluginKey, packageRoot, installedPackagePath: activePlugin.packagePath });
|
||||||
activePlugin = await refreshPluginManifestFromPackage(activePlugin, packageRoot);
|
activePlugin = await refreshPluginManifestFromPackage(activePlugin, packageRoot);
|
||||||
manifest = activePlugin.manifestJson;
|
manifest = activePlugin.manifestJson;
|
||||||
|
assertPackageActivation?.({ pluginKey, packageRoot, installedPackagePath: activePlugin.packagePath, manifest });
|
||||||
const workerEntrypoint = resolveWorkerEntrypoint(activePlugin, localPluginDir);
|
const workerEntrypoint = resolveWorkerEntrypoint(activePlugin, localPluginDir);
|
||||||
|
|
||||||
// ------------------------------------------------------------------
|
// ------------------------------------------------------------------
|
||||||
|
|||||||
@@ -199,6 +199,8 @@ export function pluginRegistryService(db: Db) {
|
|||||||
id: string,
|
id: string,
|
||||||
data: {
|
data: {
|
||||||
packageName?: string;
|
packageName?: string;
|
||||||
|
packagePath?: string;
|
||||||
|
status?: "upgrade_pending";
|
||||||
version?: string;
|
version?: string;
|
||||||
manifest?: PaperclipPluginManifestV1;
|
manifest?: PaperclipPluginManifestV1;
|
||||||
},
|
},
|
||||||
@@ -210,6 +212,11 @@ export function pluginRegistryService(db: Db) {
|
|||||||
updatedAt: new Date(),
|
updatedAt: new Date(),
|
||||||
};
|
};
|
||||||
if (data.packageName !== undefined) setClause.packageName = data.packageName;
|
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.version !== undefined) setClause.version = data.version;
|
||||||
if (data.manifest !== undefined) {
|
if (data.manifest !== undefined) {
|
||||||
setClause.manifestJson = data.manifest;
|
setClause.manifestJson = data.manifest;
|
||||||
|
|||||||
+46
-15
@@ -5,7 +5,17 @@
|
|||||||
// reloads parked tabs onto the fresh bundle. Left as the literal placeholder in
|
// reloads parked tabs onto the fresh bundle. Left as the literal placeholder in
|
||||||
// dev, where HMR (not the worker) drives refreshes.
|
// dev, where HMR (not the worker) drives refreshes.
|
||||||
const BUILD_ID = "__PAPERCLIP_BUILD_ID__";
|
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.addEventListener("install", () => {
|
||||||
self.skipWaiting();
|
self.skipWaiting();
|
||||||
@@ -23,32 +33,53 @@ self.addEventListener("activate", (event) => {
|
|||||||
self.addEventListener("fetch", (event) => {
|
self.addEventListener("fetch", (event) => {
|
||||||
const { request } = event;
|
const { request } = event;
|
||||||
const url = new URL(request.url);
|
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")) {
|
if (request.method !== "GET" || url.pathname.startsWith("/api")) {
|
||||||
return;
|
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(
|
event.respondWith(
|
||||||
fetch(request)
|
fetch(request)
|
||||||
.then((response) => {
|
.then(async (response) => {
|
||||||
if (response.ok && url.origin === self.location.origin) {
|
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();
|
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;
|
return response;
|
||||||
})
|
})
|
||||||
.catch(async () => {
|
.catch(async () => {
|
||||||
// caches.match() resolves undefined on a miss (and the promise itself
|
if (privateRequests.has(request.url)) return Response.error();
|
||||||
// is always truthy, so `||` can never supply a fallback). respondWith
|
if (!publicAsset) return request.mode === "navigate" ? new Response("Offline", { status: 503 }) : Response.error();
|
||||||
// must always receive a real Response — resolving undefined breaks
|
// Restrict lookup to this policy's cache; old arbitrary-response caches
|
||||||
// the navigation with "Failed to convert value to 'Response'" instead
|
// must not become fallback candidates if activation cleanup fails.
|
||||||
// of showing anything.
|
try {
|
||||||
if (request.mode === "navigate") {
|
const cached = await (await caches.open(CACHE_NAME)).match(request);
|
||||||
return (await caches.match("/")) ?? new Response("Offline", { status: 503 });
|
if (cached && !privateCacheControl.test(cached.headers.get("cache-control") ?? "")) return cached;
|
||||||
}
|
} catch { /* Unavailable cache storage is an offline miss. */ }
|
||||||
return (await caches.match(request)) ?? Response.error();
|
return Response.error();
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { ChatSetupSidebarProvider } from "@/context/ChatSetupSidebarContext";
|
import { ChatSetupSidebarProvider } from "@/context/ChatSetupSidebarContext";
|
||||||
|
import { PluginAppShellOverlays } from "./PluginAppShellOverlays";
|
||||||
import {
|
import {
|
||||||
useCallback,
|
useCallback,
|
||||||
useEffect,
|
useEffect,
|
||||||
@@ -785,6 +786,7 @@ export function Layout() {
|
|||||||
onOpenChange={setShortcutsOpen}
|
onOpenChange={setShortcutsOpen}
|
||||||
/>
|
/>
|
||||||
<ToastViewport />
|
<ToastViewport />
|
||||||
|
<PluginAppShellOverlays localTrusted={health?.deploymentMode === "local_trusted"} />
|
||||||
</div>
|
</div>
|
||||||
</GeneralSettingsProvider>
|
</GeneralSettingsProvider>
|
||||||
</ChatSetupSidebarProvider>
|
</ChatSetupSidebarProvider>
|
||||||
|
|||||||
@@ -117,6 +117,12 @@ vi.mock("./PropertiesPanel", () => ({
|
|||||||
PropertiesPanel: () => null,
|
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", () => ({
|
vi.mock("./CommandPalette", () => ({
|
||||||
CommandPalette: () => null,
|
CommandPalette: () => null,
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import { NewAgentDialog } from "./NewAgentDialog";
|
|||||||
import { KeyboardShortcutsCheatsheet } from "./KeyboardShortcutsCheatsheet";
|
import { KeyboardShortcutsCheatsheet } from "./KeyboardShortcutsCheatsheet";
|
||||||
import { ToastViewport } from "./ToastViewport";
|
import { ToastViewport } from "./ToastViewport";
|
||||||
import { AnnouncementWell } from "./AnnouncementWell";
|
import { AnnouncementWell } from "./AnnouncementWell";
|
||||||
|
import { PluginAppShellOverlays } from "./PluginAppShellOverlays";
|
||||||
import { MobileBottomNav } from "./MobileBottomNav";
|
import { MobileBottomNav } from "./MobileBottomNav";
|
||||||
import { WorktreeBanner } from "./WorktreeBanner";
|
import { WorktreeBanner } from "./WorktreeBanner";
|
||||||
import { DevRestartBanner } from "./DevRestartBanner";
|
import { DevRestartBanner } from "./DevRestartBanner";
|
||||||
@@ -788,6 +789,7 @@ export function Layout({ sidebarSections }: { sidebarSections?: ReactNode }) {
|
|||||||
<KeyboardShortcutsCheatsheet open={shortcutsOpen} onOpenChange={setShortcutsOpen} />
|
<KeyboardShortcutsCheatsheet open={shortcutsOpen} onOpenChange={setShortcutsOpen} />
|
||||||
<ToastViewport />
|
<ToastViewport />
|
||||||
<AnnouncementWell health={health} />
|
<AnnouncementWell health={health} />
|
||||||
|
<PluginAppShellOverlays localTrusted={health?.deploymentMode === "local_trusted"} />
|
||||||
</div>
|
</div>
|
||||||
</GeneralSettingsProvider>
|
</GeneralSettingsProvider>
|
||||||
</ChatSetupSidebarProvider>
|
</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-a: #a6aaad;
|
||||||
--agent-cap-v1-muted-dream-b: #44464a;
|
--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");
|
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 shell = new Response("<html>app shell</html>", { status: 200 });
|
||||||
const listener = loadServiceWorkerFetchListener({
|
const listener = loadServiceWorkerFetchListener({
|
||||||
fetch: () => Promise.reject(new TypeError("network down")),
|
fetch: () => Promise.reject(new TypeError("network down")),
|
||||||
@@ -89,7 +89,8 @@ describe("sw.js offline fallback", () => {
|
|||||||
mode: "navigate",
|
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 () => {
|
it("returns a network-error Response for a failed asset with no cache entry", async () => {
|
||||||
|
|||||||
Reference in new issue
Block a user