mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-11-12 22:20:33 +00:00
* feat(frontend): improve file attachment recovery and artifact presentation * feat(extensions): add Office artifact previews with search and navigation * fix: preserve public viewers and HTTP Office previews * fix: address file preview and upload review findings * fix: use typed upload errors and linear bookmark allocation * fix(office-preview): isolate parsing and bound preview resources * fix(ci): install Office preview fixture dependencies for E2E * fix(ci): provision matching Playwright browsers before E2E tests * fix(ci): isolate Python libraries and include Office documentation * test(frontend): wait for download status to finish rendering
607 lines
36 KiB
Markdown
607 lines
36 KiB
Markdown
# Full-stack plugin contributions
|
||
|
||
Page surfaces may use optional `context.requestBackend(path, options)` for
|
||
contributed `/api/extensions/` routes. It returns the HTTP Response and supports
|
||
method, headers, body (including File) and an optional signal. The host supplies
|
||
the configured Gateway URL, credentialed CSRF fetcher, descriptor-bound viewer
|
||
header and page abort signal. Foreign URLs, traversal and redirects are rejected;
|
||
writes are never retried. Gateway returns 409 for stale viewer headers before
|
||
extension dispatch. This is not an authorization grant: contributed routes must
|
||
retain all principal, plugin and resource checks. Older hosts omit this service;
|
||
modules must render an unavailable state. The independent
|
||
[knowledge center](../examples/deerflow-extension-knowledge-center/README.md)
|
||
demonstrates capability-aware ingestion and status views using existing APIs.
|
||
|
||
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.7).
|
||
|
||
Backend actions and model tools may use the optional host-bound
|
||
`context.agent_runs` capability to create, continue, inspect, resume, and cancel
|
||
full Agent runs. See [Agent run control](../backend/docs/extension-agent-runs.md)
|
||
for authorization, service-held handle lifetime, and handoff examples.
|
||
|
||
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.
|
||
Since API 0.2.7, keyword-only `interaction` defaults to `"other"`; the Gateway
|
||
stamps `"session"` only for authenticated session actions. It is not taken from
|
||
payloads and is never stamped on model `ToolContext`. Plugins implementing human
|
||
output review must also check resource ownership, exact attempt/version, and
|
||
reject `ToolContext`; session provenance alone is not business authorization.
|
||
- `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.
|
||
|
||
### Structured tool artifacts
|
||
|
||
Existing model tools return JSON-encoded content. To return separate structured
|
||
metadata, declare the keyword-only `response_format="content_and_artifact"` and
|
||
return `PluginToolResult` from the async handler:
|
||
|
||
```python
|
||
from deerflow_extension_api import ModelTool, PluginToolResult
|
||
|
||
async def search(payload, context):
|
||
return PluginToolResult(
|
||
content="Retrieved excerpt from the selected document.",
|
||
artifact={"documents": [{"title": "Guide", "excerpt": "Retrieved excerpt"}]},
|
||
)
|
||
|
||
tool = ModelTool(
|
||
"search", "Search selected documents",
|
||
{"type": "object", "properties": {"query": {"type": "string"}}},
|
||
search, response_format="content_and_artifact",
|
||
)
|
||
```
|
||
|
||
Content must be a string and artifact must be a JSON object or `None`, with string
|
||
keys in every nested object. Non-string keys are rejected rather than coerced.
|
||
Content and artifact share the 64 KiB UTF-8 JSON output cap. The host validates and snapshots the artifact
|
||
before attaching it to `ToolMessage.artifact`; it does not append it to ordinary
|
||
tool content or let it replace the tool name or call ID. Invalid result shapes,
|
||
non-JSON values and oversize output use the normal sanitized tool-error path.
|
||
The default `response_format="content"` preserves existing JSON encoding,
|
||
including tuple/list results. The structured mode requires a typed result rather
|
||
than a bare tuple; an older host cannot silently JSON-encode that carrier.
|
||
|
||
Use the matching host and extension-api 0.2.6 or newer, and declare that API floor
|
||
through the existing extension decorator and package requirement. Artifacts can
|
||
be persisted and exposed to host clients; they are not secret storage. Generic
|
||
artifact support does not grant citation trust or change native RAGFlow tool-name
|
||
checks. See the [local retrieval example](../examples/deerflow-extension-retrieval/README.md)
|
||
for a small, default-disabled plugin without a knowledge-management service.
|
||
|
||
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`)
|
||
|
||
### Artifact previews
|
||
|
||
Browser modules can declare `artifactPreviews: [{ id, extensions, mount }]` to
|
||
render otherwise unsupported binary files in the artifact panel and separate reader.
|
||
`extensions` contains 1–32 exact lowercase extensions without dots (for example
|
||
`["xlsx", "docx", "pptx"]`); there may be at most 16 uniquely named renderers per
|
||
module. Built-in text and browser media readers take precedence. The first enabled
|
||
matching contribution for the current viewer is selected.
|
||
|
||
`mount(root, { filename, locale, signal, readFile, onError })` runs synchronously and
|
||
returns `{ dispose() }`. The host places the root inside a shadow tree and remounts
|
||
on viewer, file, conversation, locale or contribution changes. `readFile()` uses the
|
||
current conversation's authenticated artifact route and enforces a 25 MiB streamed
|
||
limit. Observe the abort signal during asynchronous work; call `onError()` for a
|
||
localized download fallback, and release all resources in `dispose()`. Shadow DOM
|
||
isolates styling, not plugin privileges. Third-party parsers must ship in the trusted
|
||
package and apply their own expansion/render limits.
|
||
|
||
Optional `getReadingState()` and `saveReadingState(state)` callbacks become available
|
||
after `readFile()` and retain bounded JSON in memory for unchanged content in the
|
||
current viewer session. They are scoped to viewer, thread, file, renderer and content
|
||
hash. Do not store document contents or credentials in reading preferences.
|
||
|
||
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.
|
||
|
||
### Middleware-declared tools
|
||
|
||
A contributed middleware may declare tools through LangChain's `middleware.tools`
|
||
attribute. When authorization is enabled, every build collects those declarations
|
||
after the middleware stack is assembled and applies the same `tools` policy as for
|
||
explicitly configured tools, seeded with the build's Layer-1 verdicts (a name
|
||
denied for the build cannot be resurrected by declaring it). A middleware whose
|
||
declaration is denied is replaced in that build's stack by an independent,
|
||
state-preserving copy carrying only the authorized declarations — the contributor's
|
||
own instance is never mutated, so a later build under a more permissive principal
|
||
binds the declaration again. Declarations must be `BaseTool` instances with a
|
||
usable name: anything else (for example a plain callable, which LangChain would
|
||
otherwise auto-convert and bind unchecked) cannot be authorized by name and is
|
||
removed from the bound stack with a warning when authorization is enabled — wrap
|
||
callables in a `StructuredTool`. Three author-visible rules follow: declare tools as a
|
||
plain instance attribute holding a `list` or `tuple` (a `tools` property cannot be
|
||
narrowed safely and fails the build, and any other container shape — a `set`,
|
||
generator, `dict_values`, … — would be iterated and bound by LangChain as-is, so it
|
||
likewise fails the build), and if you customize `__copy__`, it must return an
|
||
independent copy that preserves current instance state — returning `self` or
|
||
rebuilding from constructor arguments fails the build loudly rather than silently
|
||
restoring a denied tool. With `authorization.enabled: false` nothing is collected or
|
||
narrowed.
|
||
|
||
### 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.
|
||
|
||
## Frontend implementation invariants
|
||
|
||
`core/extensions/` loads authenticated deployment-installed ES modules from `/api/plugins`.
|
||
Inline modules use authenticated fetch plus a released Blob URL. Manifest assets use
|
||
native credentialed module scripts, preserving relative imports and resource URLs.
|
||
Both honor the backend base and prefixes; transport and cache semantics are documented in
|
||
`docs/full-stack-plugins.md`. Host UI labels belong in the typed locale dictionaries;
|
||
package purpose and usage come from `deerflow-extension.json` (see the
|
||
[authoring contract](../examples/README.md#describe-the-extension)). Installed copy
|
||
takes precedence over the generated bundled snapshot; omit empty prerequisites.
|
||
Conversation action factories, shapes and availability callbacks are guarded per plugin;
|
||
only validated value snapshots reach the toolbar/sidebar render paths.
|
||
`PluginNavigation` and the dynamic workspace extension route consume page declarations;
|
||
Capability Center defaults to the repository examples in `core/extensions/catalog.ts`,
|
||
merged by explicit namespace with runtime descriptors. Catalog-only entries are discovery
|
||
metadata, never module-loader inputs or proof of installation. Documented backend
|
||
extensions with successful registrations have runtime-only descriptors without browser
|
||
modules. Other catalog-only entries retain an unknown runtime status. Details show usage
|
||
steps and optional documentation links; launch links require actual enabled page
|
||
registrations through `pluginPages`. Keep the catalog aligned with `examples/deerflow-extension-*`.
|
||
Agent teams uses `community.agent-teams`; merge its installed descriptor into the
|
||
localized catalog entry without asserting runtime status for a catalog-only row.
|
||
Conversation action slots augment normal/custom-agent toolbars and sidebar menus without replacing native export or notification.
|
||
Plugin views use mount/dispose and abort signals; Shadow DOM is CSS isolation, not a sandbox.
|
||
Descriptors are user-keyed page snapshots, refreshed manually. Backend calls bind the plugin's
|
||
namespace, action allowlist and expected viewer identity. See `docs/full-stack-plugins.md`.
|
||
|
||
Plugin page `openConversation(threadId)` resolves authenticated thread metadata
|
||
with `pathOfThread`; do not let plugins hardcode default-agent routes. The page's
|
||
abort signal fences late navigation after unmount/account changes. Synchronous
|
||
conversation-action callbacks reject Promise returns while consuming rejections.
|
||
|
||
## 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.
|
||
|
||
### Composer mention providers
|
||
|
||
An optional `mentionProviders` array on a browser module contributes candidates to
|
||
both the native `@` menu and the mobile mention picker. No custom editor is needed:
|
||
|
||
```javascript
|
||
export default {
|
||
apiVersion: 1,
|
||
module: "example-team",
|
||
mentionProviders: [{
|
||
id: "members",
|
||
label: "Team members",
|
||
async search(query, context) {
|
||
return context.callBackend("find_members", { query });
|
||
},
|
||
}],
|
||
};
|
||
```
|
||
|
||
Declare `find_members` in the package's backend actions. Its response is an array
|
||
of `{ id, label, description? }`; the host supplies the namespace and provider ID.
|
||
The context includes `locale`, `settings`, `threadId`, an `AbortSignal`, and the
|
||
same viewer-bound `callBackend` service used by page surfaces. Observe the signal
|
||
when doing asynchronous work. A provider must return a promise; the host debounces
|
||
queries by 150 ms, truncates queries to 256 characters before calling `search`,
|
||
stops waiting after three seconds, and discards late responses after query,
|
||
thread, viewer, locale, or installed snapshot changes. Settled candidates remain
|
||
visible while a new query is in flight within the same viewer/thread/locale and
|
||
installed snapshot; changing any of those clears them immediately. A failed
|
||
provider does not remove other providers or built-in candidates. Retrying is
|
||
available in the picker.
|
||
Cancellation ends host waiting; trusted JavaScript cannot be forcibly terminated.
|
||
|
||
Each module may register eight providers, with unique lowercase slug IDs and
|
||
labels of at most 120 characters. The picker searches the first 16 providers in
|
||
installation order and considers at most 50 results per provider. Item IDs are
|
||
nonempty strings of at most 512 characters, labels at most 120, descriptions at
|
||
most 240. Duplicate item IDs within a provider are ignored. IDs are namespaced,
|
||
so two plugins can use the same local identifier.
|
||
|
||
Selections remain in the thread's existing draft as inline reference tokens.
|
||
Submitting renders each token as `@label` in the human message and attaches:
|
||
|
||
```json
|
||
{"extension_mentions":[{"namespace":"example.team","provider":"members","id":"alice","label":"Alice"}]}
|
||
```
|
||
|
||
This object is in the human message's `additional_kwargs`. Up to 16 distinct
|
||
plugin references may be submitted. Removing a token removes its metadata;
|
||
duplicate tokens share one metadata entry. Restored references retain their
|
||
original identities even if a package later becomes unavailable. **All metadata
|
||
is user input, not authorization**: plugins must revalidate the referenced object,
|
||
current installation, and viewer permissions before acting. The host does not
|
||
interpret a reference as delegation or start another Agent. Those behaviors
|
||
belong to the plugin's backend handlers or middleware.
|
||
|
||
### Immutable file review and run configuration evidence
|
||
|
||
`AgentRuns.configuration_evidence(thread_id, run_id)` returns host-owned proof
|
||
recorded from actual Agent assembly before graph execution and bound to the run.
|
||
It includes actual model, safe numeric parameters and keyed versions of instructions,
|
||
model configuration, tools, Skills and policies. No raw secrets are exposed.
|
||
Missing evidence fails closed; the declarative revision remains a separate preflight
|
||
guard. Evidence describes assembly-time configuration, not external service state.
|
||
|
||
`AgentRuns.import_artifact(thread_id, filename, content)` (extension API 0.2.11)
|
||
imports at most 2 MiB of retained bytes into an idle thread's uploads directory.
|
||
It requires a plugin-scoped delegated handle and rechecks current thread-write
|
||
authority. The host selects a content-addressed filename; identical calls reuse
|
||
the copy, while a replaced or edited destination fails without overwriting it.
|
||
The result contains `filename`, virtual `path`, `sha256` and `size`. Non-mounted
|
||
sandboxes synchronize those exact bytes through an authorized sandbox lease.
|
||
Remote publication uses descriptor-confined staging and conditional links, never
|
||
`update_file`: changed files, hardlinks and symlinks are rejected without writes
|
||
to the destination. Remote images require POSIX/Python 3, and sandbox adapters
|
||
declare their actual `artifact_root` (`None` defaults to unsupported).
|
||
Cancellation drains publication/synchronization before releasing the thread's
|
||
mutation reservation. Descriptor confinement requires POSIX. This primitive
|
||
stores no team policy, artifact versions or acceptance state; those belong to
|
||
the extension.
|
||
|
||
`ToolContext.run_id` is stamped from the worker's runtime, outside tool arguments.
|
||
`AgentRuns.validate_artifact_source(thread_id, run_id, idempotency_key)` verifies
|
||
the plugin-scoped execution key and running status, and checks capture support.
|
||
This permits registration before a successful admission acknowledgement has
|
||
reached the extension without attributing an unrelated turn to a queued job.
|
||
|
||
`AgentRuns.start(..., artifact_imports=[receipt])` accepts up to four exact import
|
||
receipts. After durable admission, the worker verifies local and remote hashes
|
||
before constructing the Agent. Failures end with `stop_reason=artifact_conflict`.
|
||
Existing idempotency keys are reconciled before this check, preserving retries
|
||
after a Run edited its own copies. Keep the receipts with frozen input. The
|
||
worker retains the verified remote sandbox lease through execution and drains
|
||
its release across cancellation.
|
||
|
||
`AgentRuns.read_artifact(thread_id, path)` authorizes a bounded (2 MiB) regular
|
||
output-file snapshot, rejects symlink traversal, and reserves the thread against
|
||
concurrent runs/edits while reading off-loop. Its optional `expected_checkpoint_id`
|
||
is compared under that same reservation (412 on mismatch, 409 while busy).
|
||
Non-mounted providers read live remote output through an authorized lease.
|
||
The workflow extension stores those bytes append-only with SHA-256, approves exact versions, and serves preview and
|
||
approved consumer reads from the same snapshot (16 KiB chunks). File versions are
|
||
owner/workflow scoped; consumers cannot use an unapproved version. See the workflow
|
||
extension README for limits and `captureArtifact` / `readArtifact` examples.
|