mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-08 22:13:59 +01:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - A managed instance gets its plugins from the control plane, and people do not install them > - Some of these plugins need deployment values, such as a service origin and a capability > - The plugin worker environment is scrubbed, and only a fixed list of first-party providers gets deployment variables > - Core also sends lifecycle events and project instance secrets only to plugins with a saved config row > - So a provisioned plugin cannot work until a person saves configuration for it > - This pull request lets the managed config grant named variables to a selected plugin > - The benefit is that a distribution plugin can run with no tenant configuration, and Core does not need to name it ## Linked Issues or Issue Description No issue exists. This describes the problem. I searched open pull requests for related work and found no duplicate. **What existing behavior does this improve?** How a plugin provisioned through `plugins.autoInstall` receives its deployment settings. **Current behavior** `buildPluginWorkerEnv` forwards variables only from a fixed list that names first-party packages. A distribution plugin cannot be on that list without Core naming it. A plugin with no `pluginConfig` row gets no resource lifecycle events, and project instance secrets refuse it. **Proposed behavior** The managed config can include `plugins.workerEnv`. It maps a catalog key to variable names. The worker of that plugin receives those variables from the server process environment. The managed config can also include `plugins.enabledCompanies`. It maps a catalog key to company IDs. In those companies, the plugin gets lifecycle events and can use project instance secrets with no config row. **Reason and benefit** The control plane stays the only place that decides which plugin gets which values. Tenants do not handle these values, and they are not in the database or in the managed config document. **Alternatives considered** - Create a config or enablement row for each company. The control plane would need a new tenant write API, and a row can stay after the document removes access or return after a database restore. The document is never saved, like the other managed settings. - Give a grant access to every company. This has no per-company opt-in. - Put the values in the managed config document. The document does not hold secrets, and this rule stays true because it holds only names. ## What Changed - `parseManagedConfigEnv` accepts an optional `plugins.workerEnv` object. Each key must be in `plugins.autoInstall`. Names must be uppercase variable names, with no duplicates. - Startup fails if a name looks like a host credential, such as a name with `KEY`, `TOKEN`, `SECRET`, `PASSWORD`, `DATABASE` or `JWT` as a word, or if it controls how the worker loads code, such as `NODE_OPTIONS`. - `parseManagedConfigEnv` accepts an optional `plugins.enabledCompanies` object. Each key must be in `plugins.autoInstall`. Values must be lowercase company UUIDs, with no duplicates. - `managedPluginWorkerEnvGrant` and `managedPluginEnabledCompanies` return values by plugin key. A managed instance only starts a selected plugin from the current image, so the saved package path is not part of this check. It can be old or null. - `buildPluginWorkerEnv` forwards granted variables. This does not depend on the `environment.drivers.register` capability. - Plugin lifecycle delivery includes a plugin with no config row only for the companies the document names. It still skips a company that disabled the plugin. The runtime event listener follows the same rule. - Project instance secrets accept a plugin with no config row only for a company the document names. - A variable grant alone gives no company access. - `doc/plugins/DISTRIBUTION-PLUGINS.md` describes both sections. ## Verification - `npx vitest run` in `server/` on `managed-config`, `managed-plugin-worker-env`, `plugin-lifecycle-delivery`, `project-instance-secrets`, `plugin-database` and `bundled-plugins`: 201 tests pass. - The new tests cover a digest-verified distribution plugin, a stale saved package path, a catalog key named after an inherited property, a company the document does not name, a company that disabled the plugin, a named company that does not exist, a grant with no enabled company, and host credential names. - `tsc --noEmit` in `server/` shows no errors in the changed files. My worktree does not have the built `@paperclipai/paperclip-runner` package, so unrelated files show errors locally. CI runs the full typecheck. - I did not run the full test suite locally. CI runs it. - I did not test this change on a running instance. ## Risks - **Older builds:** a build without this change refuses a document with `plugins.workerEnv` or `plugins.enabledCompanies`, because it fails closed on unknown keys. A control plane must send these sections only to a build that supports them. - **Trust:** the granted plugin gets the variable values in its process. The image owner and the control plane already trust this plugin. The name check stops a mistake with host credentials, but a name check cannot prove that a value is not sensitive. Core cannot use an allowlist, because it must not name the variables of a private plugin. - **Company scope:** the control plane sets which companies a plugin serves. A new company needs a new document, and the server reads the document when it starts. Self-hosted instances do not change. - **Rotation:** a new value reaches the worker only when the server restarts. Rotation without a restart is not part of this change. - **Background calls:** a grant does not add proactive company access. A plugin that makes background calls with no invocation still needs a saved config for that company. - This changes core plugin behavior, and I did not discuss it in `#dev` first. Maintainers can tell me if they want a discussion before review. ## Model Used - Claude Opus 5.5 (`claude-opus-5-5`), through Claude Code, with tool use and code execution. ## 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 - [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 - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
147 lines
7.6 KiB
Markdown
147 lines
7.6 KiB
Markdown
# 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.
|
|
|
|
A managed instance can also give a selected plugin process variables through
|
|
`plugins.workerEnv`. The document holds variable names, not values. The deployment
|
|
sets the values in the server's process environment:
|
|
|
|
```json
|
|
{ "plugins": {
|
|
"autoInstall": ["example-extension"],
|
|
"workerEnv": { "example-extension": ["EXAMPLE_ORIGIN", "EXAMPLE_CAPABILITY"] }
|
|
} }
|
|
```
|
|
|
|
Each key must be in `autoInstall`. Startup fails if a name looks like a host
|
|
credential, such as one with `KEY`, `TOKEN`, `SECRET`, `PASSWORD`, `DATABASE` or
|
|
`JWT` as a word, or if it controls how the worker loads code, such as
|
|
`NODE_OPTIONS`. Give a granted variable a name for its own use, not a reused host
|
|
variable. Only the plugin with that catalog key receives the variables. A managed
|
|
instance runs a selected plugin only from the current image, so an install from
|
|
another source does not start.
|
|
|
|
A grant does not give access to any company. To let a plugin serve a company with
|
|
no saved configuration, name the company in `plugins.enabledCompanies`:
|
|
|
|
```json
|
|
{ "plugins": {
|
|
"autoInstall": ["example-extension"],
|
|
"enabledCompanies": { "example-extension": ["<company-uuid>"] }
|
|
} }
|
|
```
|
|
|
|
The plugin then receives resource lifecycle events for that company and can use
|
|
project instance secrets there. A company can still disable the plugin. This does
|
|
not give access for background worker calls. Those still need a saved company
|
|
configuration. Core reads the list from the document on each use and does not save
|
|
it, so a database restore cannot bring back access that the document removed.
|
|
|
|
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.
|