deer-flow/docs/full-stack-plugins.md
Zeren Wang 82671596f3
feat(authz): add plugin resource targets, provenance, action and management enforcement (#5842)
* feat(authz): add plugin resource targets, provenance, action and management enforcement

PR1 of the extension-tool-authorization series
(docs/plans/2026-09-24-extension-tool-authorization-spec.md): plugin
authorization plumbing, no new author-facing declaration.

- `authz/plugin_targets.py` is the single encoder/validator for composite
  targets (`namespace/part`), with one validating composer behind three
  kind-checked constructors; invalid input raises instead of degrading.
- `authz/plugin_authz.py` is the harness decision layer (`plugin_action`,
  `plugin_management`, plus the batch filters the page slice will consume):
  disabled is a no-op, an explicit deny raises, and provider errors follow
  `fail_closed` like the sandbox gate.
- `POST /api/plugins/{ns}/actions/{name}` decides `plugin_action:invoke`
  after the action resolves and before the body is streamed.
- `deerflow_extension_api.auth` publishes `require_plugin_management` /
  `arequire_plugin_management` with two `app.state` resolver keys; the
  Gateway installs both. Contract version 0.2.3 -> 0.2.4.
- `tools/tool_provenance.py` records host-written plugin provenance for
  display (assembly descriptor, Layer-2 context later); no decision reads a
  label.
- Plugin providers use a `(config signature, loop key)` cache so a
  loop-affine provider is never handed to another loop or to a sync caller.
- RBAC gains `plugin_actions` / `plugin_management` aliases;
  `config_version` 47 -> 48 across the three mirrors.

Tests: new plugin target/provenance/action/management suites plus a
blocking-IO anchor whose offloads were reverted to prove red->green.

* fix(authz): decide plugin resources from the request's config snapshot

Review round 1 on PR #5842 (willem-bd).

P1 — the decision layer no longer reloads the configuration. The six
`authz/plugin_authz.py` functions take a required `app_config` snapshot (the
same request-scoped config the Gateway used to resolve the provider), and
`resolve_/aresolve_plugin_authorization` return it as a third element, so
`enabled`/`fail_closed` can never come from a second read that drifted from
the one that produced the provider. A snapshot the caller could not read at
all is *unavailable*, not disabled: it fails closed as
`authz.config_unavailable`. The Gateway's `_plugin_app_config` now separates
an absent `config.yaml` (no gate, today's behavior) from a config that exists
but cannot be read or validated (deny) instead of swallowing both through
`safe_app_config`.

P2 — `_validated_decision` requires `type(decision.allow) is bool` on both
the sync `authorize` and async `aauthorize` paths, so a provider returning
`AuthzDecision(allow="false")` is a malformed verdict that follows
`fail_closed` instead of being read as an allow.

Both fixes are covered by regression tests that were verified red against the
pre-fix behavior (P1: 200 vs 403 and resolver True vs False; P2: 12 cases
across fail_closed/fail_open and sync/async), plus tests pinning that an
absent config and `authorization.enabled: false` keep today's behavior.

* fix(authz): treat a request-time config absence as unavailable

Review round 2 on PR #5842 (willem-bd).

P1 — `_plugin_app_config[_async]` no longer maps a missing `config.yaml` to
`None` ("no gate"). A Gateway cannot have started without a readable config
(`app/gateway/app.py` reads it through this same accessor in `lifespan`), so
absence *during a request* means the configuration this process was running on
became unavailable — a hot reload, an atomic replace, a ConfigMap remount. It is
unavailable, not disabled: the error propagates,
`resolve_/aresolve_plugin_authorization` raise
`_PluginAuthorizationUnavailable(fail_closed=True)`, the action dispatcher
returns 403 and the management resolvers answer `False`. The dead
`(None, None, None)` allow path is gone, the resolvers return a non-optional
`AppConfig` as their third element, and callers key "authorization disabled" on
`provider is None` alone. This matches the rule the decision layer already
documented (`authz.config_unavailable`) and the public docs.

P2 — `_deny_reason_code` is total: a `reasons` that is not iterable degrades the
code to `authz.denied` instead of raising `TypeError` outside the guarded
provider path, which turned a denial into an unhandled error. It is deliberately
not folded into `_validated_decision`: `allow` was a real `bool`, so the verdict
is a valid denial, and treating it as malformed would flip it to an allow under
`fail_closed: false`.

Both fixes are covered by regression tests verified red against the pre-fix
behavior (P1: resolver True vs False, async `PermissionError` not raised, route
200 vs 403; P2: `TypeError: 'NoneType' object is not iterable` at
`plugin_authz.py:113`). The action-route tests in
`tests/test_plugin_contributions.py` now install a readable
`authorization.enabled: false` config instead of relying on a config-less
process, and `docs/full-stack-plugins.md` plus the PR1 decision log record the
new rule.

* fix(authz): apply the running policy when the plugin config is unreadable

Follow-up to f03d2ea9 (review round 2 on PR #5842, willem-bd).

The first attempt read the P1 finding as "absence is never disabled" and failed
every config read closed unconditionally. That is too broad for this repo: a
config-less host is deliberately treated as having no policy to apply
(`_get_route_authorization_config` falls back to a disabled config,
`sandbox_authz.safe_app_config` documents the same no-op for "CI runners and
direct-call tests"), and the rule turned the config-less e2e plugin host
(`extension_test_fixtures.bookmark_plugin_gateway`) into a 403 — the three
`bookmark-plugin.spec.ts` cases lost the save action. Reproduced locally:
`POST /api/plugins/community.bookmarks/actions/save` against that fixture host
returned 403 before, 200 after this change.

The rule is now "has this process loaded a configuration?", not "was the last
read successful?":

- No loaded config (`peek_loaded_app_config()`, new read-only accessor on
  `deerflow.config.app_config`) and no `config.yaml`: no policy to apply, so the
  gate stays a no-op — unchanged for CI runners, direct-call tests and hosts
  that mount only the plugins router.
- A loaded config whose file disappears or cannot be read: *unavailable*, never
  *disabled*. The resolvers raise `_PluginAuthorizationUnavailable` carrying the
  failure flag of the configuration this process runs on (authorization
  disabled there -> no gate; enabled -> that policy's `fail_closed`, `True` by
  default), so an enabled Gateway answers 403/False in the window instead of
  letting protected plugin actions and management routes through.
- A config file that exists but cannot be read or validated right now: fail
  closed when nothing is loaded (round 1's rule), otherwise the loaded policy
  decides.

`peek_loaded_app_config()` is preferred over a Gateway-side startup snapshot: it
reflects the last successfully loaded config, so a hot-reloaded policy is what
applies during the window, and there is no lifecycle hook to keep in sync.

Evidence: the three no-config/lost-config branches in
`tests/test_plugin_management_guard.py` and
`tests/test_plugin_action_authorization.py`, round 1's unreadable-config tests
unchanged, `test_peek_loaded_app_config_survives_a_missing_file` in
`tests/test_app_config_reload.py`, and the fixture-host probe above. Docs
(`docs/full-stack-plugins.md`, `reference.mdx` en/zh) and the PR1 decision log
state the final rule; the log records the superseded variant.

* fix(authz): keep a denial whose reasons raise while being read

* fix(authz): intersect plugin batch filters with the candidates

The plugin_page/plugin_management batch helpers returned the provider's
`filter_resources` answer verbatim, so a custom provider could hand the
projections a target that was never asked about ("*", another namespace,
a phantom page). Every other filter call site re-projects from its own
candidate list; do the same here, state the intersection on the provider
contract, and pin it with a regression test that fails on the old
pass-through.

* docs(plugins): map a management denial to 403 in the route example

The contributed-route example called `arequire_plugin_management` and let
the documented `PermissionError` escape. The Gateway installs no
`PermissionError` handler, so a route copied from the example answered 500
on a normal policy denial instead of 403. Both example routes now translate
the denial, and the surrounding bullets say why the translation is the
route's job.

* docs(extensions): point the 0.2.4 contract row at the PR that ships it

Every other row in the version-history table links the pull request that
shipped the version; 0.2.4 linked the authorization tracking issue instead.
Both the English and Chinese tables now link this PR.
2026-09-25 14:46:12 +08:00

362 lines
22 KiB
Markdown

# Full-stack plugin contributions
A deployment-installed Python extension can register a `PluginContribution` with
optional browser code, authenticated backend actions and model tools. This extends
the existing `install(registry, config)` workflow. MCP and Skills keep their existing
APIs and lifecycles. Public contracts live in `deerflow_extension_api` (0.2.4).
The browser contribution API is experimental. `BrowserModule(code=...)` remains the
self-contained transport; `BrowserAssets(root=...)` adds manifest-listed resources
without changing the page/action API or requiring a host frontend rebuild.
## What users see
Capability Center has an **Extensions** tab with read-only information and deployment
status. A plugin can add a conversation action, its own workspace page and an optional
sidebar entry. The [bookmarks example](../examples/deerflow-extension-bookmarks/README.md)
uses all three: save the last visible answer, then search, rename or delete it under
**My bookmarks**. Existing notification and Markdown/JSON export behavior is unchanged.
The [Jev context pruning example](../examples/deerflow-extension-jev-context/README.md)
combines a catalog contribution with public middleware hooks to shorten old read-only
tool results. It requires deployment opt-in and a separate Jev API key.
## Registration and execution
`registry.plugin(...)` returns `True` when accepted. Its default public protocol
implementation returns `False` on a host without support, so packages must check the
result. A declaration needs a unique namespace and at least one browser module,
backend action or tool. Validation happens before registration; install failures use
the existing positional rollback and source attribution.
- `BrowserModule(module, code, public_fields=())` contains a self-contained ES module,
at most 512 KiB. It may export `surfaces` and `conversationActions` with `apiVersion: 1`.
- `BrowserAssets(module, root, manifest="ui_manifest.json", public_fields=())`
loads a versioned resource manifest from an installed package (details below).
- `BackendAction(name, handler)` declares an async handler receiving a JSON object and
`ActionContext(principal, settings)`. The principal comes from host authentication.
- `ModelTool(name, description, input_schema, handler, group="extensions")` declares
an async handler with a `ToolContext` that additionally carries the thread ID.
Object schemas must be inline: reference resolution is rejected. Tools enter the
ordinary host tool assembly, including group filtering and later authorization.
- `SettingsField` describes a non-secret deployment value. There is no online override
store or settings write API. Browser clients see only `enabled` and explicitly
listed public fields. Plugin code remains responsible for business authorization.
Tool names are namespace-derived and collision-checked. Tool inputs are bounded to
256 KiB, outputs to 64 KiB and execution to 30 seconds. Backend actions accept object
inputs up to 256 KiB and have a 30-second timeout. Cancellation does not guarantee
rollback of external effects or already-running worker-thread operations.
A plugin can also contribute tools alone. The
[text classification example](../examples/deerflow-extension-jev-classify/README.md)
registers one model tool and a status action, no browser code: the agent labels a
list of texts through a deployment-configured Jev or chat-model backend, and the
plugin keeps the whole call inside those bounds with its own batch and deadline
limits. It requires deployment opt-in and a separate backend API key.
Durable `batch_task` workers pin the Gateway app's extension snapshot at startup
and use it for both plugin tools and subagent execution. Recovered items use the
new worker's snapshot after restart; no Python snapshot is stored in the durable
`execution_spec`. Standalone batch services without an explicit snapshot capture
the process default once at construction.
## Browser API
The authenticated host exposes:
| Route | Purpose |
| ------------------------------------------------------- | --------------------------------------------------------------- |
| `GET /api/plugins` | Deployed descriptors, public configuration and declared actions |
| `GET /api/plugins/modules/{module}/{sha256}.mjs` | Installed code with a content revision |
| `GET /api/plugins/{namespace}/assets/{revision}/{path}` | One manifest-listed static resource |
| `POST /api/plugins/{namespace}/actions/{name}` | Invoke one declared backend action |
The host does not accept filesystem paths, import strings or arbitrary remote URLs
from the browser. Inline modules use JavaScript content type and private no-store
caching; packaged assets use their declared file type and private immutable caching.
Both transports send `nosniff`. Existing Gateway session/CSRF policies apply; PATs do not gain a new
route allowlist. The browser sends its descriptor's viewer ID so the action route can
reject a stale view after account changes, in addition to normal request authentication.
Inline module downloads honor `NEXT_PUBLIC_BACKEND_BASE_URL`, including a path prefix,
and use the host's authenticated fetch helper before importing a temporary Blob URL.
The URL is released after import, including on failure. Browser modules must be
self-contained: relative imports and assets resolved against `import.meta.url` are
unsupported. Deployments with a Content Security Policy must allow `blob:` in
`script-src` and the configured backend in `connect-src`; split-origin backends must
allow credentialed CORS from the frontend, as for other host API calls.
A page surface declares `id`, `slot: "page"`, `title`, `mount(root, context)` and
optional `navigation: { label, labelZh?, icon? }`. The host generates the URL
`/workspace/extensions/{namespace}/{id}` and mounts only that registered page.
`mount` runs synchronously and returns an object `{ dispose }`, whose `dispose()` is called on unmount. The context includes locale,
public settings, an abort signal and a namespace-bound `callBackend` helper.
The optional `openConversation(threadId)` host helper reads current conversation
metadata through the authenticated API and uses the host's normal/custom-agent
routing rules. Missing or inaccessible conversations reject without navigating;
unmount/account changes abort pending reads and prevent late navigation. Cleanup
aborts outstanding work and fences late callbacks. Plugin async work should observe
the signal and release resources in `dispose()`.
Conversation actions receive a conversation context and host services. The
`latestVisibleAnswer` and `conversationText` services reuse the existing export
sanitizer; sidebar reads go through the authenticated conversation API. Plugin code
must still escape user/model text when rendering it.
Factories and availability callbacks must be synchronous; invalid Promise returns
are rejected and their rejections consumed. The host validates each locale-dependent action group and evaluates availability
inside a per-plugin error boundary. A malformed or throwing contribution is omitted
without removing healthy plugin actions or failing the conversation page. Plugin tool
names that collide with ordinary tools follow the host's ordinary-first deduplication;
unrelated tools remain available. Duplicate names within the plugin tool set still fail
strict validation.
## Packaged resources (`assets-v1`)
Register a package-owned directory alongside the Python implementation:
```python
from pathlib import Path
from deerflow_extension_api import BrowserAssets, PluginContribution
registry.plugin(PluginContribution(
namespace="community.example",
title="Example",
frontend=BrowserAssets("example.v1", Path(__file__).parent),
))
```
Place `ui_manifest.json` at that root, and include it and every listed file in the
installed wheel. The [bookmarks package](../examples/deerflow-extension-bookmarks/README.md)
is a working example. Its manifest uses this schema:
```json
{
"schema_version": 1,
"entry": "static/dist/index.mjs",
"files": [
"static/dist/index.mjs",
"static/dist/chunks/bookmarks.mjs",
"static/dist/styles.css",
"static/dist/bookmark.svg"
]
}
```
`entry` must be a listed `.js` or `.mjs` ES module exporting the existing browser
API v1 object. Relative static/dynamic imports and `new URL(..., import.meta.url)`
resolve within the revision's URL tree. Bundle third-party dependencies into the
package: bare npm imports and shared host React instances are not provided. Emit
relative URLs, not absolute `/assets/...` paths from a bundler's default public path.
The manifest accepts only `schema_version`, `entry` and `files`; unknown versions,
extra/duplicate keys, duplicate paths, missing files and symlinks are rejected at
registration. The declared root itself must not be a symlink (including a dangling
link); ordinary parent directory aliases are resolved before checking package files.
Paths use ASCII letters, digits, `_`, `-`, `.` and `/` separators;
segments must start with a letter, digit, `_` or `-`. Dotfiles, dot segments, empty
segments, percent encoding, query strings and backslashes are rejected. No directory
listing or unlisted file is served. Limits: 64 KiB manifest, 256 files, 4 MiB per
file and 16 MiB total per plugin. These limits bound the in-memory startup snapshot.
Supported types are JS/MJS, CSS, JSON/source maps, WASM, PNG/JPEG/GIF/WebP/SVG/ICO
and WOFF/WOFF2/TTF/OTF. HTML and executable server files are not served. SVG can
contain active document content, so asset responses carry
`Content-Security-Policy: sandbox`: direct navigation cannot execute scripts or
retain the Gateway's origin.
This document restriction preserves image, stylesheet and module subresource use;
it does not sandbox the plugin JavaScript deliberately loaded into the host page.
Do not list secrets or private build sources; any authenticated user can download listed assets,
including source maps, even when the contribution is disabled.
The host snapshots all listed bytes during registration and computes a SHA-256
revision over the manifest, paths and file content hashes. Changing any resource
changes every resource URL's revision. Requests read that immutable snapshot, not
the filesystem. Responses require authentication and send
`Cache-Control: private, max-age=31536000, immutable`, `Vary: Cookie, Authorization`
and the file's MIME type. Missing files/revisions return 404 with `private, no-store`.
There is no public CDN contract or retained historical snapshot. A browser can retain
already cached code after removal; uncached old resources require a page reload after
restart/upgrade. Installation and authorization are not instantaneous cache revocation.
Discovery labels the transport `inline-v1` or `assets-v1`. The frontend rejects unknown
transports and treats an omitted transport as legacy inline v1. Deploy the matching
host frontend/backend together before installing an assets-v1 plugin; this does not
make old host frontends support the new transport.
For packaged JavaScript the host inserts a native module script with
`crossorigin="use-credentials"`, then obtains exports from the document's module map.
This preserves authenticated static and lazy imports without rewriting source or Blob
URLs. See the [HTML module-script credential rules](https://html.spec.whatwg.org/multipage/scripting.html#attr-script-crossorigin).
URLs honor `NEXT_PUBLIC_BACKEND_BASE_URL`, including relative/absolute prefixes.
The host stops waiting after 30 seconds and rejects that contribution, removing its
loading script node. This is a waiting deadline, not execution cancellation: native
module fetching/evaluation can continue and top-level side effects can occur later.
A late completion cannot change the already rejected host result. Blocking synchronous
plugin code can also delay the deadline's timer. Trusted plugins should keep top-level
code free of user-visible side effects, start UI work in `mount` and release it in
`dispose`. This loader does not provide preemption, rollback or a security sandbox.
Module exports are shared within a document. Keep viewer data in per-mount state,
observe the host abort signal and clear it on dispose; never retain principals or
private results in module-level state across account changes.
Split-origin deployments need exact-origin credentialed CORS and working session
cookies; browser third-party cookie restrictions still apply.
Other resource requests are the plugin's responsibility: attach a stylesheet or image
with `crossOrigin = "use-credentials"`, and use `fetch(url, { credentials: "include" })`
for WASM, JSON or binary assets. CSS font/background requests do not universally carry
cross-origin session cookies. Prefer same-origin deployment for such CSS references,
or fetch with credentials and construct a `FontFace`/Blob URL, releasing it on dispose.
The host does not rewrite CSS URLs or add authorization tokens to URLs. CSP must allow
the backend origin in the applicable `script-src`, `style-src`, `img-src`, `font-src`
and `connect-src` directives. Inline-v1 still needs `blob:` in `script-src`.
## Authorization of plugin surfaces
Fine-grained authorization (`authorization:` in `config.yaml`; the native RFC is
[`docs/plans/2026-07-10-pluggable-authorization-rfc.md`](plans/2026-07-10-pluggable-authorization-rfc.md))
is enforced on two plugin entry points when it is enabled. With
`authorization.enabled: false` both are no-ops and today's behavior is
unchanged, on every entry point.
| Entry point | `resource` | `action` | `target` |
| --- | --- | --- | --- |
| Registered backend action | `plugin_action` | `invoke` | `<namespace>/<action-name>` |
| Enterprise management route | `plugin_management` | `read` / `write` | `<namespace>/permissions.read` / `<namespace>/permissions.write` |
Targets are composed and validated by `deerflow.authz.plugin_targets`; the left
side is always a host-validated plugin namespace. Read and write management
authority are separate **targets** (the built-in provider ignores `action`), and
page access never implies write authority.
The registered-action check runs in `POST /api/plugins/{namespace}/actions/{name}`
after the action has been resolved — so an unknown action stays a 404 — and
**before** the request body is read, so a denied caller cannot consume the
256 KiB input budget or reach the handler. A denial is
`403 {"detail": "Plugin action not permitted for your role."}`; `fail_closed`
(see below) decides whether a provider failure denies or proceeds.
The built-in RBAC provider reads these from `authorization.provider.config.roles`
as `plugin_actions` and `plugin_management`. A role with no policy for a
resource is **unrestricted** for it, so list these keys explicitly wherever you
want to constrain them; `config.example.yaml` shows the shape.
### Guarding a contributed management route
A contributed router is not covered by the registered-action dispatcher, so it
asks for plugin-scoped authority itself:
```python
from deerflow_extension_api import arequire_plugin_management, require_admin
from fastapi import HTTPException, Request
@router.get("/{namespace}/permissions")
async def read_permissions(namespace: str, request: Request):
try:
principal = await arequire_plugin_management(request, namespace, scope="read")
except PermissionError as exc:
raise HTTPException(status_code=403, detail=str(exc)) from exc
... # read the enterprise's own policy store
@router.put("/{namespace}/permissions")
async def write_permissions(namespace: str, request: Request):
try:
principal = await arequire_plugin_management(request, namespace, scope="write")
except PermissionError as exc:
raise HTTPException(status_code=403, detail=str(exc)) from exc
... # validate delegation/scope, then commit
```
- The helper asks the host's configured provider, with the host's trusted
request identity; it never reads or writes enterprise policy itself.
- It **fails closed**: an unknown namespace, an anonymous caller, a missing or
failing host resolver, or a non-allowed decision raises `PermissionError`.
- That `PermissionError` is not an HTTP response. The Gateway installs no
`PermissionError` handler, so a route that lets it escape answers `500`, not
`403`; translate it at your own boundary as the example does. The host's other
contributed-route safeguards — authentication, CSRF, PAT rejection — run
before your handler and none of them covers a policy denial.
- When authorization is disabled the host answers "allow", so the helper is a
no-op and a deployment that turns authorization off does not start rejecting
enterprise routes. An enterprise that needs an unconditional floor keeps
calling `require_admin` as well.
- "Cannot answer" includes a configuration the host cannot read right now: a
config file mid-write or briefly absent while an editor, a deploy or a
ConfigMap mount replaces it, or a document that does not validate. The
decision uses the config snapshot captured when the request's provider was
resolved, and a host that is running on a configuration never reads such a
failure as "authorization is disabled": it follows the configured failure
policy, which denies under the default `fail_closed: true`. A host with no
configuration at all has no policy to apply, so the helper stays a no-op.
- Use `arequire_plugin_management` from an async endpoint. `require_plugin_management`
is the synchronous form for a FastAPI `def` endpoint, which FastAPI runs in
its thread pool; calling it from an async endpoint would do the host's
configuration read on the event loop.
- The plugin namespace must be an installed plugin's namespace, and the
decision is re-evaluated per request: an execution-time policy change is
observed by the next call.
### Documented boundaries
- **Contributed routers are not covered by the registered-action dispatcher.**
Only registered actions go through the automatic check; a contributed router
uses `require_plugin_management` for plugin-scoped checks and keeps its own
administrator floor where one is needed. Arbitrary in-process plugin code is
trusted operator-installed code and RBAC does not sandbox it.
- **Declared plugin pages are not gated yet.** A page's navigation entry, direct
route and mount are still decided by the plugin's `enabled` setting alone;
server-evaluated page authorization arrives with the page-declaration
contract. Until then a browser surface must keep checking its own backend
operations, which *are* covered above.
- **Runtime-injected tools stay Layer-2 decisions.** Tool declarations added
through `request.tools` inside `wrap_model_call`, and model calls naming a tool
absent from the bound tool set, are not part of assembly-time narrowing; the
execution-time provider decides each call by name, like every other tool call.
- **`create_deerflow_agent` (`deerflow.agents.factory`) still performs no
authorization.** Pre-existing and unchanged by this work.
- **Durable tasks keep today's contract.** Only the exposed submission wrapper is
checked; raw status/cancel service calls and their ownership checks are
unchanged.
- **The pre-existing route/model/skill authorization cache is untouched.** It
keeps its synchronous lifecycle and call sites; the plugin paths use a
separate provider cache keyed by configuration signature and event loop, so a
loop-affine provider is never handed to another loop.
## Trust and lifecycle
**Browser and Python plugins are trusted operator-installed code.** Browser modules
run in the main page. Shadow DOM scopes CSS; it is not a security sandbox, and a
plugin can access same-origin browser capabilities. No shared React instance is
promised: plugins mount their own DOM rather than providing a component for the
host React tree. Sandboxed iframes, right-side panels, composer selection and version
negotiation beyond API v1 checks are follow-up designs discussed in #5510 and #5539.
Install, remove, enable/disable or upgrade with the existing deployment/CLI workflow
and restart the service. Python packages must be delivered into the actual execution
environment. Once this host contract is installed, a new compatible plugin does not
require plugin-specific host frontend compilation. Container delivery still needs a
persistent package installation or an image containing the package.
Browsers retain their discovered plugin set until manual refresh; there is no automatic
refresh or hot-unload guarantee. Old UI does not guarantee old backend code is retained
across a service restart. A changed asset revision or removed action fails explicitly
and requires reload. Disabled/unloaded pages have no navigation entry after refresh;
visiting an unavailable page directly does not mount a plugin.
## Validation scope
The bookmark E2E test runs the production frontend against real Python action handlers
and SQLite, with synthetic authentication and scripted LangGraph ToolNode calls.
It covers persistence, owner isolation, read-only deployment management and the full
page workflow. It does not claim live model behavior or a full production deployment.
The example uses single-host storage, not a multi-node persistence contract.
`plugin-assets.spec.ts` also checks authenticated static/lazy imports, CSS and images,
plus direct SVG navigation through the real Gateway asset route. Its script-execution
control removes the sandbox header from the same SVG to verify the restriction.
To exercise the actual Turbopack development build, start the frontend with
`DEER_FLOW_DEV_BUNDLER=turbo pnpm dev`, then run
`PLAYWRIGHT_SKIP_WEB_SERVER=1 pnpm exec playwright test tests/e2e/bookmark-plugin.spec.ts`.
Set `PLAYWRIGHT_BASE_URL` if the development server uses a port other than 3000.