diff --git a/AGENTS.md b/AGENTS.md index 047dd43c8..b13cff909 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -66,7 +66,7 @@ deer-flow/ │ # Managed integration skill packs are global at .deer-flow/integrations/skills/{provider}/ │ # Integration credentials and enabled state remain per-user ├── contracts/ # Cross-component JSON contracts (e.g. subagent status, skill review) -├── examples/deerflow-extension-example/ # Standalone package demonstrating five extension contribution kinds +├── examples/ # Extension examples: deerflow-extension-{example,bookmarks} ├── scripts/ # Root orchestration scripts invoked by the Makefile (check, configure, doctor, support_bundle, serve, nginx, docker, deploy, setup_wizard) ├── tests/ # Root-level tests (currently tests/skills/ — public skill tests) └── docs/ # Cross-cutting docs, plans, and design notes @@ -75,7 +75,8 @@ deer-flow/ Third-party extensions are loaded from a top-level `plugins:` list in `config.yaml` (operator-controlled on purpose — that list causes code to be imported, so it is deliberately kept out of the API-writable `extensions_config.json`). Packaged extensions can contribute -middleware, lifecycle observers, Gateway services, and FastAPI HTTP routers. Manage them with `deerflow extensions install/upgrade/list/enable/disable/remove` or the root +middleware, lifecycle observers, Gateway services, FastAPI HTTP routers, and experimental +full-stack plugins. Manage them with `deerflow extensions install/upgrade/list/enable/disable/remove` or the root `make extension-*` wrappers. Every mutation requires a Gateway restart, and both build hooks and extension code execute with Gateway privileges, so only trusted operator sources belong in this path. The manager transaction, accepted source forms, lock discipline, and diff --git a/CHANGELOG.md b/CHANGELOG.md index 35e393663..911ca3b2e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2988,6 +2988,13 @@ This release closes that milestone with **765 merged pull requests**. and a reference of every public name with the contract's version history. Also correct stale descriptions of the contribution kinds and of run evidence metadata redaction in `AGENTS.md`. +- **docs:** Bring the extension developer manual up to the + `deerflow-extension-api` 0.2.3 contract: a Full-Stack Plugins chapter + covering `registry.plugin()`, browser modules and packaged assets, backend + actions, model tools and settings; the request-scoped run evidence reader + with a per-user route example; and plugin troubleshooting and operations + notes. Also correct the plugin `mount` return value in + `docs/full-stack-plugins.md`. ### Internal diff --git a/CHANGELOG_zh.md b/CHANGELOG_zh.md index 8df1f120d..15d69aaf7 100644 --- a/CHANGELOG_zh.md +++ b/CHANGELOG_zh.md @@ -2232,6 +2232,10 @@ 放置位置、生命周期与观察者、服务与路由、运行证据读取器、扩展运维、按错误信息排查, 以及列出全部公开名称和契约版本历史的参考章节。同时修正 `AGENTS.md` 中对贡献类型 和运行证据元数据脱敏的过时描述。 +- **文档:** 将扩展开发手册更新到 `deerflow-extension-api` 0.2.3 契约:新增全栈 + 插件章节,涵盖 `registry.plugin()`、浏览器模块与打包资源、后端动作、模型工具和 + 设置;新增请求级运行证据读取器及按用户的路由示例;补充插件的排查与运维说明。同时 + 修正 `docs/full-stack-plugins.md` 中插件 `mount` 返回值的描述。 ### 内部改进 diff --git a/backend/packages/harness/deerflow/extensions/AGENTS.md b/backend/packages/harness/deerflow/extensions/AGENTS.md index db3fd3d9c..5df2f83b0 100644 --- a/backend/packages/harness/deerflow/extensions/AGENTS.md +++ b/backend/packages/harness/deerflow/extensions/AGENTS.md @@ -141,9 +141,10 @@ entry, the manager owns the controlled locked sync. The public package is `packages/extension-api/` and must never import `deerflow` or carry framework dependencies. Extensions declare any FastAPI, LangChain, or LangGraph imports -themselves. Its registry contract exposes seven contribution kinds: middleware +themselves. Its registry contract exposes eight contribution kinds: middleware contributors, task-lifecycle contributors, system-model-call observers, agent-assembly -observers, context-compaction observers, Gateway-lifetime services, and eager routers. Middleware contributions declare lead/subagent scope, stable +observers, context-compaction observers, Gateway-lifetime services, eager routers, and +experimental full-stack plugins (`registry.plugin()`, see `docs/full-stack-plugins.md`). Middleware contributions declare lead/subagent scope, stable order, and a semantic placement (`MODEL_LOGICAL`, `MODEL_PHYSICAL`, `TOOL_VISIBLE`, `TOOL_RAW`, or `STANDARD`) rather than a fragile list index. `extensions/stack.py` is the single final composition point; do not inject inside diff --git a/docs/full-stack-plugins.md b/docs/full-stack-plugins.md index 546ec5bdf..cb60358f9 100644 --- a/docs/full-stack-plugins.md +++ b/docs/full-stack-plugins.md @@ -79,7 +79,7 @@ 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` returns a synchronous `dispose()` callback. The context includes locale, +`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 diff --git a/frontend/src/content/en/harness/extensions/_meta.ts b/frontend/src/content/en/harness/extensions/_meta.ts index 312cede71..3a2189030 100644 --- a/frontend/src/content/en/harness/extensions/_meta.ts +++ b/frontend/src/content/en/harness/extensions/_meta.ts @@ -19,6 +19,9 @@ const meta: MetaRecord = { "run-evidence": { title: "Run Evidence", }, + plugins: { + title: "Full-Stack Plugins", + }, operations: { title: "Operating Extensions", }, diff --git a/frontend/src/content/en/harness/extensions/index.mdx b/frontend/src/content/en/harness/extensions/index.mdx index c57dd63e1..cd6392789 100644 --- a/frontend/src/content/en/harness/extensions/index.mdx +++ b/frontend/src/content/en/harness/extensions/index.mdx @@ -51,6 +51,7 @@ The two middleware paths are easy to confuse. `extensions.middlewares` inserts a | `registry.context_compaction_observer(obs)` | A `CompactionEvent` each time summarization removes messages from context. See [Lifecycle and Observers](/docs/harness/extensions/observers) | | `registry.service(service)` | An object started after the Gateway's persistence layer is ready and stopped at shutdown. May read runs through the [Run Evidence](/docs/harness/extensions/run-evidence) reader. See [Services and Routes](/docs/harness/extensions/services-and-routes) | | `registry.routers(routers)` | FastAPI routers mounted after every host route, behind Gateway authentication. See [Services and Routes](/docs/harness/extensions/services-and-routes) | +| `registry.plugin(contribution)` | Experimental. Browser modules, authenticated backend actions, and model tools. Returns `False` on a host without plugin support. See [Full-Stack Plugins](/docs/harness/extensions/plugins) | A single extension may register any combination. The [bundled example](https://github.com/bytedance/deer-flow/tree/main/examples/deerflow-extension-example) registers a middleware, a task-lifecycle contributor, a system-model observer, a service, and a router. @@ -102,7 +103,7 @@ The extension manager asks for confirmation before installing, rejects source UR ## Versioning -The contract package is versioned separately from DeerFlow, and a host exposes its version as `deerflow_extension_api.API_VERSION`. This manual covers `deerflow-extension-api` **0.2.1**. +The contract package is versioned separately from DeerFlow, and a host exposes its version as `deerflow_extension_api.API_VERSION`. This manual covers `deerflow-extension-api` **0.2.3**. - Before 1.0, a minor release may break extensions and a patch release only adds. From 1.0 on, breaking changes bump the major. - Every Protocol method has a default implementation and every optional dataclass field has a default, so additive releases do not break already-released extensions. @@ -113,7 +114,7 @@ Declare the matching range in your package metadata as well, for example `deerfl ## Terminology - **Host**: the DeerFlow Gateway process that loads extensions. -- **Contribution**: one object registered through the registry: a contributor, observer, service, or router. +- **Contribution**: one object registered through the registry: a contributor, observer, service, router, or plugin. - **Contributor**: an object the host calls back to obtain contributions, such as a `MiddlewareContributor` that returns middleware for each agent it builds. - **Scope**: the lifetime a piece of state belongs to. The **app scope** lives as long as the Gateway; a **task scope** lives for one lead run or one subagent execution. - **`ExtensionData`**: the typed store attached to a scope, keyed by Python type so two extensions cannot collide. diff --git a/frontend/src/content/en/harness/extensions/operations.mdx b/frontend/src/content/en/harness/extensions/operations.mdx index 609475846..82f505f35 100644 --- a/frontend/src/content/en/harness/extensions/operations.mdx +++ b/frontend/src/content/en/harness/extensions/operations.mdx @@ -156,6 +156,18 @@ These checks catch packaging accidents. They are not a malware scan. Because the snapshot is a copy, edits to your working directory reach DeerFlow only through `make extension-upgrade SOURCE=` followed by a restart. +### Plugins with browser assets + +A plugin that declares `BrowserAssets` ships a `ui_manifest.json` and the static files it lists inside its Python package. The [bookmarks example](https://github.com/bytedance/deer-flow/tree/main/examples/deerflow-extension-bookmarks) keeps them under `deerflow_extension_bookmarks/static/dist/`. For operators this means: + +- **The files must be in what gets installed.** A local directory is snapshotted as-is, so build any JavaScript bundle before `install` or `upgrade`: the manager runs the Python build backend, not a JavaScript build. For an index or Git source, the files must be inside the built wheel. +- **The snapshot rules still apply.** A local directory containing symlinks or secret-looking files is rejected by the manager before the Gateway ever reads the manifest. +- **The Gateway reads the files once**, when `install()` registers the plugin at startup, and serves them from memory under a content-derived revision. Changing an asset needs `upgrade` and a restart, like any code change. Browsers may keep cached code from the previous revision until the page is reloaded. +- **Listed files are readable by any signed-in user**, including source maps, even while the plugin is disabled. Do not ship private sources or secrets in the manifest. +- Deploy the Gateway and the frontend from the same release. An older frontend does not understand the `assets-v1` transport. + +Manifest errors are listed in [Troubleshooting](/docs/harness/extensions/troubleshooting). + ## Upgrading `upgrade` replaces the source of an extension that is already installed and keeps its record: `config`, `required`, and `enabled` stay as they are. diff --git a/frontend/src/content/en/harness/extensions/plugins.mdx b/frontend/src/content/en/harness/extensions/plugins.mdx new file mode 100644 index 000000000..0cb8f74bf --- /dev/null +++ b/frontend/src/content/en/harness/extensions/plugins.mdx @@ -0,0 +1,557 @@ +--- +title: Full-Stack Plugins +description: Experimental. One PluginContribution can ship browser code with workspace pages and conversation actions, authenticated backend actions, and model tools. Covers every field and validation rule, the two browser transports (inline module and manifest-listed static assets), the browser API, the HTTP endpoints and caching, the security model, and a walkthrough of the bookmarks example. +--- + +import { Callout } from "nextra/components"; + +# Full-Stack Plugins + + + **Experimental.** The plugin contract is `api_version` 1. The inline + `BrowserModule` transport arrived in `deerflow-extension-api` 0.2.2, and the + manifest-based `BrowserAssets` transport in **0.2.3**. The page and action + interfaces are shared by both and may still change before 1.0. + + +The other contribution kinds change what the host does behind the scenes. A **plugin** adds a feature users can see and use: a page in the workspace, an entry in the conversation menu, backend actions that page can call, and tools the model can call. All of it comes from one Python package installed through the ordinary extension workflow, so there is no DeerFlow frontend rebuild and no host code specific to the plugin. + +## What users see + +| Surface | Where it appears | +| -------------------------- | ---------------------------------------------------------------------------------------------------------- | +| **Extensions** tab | Capability Center (`/workspace/capabilities?tab=extensions`). A read-only card per installed plugin: title, description, and "Enabled · Managed by your administrator" or "Disabled · ..." | +| Workspace page | `/workspace/extensions/{namespace}/{surface id}`, with an optional sidebar entry. The host builds the URL; a plugin cannot claim arbitrary paths | +| Conversation action | A menu group in the chat toolbar and in each sidebar conversation's menu | +| Model tool | Part of the agent's ordinary tool set, subject to the same group filtering and policies as any other tool | + +Nothing in the UI installs, enables, or configures a plugin. The catalog is read-only for everyone, administrators included. Browsers keep the plugin set they discovered until the page is reloaded manually. + +## The contribution + +`install()` builds one `PluginContribution` and passes it to `registry.plugin()`: + +| Field | Type | Default | Meaning | +| ------------- | ------------------------ | -------------- | ------------------------------------------------------------------------------------------ | +| `namespace` | `str` | required | Unique identity, matching `[a-z][a-z0-9_.-]{0,95}`, such as `community.bookmarks`. Used in URLs and tool names | +| `title` | `str` | required | Card and page title. Must be non-empty | +| `description` | `str` | `""` | Card text | +| `enabled` | `bool` | **`False`** | The plugin's on switch. Actions and tools refuse to run while it is `False`. See [Enabling](#enabling-a-plugin) | +| `fields` | `tuple[SettingsField]` | `()` | Non-secret deployment settings. See [Settings](#settings) | +| `frontend` | `BrowserModule \| BrowserAssets \| None` | `None` | The browser code. See [Browser code](#browser-code) | +| `backend` | `tuple[BackendAction]` | `()` | Actions the browser code can call | +| `tools` | `tuple[ModelTool]` | `()` | Tools the model can call | +| `api_version` | `int` | `1` | Must be `1` | + +A contribution must supply at least one of `frontend`, `backend`, or `tools`. The registry validates the whole contribution before storing any of it, and raises `ValueError` on the first violation: + +| Rule | Error message | +| --------------------------------------------------------------------------------- | ------------------------------------------------------------- | +| `api_version` is `1` | `Unsupported plugin contract` | +| At least one browser module, backend action, or tool | `A plugin must contribute a browser module, backend action or tool` | +| Tool names match `[a-z][a-z0-9_]{0,63}`, are unique, have a description and an `async` handler | `Model tools require unique names, descriptions and async handlers` | +| Tool schemas are valid, inline, object-typed JSON Schema (see [Model tools](#model-tools)) | `Invalid plugin object schema` | +| Action names match `[a-z][a-z0-9_-]{0,63}`, are unique, and have an `async` handler | `Backend actions require unique names and async handlers` | +| `frontend` is a `BrowserModule`, a `BrowserAssets`, or `None` | `Unsupported browser transport` | +| Inline code is non-empty and at most 512 KiB of UTF-8 | `Browser code must be nonempty and at most 512 KiB` | +| An assets manifest and every listed file pass the [asset rules](#manifest-rules) | `Unsupported browser asset manifest; ...`, `Browser assets must not contain symlinks`, ... | +| Namespace, settings fields, and module identifier are valid (see [Settings](#settings)) | `Invalid settings contribution or namespace`, `Invalid or duplicate settings field`, ... | +| Namespace and browser module identifier are unique across all loaded plugins | `Duplicate plugin namespace`, `Duplicate browser module` | + +Any exception escaping `install()`, including these `ValueError`s and the `FileNotFoundError` raised for a manifest-listed file that does not exist, fails that extension's load as a whole: everything it registered is rolled back and the Gateway logs the reason (see [Runtime](/docs/harness/extensions/runtime)). + +### Check the return value + +`registry.plugin()` returns `True` when the host accepted the contribution. The contract's default implementation returns `False`, which is what an older host without plugin support does. A plugin that is meaningless without its UI should fail loudly instead of loading half-installed: + +```python +if registry.plugin(contribution) is not True: + raise RuntimeError("this extension requires a host with full-stack plugin support") +``` + +## Enabling a plugin + +A plugin has **two** switches, and both must be on: + +- The `plugins:` record's `enabled` controls whether the Gateway imports the package at all. The extension manager's `enable` and `disable` commands flip it. +- `PluginContribution.enabled` controls the contribution. It defaults to `False`, and there is no runtime override store, so a plugin that never sets it stays disabled: `GET /api/plugins` reports `enabled: false`, the browser does not load its module, actions return `403 Plugin disabled by administrator.`, and its tools are left out of every agent. + +The convention is to read the second switch from the package's private config, so operators control it in `config.yaml`: + +```python +enabled=config.get("enabled", False) is True, +``` + +```yaml +plugins: + - name: notes + use: acme_notes:install + enabled: true # load the package + config: + enabled: true # turn the contribution on +``` + +Both are read only at startup, so a change to either needs a Gateway restart. + +## Settings + +`SettingsField` declares a non-secret deployment value that actions and tools receive, and that the browser can optionally see: + +| Field | Default | Meaning | +| --------------------- | -------- | ---------------------------------------------------------- | +| `key` | required | Matches `[a-z][a-z0-9_]{0,63}`. `enabled` is reserved | +| `title` | required | Display label | +| `kind` | required | `"boolean"`, `"integer"`, or `"string"` | +| `default` | required | The value. Must match `kind` and the bounds below | +| `description` | `""` | | +| `minimum` / `maximum` | `None` | Integer bounds | +| `max_length` | `256` | String bound, 1 to 4096 | + +The host adds the boolean `enabled` field itself. With it, a plugin can have at most 32 fields, which leaves 31 of your own. Values come only from the package: `default` is the value every action, tool, and browser receives. There is no settings API and no online editing, so derive defaults from `config` in `install()` when operators need to change them. + + + Settings are not a secret store. Never put credentials in a `SettingsField`. + Read secrets in `install()` from the environment or the private `config`, and + keep them in your own Python objects. + + +The browser sees only `enabled` plus the keys listed in the browser declaration's `public_fields` (`BrowserModule` and `BrowserAssets` both have it). Listing a key that is not a declared field fails validation. + +## Backend actions + +```python +BackendAction(name: str, handler: async (payload, context) -> Any) +``` + +The browser calls an action through the host, and the handler receives: + +- `payload`: a read-only mapping parsed from the JSON request body. The host guarantees only that it is a JSON **object** of at most 256 KiB. Validate every field yourself. +- `context`: an `ActionContext` with `principal`, the authenticated caller as an `ExtensionPrincipal` (`user_id`, `is_admin`, `is_internal`, `roles`), and `settings`, the read-only settings mapping. + +The return value must be JSON-serializable. The host maps outcomes to HTTP statuses and does not expose your exception text: + +| Outcome | Status | Body `detail` | +| ----------------------------------------- | ------ | ---------------------------------------------- | +| Handler returns | `200` | Your return value | +| Handler raises `ValueError` | `422` | `Invalid plugin action input.` | +| Handler raises anything else | `502` | `Plugin action failed.` (logged with the exception type) | +| Handler runs longer than 30 seconds | `504` | `Plugin action timed out.` | +| Body is not a JSON object | `422` | `Plugin action requires a JSON object.` | +| Body exceeds 256 KiB | `413` | `Plugin action input exceeds 256 KiB.` | +| Plugin disabled | `403` | `Plugin disabled by administrator.` | +| Unknown namespace or action | `404` | `Plugin is not installed.` / `Plugin action is not installed.` | +| Viewer header does not match the session | `409` | `Account changed; reload this plugin view.` | +| No authenticated principal | `401` | `Authentication required.` | + +A handler cannot choose its own status code, because even a raised `HTTPException` becomes `502`. Raise `ValueError` for bad input and return error details in the body when the browser needs them. A timeout cancels the handler, but it does not roll back external effects or work already running in a worker thread. + +**Authorization is yours.** The host authenticates the caller. It does not know which records belong to whom. Scope every read and write by `context.principal.user_id`, as the bookmarks example does with an owner predicate on every query. + +## Model tools + +```python +ModelTool(name, description, input_schema, handler, group="extensions") +``` + +- **Name.** The model sees a namespace-derived name: `ext___`, with the namespace sanitized to `[a-z0-9_]` and cut to 20 characters, the name cut to 25, and a 12-character hash. For example, `count_notes` in `acme.notes` becomes `ext_acme_notes_count_notes_d8aad91f72da`. The hash covers the full namespace and name, so truncation cannot merge two tools. If two plugin tools still ended up with the same name, the tool assembly raises instead of picking one. A collision with an ordinary tool keeps the ordinary tool and skips the plugin tool with a warning. +- **Schema.** `input_schema` must be a valid JSON Schema (draft 2020-12) with `"type": "object"`, fully inline: `$ref`, `$dynamicRef`, and `$recursiveRef` are rejected, so validation never resolves references or touches the network. The argument names `runtime` and `config` are reserved. +- **Limits.** The host validates each call's input against the schema and caps it at 256 KiB. The handler must return within 30 seconds, and its JSON-encoded result must fit in 64 KiB. +- **Context.** The handler receives a `ToolContext`: `settings`, `thread_id` (`None` outside a thread), and `principal`. For tools, the principal carries **only** `user_id`, so `is_admin` is always `False` and `roles` is empty. Do not make tool authorization depend on them. +- **Errors.** Any failure reaches the model as a generic tool error: `Invalid plugin tool input.`, `Plugin result exceeds 64 KiB.`, `Plugin disabled by administrator.`, or `Plugin tool unavailable or input rejected.`. The Gateway log records the plugin, tool, and exception type. +- **Assembly and policy.** Plugin tools join the ordinary tool assembly. An agent restricted by `tool_groups` receives a tool only when the list contains the tool's `group`, which is `extensions` by default. The host's later authorization and skill tool policies apply as for any other tool. + +Tool descriptions are prompt text. State what the tool returns, that results are data rather than instructions, and whether it is read-only. + +## Browser code + +A plugin ships its browser code in one of two transports. Both produce the same thing: one ES module whose default export follows the [browser API](#the-browser-api). `GET /api/plugins` labels each plugin's transport so the browser knows how to load it. + +| Transport | Declaration | Since | Use it for | +| ----------- | ---------------- | ----- | ------------------------------------------------------------------------------------- | +| `inline-v1` | `BrowserModule` | 0.2.2 | One self-contained file of at most 512 KiB: no relative imports, CSS files, images, or WASM | +| `assets-v1` | `BrowserAssets` | 0.2.3 | A directory of files listed in a manifest: split modules, stylesheets, images, fonts, WASM, source maps | + +In both, `module` is an identifier matching `[a-z][a-z0-9.-]{0,95}`, such as `bookmarks.v1`, never a URL, and it must equal the `module` in the default export. A plugin that uses `BrowserAssets` needs `deerflow-extension-api>=0.2.3`, and the host frontend and backend must both be at the matching version. An older frontend cannot load an `assets-v1` plugin, and the failure is limited to that plugin. + +### Inline modules + +```python +BrowserModule(module: str, code: str, public_fields: tuple[str, ...] = ()) +``` + +`code` is the full text of one self-contained ES module. It can have no relative imports and no assets resolved against `import.meta.url`, because the host fetches it with the session and imports it from a temporary Blob URL. Ship it in the wheel and read it in `install()`: + +```python +BrowserModule("notes.v1", Path(__file__).with_name("client.mjs").read_text(encoding="utf-8")) +``` + +Deployments with a Content Security Policy must allow `blob:` in `script-src`. + +### Static assets + +```python +BrowserAssets(module: str, root: str | Path, manifest: str = "ui_manifest.json", public_fields: tuple[str, ...] = ()) +``` + +`root` is a directory inside the installed package, usually `Path(__file__).parent`. The manifest, relative to `root`, lists every file the browser may load: + +```json filename="ui_manifest.json" +{ + "schema_version": 1, + "entry": "static/dist/index.mjs", + "files": [ + "static/dist/index.mjs", + "static/dist/chunks/page.mjs", + "static/dist/notes.css" + ] +} +``` + +The entry module can import its siblings with relative paths and locate resources with `new URL(..., import.meta.url)`: + +```js filename="static/dist/index.mjs" +import { mountNotes } from "./chunks/page.mjs"; + +export default { + apiVersion: 1, + module: "notes.v1", + surfaces: [ + { + id: "notes", + slot: "page", + title: "Notes", + navigation: { label: "My notes", labelZh: "我的笔记" }, + mount: mountNotes, + }, + ], +}; +``` + +```js filename="static/dist/chunks/page.mjs" +export function mountNotes(root, context) { + const style = document.createElement("link"); + style.rel = "stylesheet"; + style.crossOrigin = "use-credentials"; + style.href = new URL("../notes.css", import.meta.url).href; + const list = document.createElement("ul"); + root.append(style, list); + context + .callBackend("list", {}) + .then(({ notes }) => { + for (const note of notes) { + const item = document.createElement("li"); + item.textContent = note; + list.append(item); + } + }) + .catch(() => {}); + return { dispose: () => root.replaceChildren() }; +} +``` + +```python +frontend=BrowserAssets("notes.v1", Path(__file__).parent), +``` + +Include the manifest and every listed file in the wheel. A hatchling wheel that packages the Python package directory picks them up without extra configuration. Bundle third-party dependencies: bare npm imports are not resolved and there is no shared host React instance. Configure your bundler to emit relative URLs rather than absolute `/assets/...` paths. + +#### Manifest rules + +The registry reads, validates, and snapshots every listed file into memory during `install()`. Any violation fails the extension's load: + +| Rule | Error message | +| ------------------------------------------------------------------------------------------------ | --------------------------------------------------------------- | +| The manifest is a JSON object with exactly `schema_version` (the integer `1`), `entry`, and `files` | `Unsupported browser asset manifest; expected schema_version 1` | +| No duplicate keys | `Duplicate browser manifest key` | +| `files` lists 1 to 256 unique, valid paths | `Browser manifest must list unique asset paths` | +| `entry` is one of `files` and ends in `.js` or `.mjs` | `Browser manifest entry must be a listed JavaScript module` | +| Every file has a supported type (below) | `Unsupported browser asset file type` | +| `root` is not a symlink, and no component of any listed path is a symlink | `Browser asset root must not be a symlink`, `Browser assets must not contain symlinks` | +| Every listed file is a regular file inside `root` | `Browser asset must be a regular file inside its package` (a missing file raises `FileNotFoundError`) | +| The manifest is at most 64 KiB, each file at most 4 MiB, and all files together at most 16 MiB | `Browser asset size limit exceeded` | + +A valid path is at most 512 characters of `/`-separated segments, where each segment uses ASCII letters, digits, `_`, `-`, and `.` and starts with a letter, digit, `_`, or `-`. This rules out dotfiles, `.` and `..` segments, empty segments, backslashes, percent encoding, and query strings. + +Supported types, served with the MIME type in parentheses: `.js` and `.mjs` (`text/javascript`), `.css`, `.json` and `.map` (`application/json`), `.wasm`, `.png`, `.jpg`/`.jpeg`, `.gif`, `.webp`, `.svg`, `.ico`, `.woff`, `.woff2`, `.ttf`, and `.otf`. HTML and server-side files are never served. + +#### Revisions and caching + +The host computes a SHA-256 **revision** over the manifest and the content hash of every file. Every URL contains it: `/api/plugins/{namespace}/assets/{revision}/{path}`. Changing any file changes the revision of all of them. Requests are answered from the startup snapshot, never from the filesystem, so editing the installed files has no effect until a restart. + +Asset responses carry `Cache-Control: private, max-age=31536000, immutable` and `Vary: Cookie, Authorization`. An unknown path or revision returns `404` with `private, no-store`. The host keeps no historical snapshots, and there is no public CDN contract. After an upgrade, a page still holding the old revision gets `404`s for anything it has not cached and must be reloaded. Conversely, a browser can keep using code it already cached after a plugin is removed, so removal is not instant cache revocation. + +#### How the browser loads assets + +The host inserts a native module script with `crossorigin="use-credentials"` pointing at the entry URL, then reads the exports from the document's module map. Relative static and dynamic imports therefore resolve inside the same revision and carry the session cookie. URLs honor `NEXT_PUBLIC_BACKEND_BASE_URL`, including path prefixes. + +- **Timeout.** The host stops waiting after 30 seconds and marks that plugin unavailable. This is a deadline for waiting, not cancellation: the module can still finish evaluating later. Keep top-level code free of side effects and start UI work in `mount`. +- **Module state is shared.** A module instance is shared by the whole document. Keep per-viewer data in per-mount state and clear it in `dispose()`. Never cache principals or private results at module level across account changes. +- **Other resources are your job.** Set `crossOrigin = "use-credentials"` on stylesheets and images you create, as `page.mjs` does, and use `fetch(url, { credentials: "include" })` for JSON, WASM, or binary data. Font and background URLs inside CSS do not reliably carry cross-origin cookies. For those, prefer a same-origin deployment, or fetch with credentials and build a `FontFace` or Blob URL that you release on dispose. +- **CSP and CORS.** The backend origin must be allowed in the relevant `script-src`, `style-src`, `img-src`, `font-src`, and `connect-src` directives. Split-origin deployments need exact-origin credentialed CORS and working session cookies. + +### The browser API + +The module's default export must match the host's browser API v1: + +```js +export default { + apiVersion: 1, // required + module: "notes.v1", // required, equal to the declaration's module + icon: "file-text", // optional + surfaces: [/* page surfaces, at most 16 */], + conversationActions(t, locale) {/* returns one action group */}, +}; +``` + +The host rejects the module, and shows "Page module unavailable" on its card, if `apiVersion` or `module` does not match or any surface or navigation entry is malformed. + +### Page surfaces + +| Field | Rule | +| ------------ | --------------------------------------------------------------------- | +| `id` | `[a-z][a-z0-9-]{0,63}`, unique within the module | +| `slot` | `"page"` (the only slot today) | +| `title` | Non-empty string | +| `navigation` | Optional `{ label, labelZh?, icon? }` adds a sidebar entry. Labels are at most 120 characters | +| `mount` | `(root, context) => { dispose }`, called synchronously | + +The host mounts the page into a Shadow DOM root and passes a context: + +| Context member | Meaning | +| ------------------------------ | ------------------------------------------------------------------------------- | +| `namespace`, `locale` | The plugin namespace and the UI locale, such as `en-US` or `zh-CN` | +| `settings` | `enabled` plus `public_fields` | +| `signal` | Aborted on unmount or account change. Pass it to listeners and fetches | +| `callBackend(action, payload)` | POSTs to one of this plugin's declared actions and resolves to the JSON response. It rejects on non-2xx responses and on actions not declared by the plugin | +| `openConversation(threadId)` | Optional. Navigates to a conversation after resolving it through the authenticated API, and rejects without navigating if it is missing or inaccessible | + +`mount` must return an object with a synchronous `dispose()`. The host calls it on unmount after aborting `signal`, and fences late `callBackend` results. + +### Conversation actions + +`conversationActions(t, locale)` must be synchronous and return `{ label, icon, actions }`. Each action has `id`, `label`, `icon`, a synchronous `available(settings)` that returns a boolean, and `execute(context, services)`: + +- `context.thread` is the conversation, and `context.messages` holds its messages when the host already has them. +- `services.callBackend` works as above. `services.latestVisibleAnswer(context)` returns the last visible assistant message as `{ id, text }` or `null`. `services.conversationText(context)` returns the visible transcript. Both reuse the host's export sanitizer, so hidden messages, tool output, and reasoning are excluded. `services.showMessage(text)` shows a toast. + +Each plugin's actions are evaluated inside their own error boundary. A malformed or throwing group is omitted, logged to the browser console, and leaves other plugins' actions and the conversation page intact. + +### Icons + +The host maps icon names to a fixed set: `bell`, `bookmark`, `download`, `file-json`, and `file-text`. Any other name renders as a generic puzzle icon. + +## HTTP endpoints + +The host serves plugins through four authenticated Gateway routes, behind the normal session and CSRF policies. Each returns `401` without an authenticated principal: + +| Route | Purpose | +| ------------------------------------------------------- | ------------------------------------------------------------------------------------------- | +| `GET /api/plugins` | Every loaded plugin: `namespace`, `title`, `description`, `viewer_id`, `module`, `entry`, `transport` (`inline-v1`, `assets-v1`, or `null`), public `settings`, and `backend_actions` names | +| `GET /api/plugins/modules/{module}/{sha256}.mjs` | An inline module, addressed by its content hash. `text/javascript`, `nosniff`, `private, no-store` | +| `GET /api/plugins/{namespace}/assets/{revision}/{path}` | One manifest-listed file. Its declared MIME type, `nosniff`, `Content-Security-Policy: sandbox`, and the immutable private caching described in [Revisions and caching](#revisions-and-caching) | +| `POST /api/plugins/{namespace}/actions/{name}` | Invokes one backend action (see [Backend actions](#backend-actions)) | + +A stale inline hash or asset revision returns `404` and requires a page reload. Both download routes serve a plugin's code even while the plugin is disabled. + +The browser sends `X-Deerflow-Plugin-Viewer` with the `viewer_id` it discovered, so an action from a view opened under a different account is rejected with `409`. + +## Security model + + + Browser modules and Python handlers are trusted, operator-installed code. + Browser code runs **in the main page** with the signed-in user's same-origin + capabilities. Shadow DOM scopes CSS, not privileges. Python handlers run in the + Gateway process with Gateway privileges. + + +What the host does guarantee: + +- It never loads code from a browser-supplied path, import string, or remote URL. It serves only the installed inline module, verified by content hash, or files listed in the manifest, read from the startup snapshot. Directory listings and unlisted files are never served. +- Asset responses carry `Content-Security-Policy: sandbox`, so opening an asset directly, such as an SVG with a script inside, cannot run scripts with the Gateway's origin. This restricts assets opened as documents. It does not sandbox the plugin JavaScript the host loads into the page. +- Every endpoint requires an authenticated principal, and the principal comes from the session rather than the request body. +- `callBackend` is bound to the plugin's own namespace and its declared action names. +- Only `enabled` and explicitly public fields reach the browser. + +Any authenticated user can download every listed asset, including source maps, even while the plugin is disabled. Never list secrets or private sources in the manifest. + +What remains the plugin's job: authorizing access to its own data by `principal.user_id`, validating every payload, escaping user and model text when rendering it (`textContent`, never `innerHTML`), and treating tool results as untrusted data. + +## Walkthrough: the bookmarks example + +[`examples/deerflow-extension-bookmarks`](https://github.com/bytedance/deer-flow/tree/main/examples/deerflow-extension-bookmarks) (version 0.2.0, which requires `deerflow-extension-api>=0.2.3`) is a complete plugin: users save the last visible answer from a conversation menu, then search, rename, open, or delete their bookmarks on a **My bookmarks** page. The Agent can search them too. It exercises every piece of this chapter: + +1. **Config and state.** `install()` requires `config.enabled` (a boolean) and an absolute `config.storage_path`, and opens a SQLite store there. Mount a persistent directory for it in containers. +2. **The contribution.** Namespace `community.bookmarks`, `enabled=config["enabled"]`, `BrowserAssets("bookmarks.v1", Path(__file__).parent)`, five `BackendAction`s (`save`, `search`, `get`, `rename`, `delete`), and one read-only `ModelTool`, `search_bookmarks`, which the model sees as `ext_community_bookmarks_search_bookmarks_`. +3. **The return check.** It raises `RuntimeError` if `registry.plugin(...)` is not `True`. +4. **Ownership.** Every SQL statement carries `owner = context.principal.user_id`, and each action accepts an exact set of payload keys. The model tool never accepts a user ID, so a user can only ever search their own bookmarks. +5. **The resources.** `ui_manifest.json` lists four files under `static/dist/`: the entry `index.mjs`, a page module `chunks/bookmarks.mjs`, `styles.css`, and `bookmark.svg`. They ship inside the Python package and need no JavaScript build step. +6. **The entry module.** `index.mjs` imports `mountBookmarks` from `./chunks/bookmarks.mjs` and exports one `page` surface, `library`, with `navigation: { label: "My bookmarks", labelZh: "我的书签", icon: "bookmark" }`, served at `/workspace/extensions/community.bookmarks/library`. It also exports a "Bookmarks → Save last answer" conversation action that calls `services.latestVisibleAnswer` and then `callBackend("save", ...)`. +7. **The page module.** `chunks/bookmarks.mjs` resolves `../styles.css` and `../bookmark.svg` against `import.meta.url` and loads them with `crossorigin="use-credentials"`, so they work on split-origin deployments. It observes `context.signal`, renders text with `textContent`, and clears its DOM in `dispose()`. + +The deployment record looks like this: + +```yaml +plugins: + - name: bookmarks + use: deerflow_extension_bookmarks:install + enabled: true + config: + enabled: true + storage_path: /var/lib/deerflow/bookmarks.sqlite +``` + +After a restart and a browser refresh, the card appears in the Extensions tab, the sidebar gains **My bookmarks**, and the conversation menus gain **Bookmarks**. The example uses single-host SQLite storage. It is not a multi-node storage pattern. + +## A minimal plugin + +The smallest useful shape is one page, two actions, and one read-only tool. This one uses the inline transport, so it is a single `client.mjs`. To split it into files, switch to [static assets](#static-assets), as `acme.notes` does in that section. Every part below has been exercised against the host's registry and routes: + +```python +"""A per-user scratchpad: a page, one backend action, one read-only model tool.""" + +from __future__ import annotations + +from collections.abc import Mapping +from pathlib import Path +from typing import Any + +from deerflow_extension_api import ( + ActionContext, + BackendAction, + BrowserModule, + ModelTool, + PluginContribution, + SettingsField, + ToolContext, + extension, +) + +NOTES: dict[str, list[str]] = {} # demo only: in-memory, single process + + +async def add_note(payload: Mapping[str, Any], context: ActionContext) -> dict[str, Any]: + text = payload.get("text") + if not isinstance(text, str) or not text.strip(): + raise ValueError("text is required") # becomes HTTP 422 + notes = NOTES.setdefault(context.principal.user_id, []) + if len(notes) >= context.settings["max_notes"]: + raise ValueError("note limit reached") + notes.append(text.strip()) + return {"count": len(notes)} + + +async def list_notes(payload: Mapping[str, Any], context: ActionContext) -> dict[str, Any]: + return {"notes": NOTES.get(context.principal.user_id, [])} + + +async def count_notes(payload: Mapping[str, Any], context: ToolContext) -> dict[str, Any]: + return {"count": len(NOTES.get(context.principal.user_id, []))} + + +@extension(api="0.2.2", name="notes") +def install(registry, config: Mapping[str, Any]) -> None: + accepted = registry.plugin( + PluginContribution( + namespace="acme.notes", + title="Notes", + description="A private scratchpad for each user.", + enabled=config.get("enabled", False) is True, + fields=(SettingsField("max_notes", "Maximum notes", "integer", 50, minimum=1, maximum=500),), + frontend=BrowserModule( + "notes.v1", + Path(__file__).with_name("client.mjs").read_text(encoding="utf-8"), + public_fields=("max_notes",), + ), + backend=(BackendAction("add", add_note), BackendAction("list", list_notes)), + tools=( + ModelTool( + "count_notes", + "Count the current user's saved notes. Read-only.", + {"type": "object", "properties": {}, "additionalProperties": False}, + count_notes, + ), + ), + ) + ) + if accepted is not True: + raise RuntimeError("acme-notes requires a host with full-stack plugin support") +``` + +And its `client.mjs`, placed next to `__init__.py` and included in the wheel: + +```js +function mountNotes(root, context) { + const list = document.createElement("ul"); + const input = document.createElement("input"); + const add = document.createElement("button"); + add.textContent = context.locale.startsWith("zh") ? "添加" : "Add"; + root.append(input, add, list); + + const render = async () => { + const { notes } = await context.callBackend("list", {}); + list.replaceChildren( + ...notes.map((note) => { + const item = document.createElement("li"); + item.textContent = note; // textContent, never innerHTML + return item; + }), + ); + }; + add.addEventListener( + "click", + async () => { + await context.callBackend("add", { text: input.value }); + input.value = ""; + await render(); + }, + { signal: context.signal }, + ); + render().catch(() => {}); + return { dispose: () => root.replaceChildren() }; +} + +export default { + apiVersion: 1, + module: "notes.v1", + surfaces: [ + { + id: "notes", + slot: "page", + title: "Notes", + navigation: { label: "My notes", labelZh: "我的笔记" }, + mount: mountNotes, + }, + ], + conversationActions(_t, locale = "en") { + const zh = locale.startsWith("zh"); + return { + label: zh ? "笔记" : "Notes", + icon: "file-text", + actions: [ + { + id: "save-answer", + label: zh ? "把最后一条回答存为笔记" : "Save last answer as a note", + icon: "file-text", + available: (settings) => settings.enabled === true, + async execute(context, services) { + const answer = await services.latestVisibleAnswer?.(context); + if (!answer) return services.showMessage(zh ? "没有可保存的回答" : "No answer to save"); + await services.callBackend("add", { text: answer.text.slice(0, 2000) }); + services.showMessage(zh ? "已保存" : "Saved"); + }, + }, + ], + }; + }, +}; +``` + +The in-memory dict is only for the example. Real plugins need storage that survives restarts and is shared across Gateway workers. + +## Lifecycle + +- Install, upgrade, enable, disable, and every `config` change need a Gateway restart, followed by a browser reload. There is no hot reload or hot unload. +- After a restart, an open page may point at a module hash, asset revision, or action that no longer exists. Those requests fail explicitly (`404`) until the user reloads. +- A disabled or removed plugin has no sidebar entry after a reload. Visiting its page URL directly shows "Extension page unavailable" and mounts nothing. + +Operator commands are covered in [Operations](/docs/harness/extensions/operations). diff --git a/frontend/src/content/en/harness/extensions/reference.mdx b/frontend/src/content/en/harness/extensions/reference.mdx index 0c113a45f..0ddfd37a4 100644 --- a/frontend/src/content/en/harness/extensions/reference.mdx +++ b/frontend/src/content/en/harness/extensions/reference.mdx @@ -1,11 +1,13 @@ --- title: Reference -description: Every public name in deerflow-extension-api 0.2.1, grouped by topic, with signatures, fields, and defaults. Also covers the compatibility rules and the contract's version history. +description: Every public name in deerflow-extension-api 0.2.3, grouped by topic, with signatures, fields, and defaults. Also covers the compatibility rules and the contract's version history. --- +import { Callout } from "nextra/components"; + # Reference -This page lists every name in `deerflow_extension_api.__all__` for contract version **0.2.1**. Import them from the package root: +This page lists every name in `deerflow_extension_api.__all__` for contract version **0.2.3**. Import them from the package root: ```python from deerflow_extension_api import ExtensionRegistry, MiddlewarePlacement, Placement @@ -34,7 +36,7 @@ def install(registry: ExtensionRegistry, config: Mapping[str, Any]) -> None: ... ### `ExtensionRegistry` -A `runtime_checkable` Protocol: the write-only surface passed to `install()`. Every method has a default implementation that registers nothing. A host whose registry predates a method inherits that default, so the call succeeds but the contribution is not registered. The version marker and package metadata are how you prevent that. +A `runtime_checkable` Protocol: the write-only surface passed to `install()`. Every method has a default implementation that registers nothing. A host whose registry predates a method inherits that default, so the call succeeds but the contribution is not registered. `plugin()` is the one method that reports this, by returning `False`; the version marker and package metadata are how you prevent it for the others. | Method | Returns | Registers | | ---------------------------------------------------------- | ------- | ---------------------------------------------------------------------------------------- | @@ -45,6 +47,7 @@ A `runtime_checkable` Protocol: the write-only surface passed to `install()`. Ev | `context_compaction_observer(observer: ContextCompactionObserver)` | `None` | An observer of summarization | | `service(service: ExtensionService)` | `None` | A Gateway-lifetime service | | `routers(routers: Sequence[Any])` | `None` | FastAPI routers, built during `install()` | +| `plugin(contribution: PluginContribution)` | `bool` | A full-stack plugin. `True` when accepted, `False` on a host without plugin support. Experimental | ## Middleware @@ -309,6 +312,18 @@ Frozen dataclass: `thread_id: str = ""`, `run_id: str = ""`, `seq: int = 0` (mon Frozen dataclasses. `RunPage`: `items: tuple[RunStatusView, ...] = ()`, `next_cursor: str | None = None`, `has_more: bool = False`. `RunEventPage`: `items: tuple[RunEventView, ...] = ()`, `next_after_seq: int | None = None`, `has_more: bool = False`. +### `resolve_run_evidence_reader(request: object) -> RunEvidenceReader | None` + +Return a reader bound to the authenticated caller of a contributed route. Use it in user-facing routes instead of the global `ExtensionRuntimeDeps.run_evidence_reader`, which sees every user's runs. `request` is duck-typed. Returns `None` on a host without request-scoped evidence. The Gateway raises `PermissionError("run evidence requires an authenticated user with runs:read")` when the caller is unauthenticated or lacks the `runs:read` permission; it never widens an admin or internal caller to global visibility. Any other resolver error propagates. + +### `require_run_evidence_reader(request: object) -> RunEvidenceReader` + +Like `resolve_run_evidence_reader()`, but raises `NotImplementedError("request-scoped run evidence is unavailable")` instead of returning `None`. Map `NotImplementedError` to HTTP 503 and `PermissionError` to 403. It never falls back to the global reader. + +### `RUN_EVIDENCE_READER_RESOLVER_KEY` + +`"deerflow_extension_run_evidence_reader_resolver"`. The `app.state` attribute the host installs its request-scoped resolver under. Host-owned. + ### `InvalidRunEvidenceCursor` Subclass of `ValueError`, raised for a malformed or unsupported cursor, or a cursor from another scope. @@ -382,18 +397,99 @@ Deterministic JSON: sorted keys, `(",", ":")` separators, `ensure_ascii=False`. SHA-256 hex digest of `canonical_json(value)` encoded as UTF-8. +## Full-stack plugins (experimental) + + + The plugin contract is experimental in 0.2.3. Always check the return value of + `registry.plugin(...)`. See [Plugins](/docs/harness/extensions/plugins). + + +### `PluginContribution` + +Frozen dataclass. + +| Field | Type | Default | +| ------------- | ------------------------------ | ------- | +| `namespace` | `str` | required | +| `title` | `str` | required | +| `description` | `str` | `""` | +| `enabled` | `bool` | `False` | +| `fields` | `tuple[SettingsField, ...]` | `()` | +| `frontend` | `BrowserModule \| BrowserAssets \| None` | `None` | +| `backend` | `tuple[BackendAction, ...]` | `()` | +| `api_version` | `int` | `1` | +| `tools` | `tuple[ModelTool, ...]` | `()` | + +The host accepts only `api_version == 1` and requires at least one of `frontend`, `backend`, or `tools`. Namespaces must be unique. + +### `BrowserModule` + +Frozen dataclass: `module: str`, `code: str` (a self-contained ES module, non-empty and at most 512 KiB), `public_fields: tuple[str, ...] = ()`. Discovery labels this transport `inline-v1`. Use `BrowserAssets` when the module needs relative imports, stylesheets, or images. + +### `BrowserAssets` + +Frozen dataclass declaring a manifest and static files inside the installed package. Discovery labels this transport `assets-v1`. + +| Field | Type | Default | +| --------------- | ----------------- | -------------------- | +| `module` | `str` | required | +| `root` | `str \| Path` | required | +| `manifest` | `str` | `"ui_manifest.json"` | +| `public_fields` | `tuple[str, ...]` | `()` | + +`root` is usually `Path(__file__).parent`. The manifest, relative to `root`, must be a JSON object with exactly the keys `schema_version` (the integer `1`), `entry`, and `files`: + +```json +{ + "schema_version": 1, + "entry": "static/dist/index.mjs", + "files": ["static/dist/index.mjs", "static/dist/styles.css"] +} +``` + +`files` lists 1 to 256 unique paths and `entry` must be one of them, ending in `.js` or `.mjs`. Paths use ASCII letters, digits, `_`, `-`, and `.`, separated by `/`, and each segment must start with a letter, digit, `_`, or `-`. The root must not be a symlink, and no listed path may pass through one. Limits: 64 KiB for the manifest, 4 MiB per file, 16 MiB in total. Accepted file types: `.js`, `.mjs`, `.css`, `.json`, `.map`, `.wasm`, `.png`, `.jpg`, `.jpeg`, `.gif`, `.webp`, `.svg`, `.ico`, `.woff`, `.woff2`, `.ttf`, `.otf`. + +`registry.plugin()` validates the manifest and reads every listed file into an in-memory snapshot. A validation error raises from `registry.plugin()`, so `install()` fails. The snapshot's revision is a SHA-256 over the manifest, the paths, and the file contents, and the Gateway serves the files from that snapshot at `GET /api/plugins/{namespace}/assets/{revision}/{path}`. Any authenticated user can download listed files, including source maps, even while the plugin is disabled, so never list secrets. + +### `BackendAction` + +Frozen dataclass: `name: str`, `handler: Callable[[Mapping[str, Any], ActionContext], Awaitable[Any]]`. Names must be unique and handlers async. + +### `ModelTool` + +Frozen dataclass: `name: str`, `description: str`, `input_schema: Mapping[str, Any]` (an inline object schema), `handler: Callable[[Mapping[str, Any], ToolContext], Awaitable[Any]]`, `group: str = "extensions"`. + +### `ActionContext` / `ToolContext` + +Frozen dataclasses. `ActionContext`: `principal: ExtensionPrincipal`, `settings: Mapping[str, bool | int | str]`. `ToolContext` extends it with `thread_id: str | None`. + +### `SettingsField` + +Frozen dataclass describing one non-secret deployment setting. + +| Field | Type | Default | +| ------------- | -------------------------------------------- | ------- | +| `key` | `str` | required | +| `title` | `str` | required | +| `kind` | `Literal["boolean", "integer", "string"]` | required | +| `default` | `bool \| int \| str` | required | +| `description` | `str` | `""` | +| `minimum` | `int \| None` | `None` | +| `maximum` | `int \| None` | `None` | +| `max_length` | `int` | `256` | + ## Version constant ### `API_VERSION` -The host's contract version as a dotted string, `"0.2.1"` for the contract this page describes. Always equal to the package version in `backend/packages/extension-api/pyproject.toml`. +The host's contract version as a dotted string, `"0.2.3"` for the contract this page describes. Always equal to the package version in `backend/packages/extension-api/pyproject.toml`. ## Compatibility rules - **Additive growth.** Every Protocol method has a default implementation and every optional dataclass field has a default. A contract release that adds a method or field does not break extensions built against an earlier one. - **Before 1.0**, a minor release may break extensions and a patch release is additive. **From 1.0 on**, breaking changes bump the major. - **The `@extension(api=...)` check.** When an install function carries a marker, the host refuses it unless: - - before 1.0: the same major and minor, and the host's version is at least the declared one (a `0.2.1` host accepts `0.2.0` and `0.2.1`, and rejects `0.2.2`, `0.1.x`, and `0.3.x`); + - before 1.0: the same major and minor, and the host's version is at least the declared one (a `0.2.3` host accepts `0.2.0` through `0.2.3`, and rejects `0.2.4`, `0.1.x`, and `0.3.x`); - from 1.0: the same major, and the host's version is at least the declared one. A marker that is not a dotted numeric string is refused. An install function without a marker is not checked. @@ -409,5 +505,7 @@ The host's contract version as a dotted string, `"0.2.1"` for the contract this | 0.1.2 | [#4780](https://github.com/bytedance/deer-flow/pull/4780) | `service` and `routers` registrations with `ExtensionService` and `ExtensionRuntimeDeps`; the packaged-extension manager | | 0.2.0 | [#4863](https://github.com/bytedance/deer-flow/pull/4863) | `agent_assembly_observer` and `context_compaction_observer` with `AgentAssemblyDescriptor`, `ToolDescriptor`, `MiddlewareDescriptor`, `CompactionEvent`; message provenance; release policies and canonical hashing; `ExtensionPrincipal`, `resolve_principal`, `require_admin` | | 0.2.1 | [#5405](https://github.com/bytedance/deer-flow/pull/5405) | `ExtensionRuntimeDeps.run_evidence_reader` with `RunEvidenceReader`, `RunPage`, `RunEventPage`, `RunStatusView`, `RunEventView`, `InvalidRunEvidenceCursor` | +| 0.2.2 | [#5647](https://github.com/bytedance/deer-flow/pull/5647) | Experimental `registry.plugin()` with `PluginContribution`, `BrowserModule`, `BackendAction`, `ModelTool`, `ActionContext`, `ToolContext`, `SettingsField` | +| 0.2.3 | [#5727](https://github.com/bytedance/deer-flow/pull/5727), [#5685](https://github.com/bytedance/deer-flow/pull/5685) | Request-scoped run evidence: `resolve_run_evidence_reader`, `require_run_evidence_reader`, `RUN_EVIDENCE_READER_RESOLVER_KEY` (#5727). Packaged browser resources: `BrowserAssets`, and `PluginContribution.frontend` also accepts it (#5685) | No release has removed a public name. diff --git a/frontend/src/content/en/harness/extensions/run-evidence.mdx b/frontend/src/content/en/harness/extensions/run-evidence.mdx index 4c2afa39f..2cc671557 100644 --- a/frontend/src/content/en/harness/extensions/run-evidence.mdx +++ b/frontend/src/content/en/harness/extensions/run-evidence.mdx @@ -1,6 +1,6 @@ --- title: Run Evidence -description: The read-only reader that lets a service find runs that changed and read their persisted events. Covers getting the reader, its three methods and their return types, cursor semantics, deletions, redaction, backend differences, and a polling service. +description: The read-only readers that let a service or a route find runs that changed and read their persisted events. Covers the global service reader and the caller-scoped request reader, the three reader methods and their return types, cursor semantics, deletions, redaction, backend differences, a polling service, and a per-user route. --- import { Callout } from "nextra/components"; @@ -11,9 +11,20 @@ Middleware and lifecycle hooks see a run while it happens. A **run evidence read The reader is read-only. No method writes to the host. -## Getting the reader +## Getting a reader -The Gateway hands one reader to every service, as `ExtensionRuntimeDeps.run_evidence_reader`: +There are two readers, and they differ only in what they can see: + +| Reader | How you get it | Sees | Use it in | +| ---------------- | ------------------------------------------------------------------------- | ---------------------------------------------- | --------------------------- | +| Service reader | `ExtensionRuntimeDeps.run_evidence_reader`, passed to `start()` | Every user's runs and events | Services: exporters, indexes | +| Request reader | `resolve_run_evidence_reader(request)` or `require_run_evidence_reader(request)` | Only the authenticated caller's own runs and events | Contributed routes | + +Both implement the same `RunEvidenceReader` interface described below. + +### The service reader + +The Gateway hands one reader to every service: ```python class MyService: @@ -26,12 +37,31 @@ class MyService: The Gateway always provides it. `None` means the extension is running on a host that does not implement the reader. Treat that as "unsupported", never as "no runs". Service lifetime and ordering are covered in [Services and Routes](/docs/harness/extensions/services-and-routes); the reader is usable from the moment `start()` is called until `stop()` returns. - The reader has **global** visibility: it sees every user's runs and events. - Services have no request principal, so the Gateway binds this reader to no - user on purpose. Event content is returned as stored. Never return data from - it to the caller of a route. + The service reader has **global** visibility: it sees every user's runs and + events. Services have no request principal, so the Gateway binds this reader + to no user on purpose. Event content is returned as stored. Never return data + from it to a caller; route handlers use the request reader instead. +### The request reader + +A route handler asks the host for a reader bound to the current request: + +| Helper | Host supports it | Caller lacks access | Host does not support it | +| --------------------------------------- | ---------------- | --------------------- | ------------------------ | +| `resolve_run_evidence_reader(request)` | Returns a reader | Raises `PermissionError` | Returns `None` | +| `require_run_evidence_reader(request)` | Returns a reader | Raises `PermissionError` | Raises `NotImplementedError` | + +Map them at your HTTP boundary: `PermissionError` to `403`, and `None` or `NotImplementedError` to `503`. Any other exception from the host's resolver propagates unchanged. Neither helper ever falls back to the service reader. + +The Gateway binds the reader from the request's authentication, never from anything the caller sends: + +- The caller must be authenticated and hold the **`runs:read`** permission. By default every signed-in user has it. When `authorization.enabled` is set in `config.yaml`, the configured provider decides. A personal access token must also carry the `runs:read` scope. +- The reader is scoped to the caller's own user ID: it sees the runs recorded with that user as owner, and nothing else. +- Admins and internal callers are **not** widened: an admin's request reader sees the admin's own runs, not everyone's. + +A cursor from one reader is rejected by a reader with a different scope, so a cursor issued to one user cannot be replayed by another. See [Cursor rules](#cursor-rules). + ## The reader interface `RunEvidenceReader` has three async methods, all keyword-only: @@ -231,10 +261,78 @@ Points worth copying: - `InvalidRunEvidenceCursor` resets the cursor to `None` instead of stopping the service. - An exception in one sync is logged, and the next poll resumes from the last committed cursor. +## Example: the caller's own runs + +This route returns the signed-in user's recently changed runs, one page at a time. It uses the request reader, so the host guarantees each caller sees only their own runs. It declares `api="0.2.3"` because the request-reader helpers first shipped in that contract version. + +```python filename="deerflow_extension_myruns/__init__.py" +"""List the caller's own recently changed runs.""" + +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any + +from deerflow_extension_api import ( + ExtensionRegistry, + InvalidRunEvidenceCursor, + RunEvidenceReader, + extension, + require_run_evidence_reader, +) +from fastapi import APIRouter, Depends, HTTPException, Request + + +def caller_reader(request: Request) -> RunEvidenceReader: + try: + return require_run_evidence_reader(request) + except NotImplementedError as exc: + raise HTTPException(status_code=503, detail="run evidence is not available on this host") from exc + except PermissionError as exc: + raise HTTPException(status_code=403, detail=str(exc)) from exc + + +def build_router() -> APIRouter: + router = APIRouter(prefix="/api/ext-myruns", tags=["ext-myruns"]) + + @router.get("/runs") + async def my_runs( + cursor: str | None = None, + limit: int = 50, + reader: RunEvidenceReader = Depends(caller_reader), + ) -> dict[str, Any]: + try: + page = await reader.list_changed_runs(cursor=cursor, limit=min(max(limit, 1), 200)) + except InvalidRunEvidenceCursor as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + return { + "runs": [{"thread_id": run.thread_id, "run_id": run.run_id, "status": run.status} for run in page.items], + "next_cursor": page.next_cursor, + "has_more": page.has_more, + } + + return router + + +@extension(api="0.2.3", name="myruns") +def install(registry: ExtensionRegistry, config: Mapping[str, Any]) -> None: + registry.routers((build_router(),)) +``` + +| Request | Response | +| ---------------------------------------------------------- | ------------------------------------------------------------------------ | +| No session | `401` from the host, before the handler runs | +| Signed in, without `runs:read` | `403 {"detail": "run evidence requires an authenticated user with runs:read"}` | +| Signed in | `200` with only the caller's runs, plus `next_cursor` and `has_more` | +| `?cursor=` not issued to this caller | `400 {"detail": "invalid run evidence cursor"}`, or `run evidence cursor scope does not match this reader` for another user's cursor | +| On a host without request-scoped evidence | `503 {"detail": "run evidence is not available on this host"}` | + +The client stores `next_cursor` and passes it back as `?cursor=` to continue. The cursor is bound to the caller, so leaking it to another user gains them nothing. + ## Common pitfalls - **Treating an empty page as unsupported.** Empty means caught up. Unsupported is `run_evidence_reader is None`. - **Advancing the cursor before the work is saved.** A crash then skips a page permanently. Save after. - **Expecting one entry per run.** A run reappears each time it changes. Key your output by `run_id`. - **Waiting for a deletion event.** There is none. Reconcile with `get_run_status`. -- **Serving reader data to users.** The reader sees every user. Never return its data from a route. +- **Serving the service reader to users.** It sees every user. Route handlers use `require_run_evidence_reader(request)`, which the host scopes to the caller. diff --git a/frontend/src/content/en/harness/extensions/services-and-routes.mdx b/frontend/src/content/en/harness/extensions/services-and-routes.mdx index 40106d7f2..c4f738578 100644 --- a/frontend/src/content/en/harness/extensions/services-and-routes.mdx +++ b/frontend/src/content/en/harness/extensions/services-and-routes.mdx @@ -40,7 +40,7 @@ Every service receives the same `ExtensionRuntimeDeps` snapshot: | `app_store` | `ExtensionData` | The app-scoped typed store, the same object middleware contributors and lifecycle hooks receive as `app_store` | | `policy` | `HostPolicySnapshot` | The limits the host enforces: token-budget settings (populated only when `token_budget.enabled`) and `max_subagents_per_run` from `subagents.max_total_per_run` | | `session_factory` | SQLAlchemy `async_sessionmaker` or `None` | The Gateway's database session factory. `None` when `database.backend` is `memory` | -| `run_evidence_reader` | `RunEvidenceReader` or `None` | A read-only view of every user's runs and persisted events. Never return its data from a route. See [Run Evidence](/docs/harness/extensions/run-evidence) | +| `run_evidence_reader` | `RunEvidenceReader` or `None` | A read-only view of every user's runs and persisted events. Route handlers use a caller-scoped reader instead. See [Run Evidence](/docs/harness/extensions/run-evidence) | `session_factory` is the host's own database connection, not a sandboxed one. A service that uses it can read and write every host table. If your extension keeps its own tables, declare a `table_prefix` in its `plugins:` record so that `alembic revision --autogenerate` leaves them alone. @@ -144,6 +144,10 @@ class ExtensionPrincipal: Both helpers are synchronous, so they work in sync and async handlers alike. They are framework-neutral, so map their results to HTTP status codes yourself: `None` to `401`, `PermissionError` to `403`. +### Reading run evidence in a route + +Do not serve data from the service's `run_evidence_reader` in a route: it sees every user. Call `require_run_evidence_reader(request)` instead. The host returns a reader scoped to the authenticated caller, provided they hold the `runs:read` permission. Map `PermissionError` to `403` and `NotImplementedError` to `503`. [Run Evidence](/docs/harness/extensions/run-evidence) covers the rules and a complete route. + ## Example: a status API This extension serves two routes. `GET /api/ext-status/me` is open to every signed-in user. `POST /api/ext-status/reset` requires an admin. Both return `503` until the service has started. @@ -251,4 +255,4 @@ Against a Gateway with the memory database backend, the routes answer: - **Generic paths.** Anything under a host namespace is rejected, and a path another extension registered first wins. Use a prefix you own. - **Treating `resolve_principal` as authentication.** The host already rejected unauthenticated requests. Use the principal for authorization within your routes, and fail closed when it is `None`. - **Assuming admin from a token.** Personal access tokens never grant admin through `require_admin`, by design. -- **Returning run evidence from a route.** The service reader is global: it sees every user's runs. +- **Returning service-reader data from a route.** The service reader is global. Routes use the caller-scoped request reader. diff --git a/frontend/src/content/en/harness/extensions/troubleshooting.mdx b/frontend/src/content/en/harness/extensions/troubleshooting.mdx index b862439db..688b87fa4 100644 --- a/frontend/src/content/en/harness/extensions/troubleshooting.mdx +++ b/frontend/src/content/en/harness/extensions/troubleshooting.mdx @@ -163,6 +163,30 @@ A system-model or compaction observation arrived while no Gateway notification l Your assembly observer raised. It runs synchronously during agent construction; keep it cheap and non-raising. +## Plugins and browser assets + +A validation error inside `registry.plugin(...)` raises, so it surfaces as `install() failed: ` for that extension, which then loads without any of its contributions. + +| Message after `install() failed:` | Cause and fix | +| ----------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------ | +| `[Errno 2] No such file or directory: ''` | The manifest, or a file it lists, is not in the installed package. Check that the wheel includes it; for an index or Git install, look inside the built wheel, not your source tree | +| `Unsupported browser asset manifest; expected schema_version 1` | The manifest must be an object with exactly `schema_version: 1`, `entry`, and `files` | +| `Duplicate browser manifest key` | A key appears twice in `ui_manifest.json` | +| `Browser manifest must list unique asset paths` | `files` is empty, has more than 256 entries, repeats a path, or contains an invalid path | +| `Browser manifest entry must be a listed JavaScript module` | `entry` is missing from `files` or does not end in `.js` or `.mjs` | +| `Invalid browser asset path` | A path contains `..`, a dot segment or dotfile, an empty segment, or a character outside `A-Z a-z 0-9 _ - .` | +| `Unsupported browser asset file type` | A listed file's extension is not an accepted type. HTML is never served | +| `Browser assets must not contain symlinks` / `Browser asset root must not be a symlink` | Replace links with real files | +| `Browser asset size limit exceeded` | Over 64 KiB for the manifest, 4 MiB for one file, or 16 MiB in total | +| `Browser code must be nonempty and at most 512 KiB` | An inline `BrowserModule` is empty or too large; switch to `BrowserAssets` | +| `Unsupported browser transport` | `frontend` is neither a `BrowserModule` nor a `BrowserAssets` | + +The plugin's files are snapshotted when the Gateway starts. After changing them, upgrade the extension and restart the Gateway. Browsers may keep already-cached code from the old revision until the page is reloaded. + +### A user-facing route cannot read run evidence + +`resolve_run_evidence_reader(request)` raises `PermissionError("run evidence requires an authenticated user with runs:read")` when the caller is not authenticated or lacks `runs:read`; return 403. `require_run_evidence_reader(request)` raises `NotImplementedError("request-scoped run evidence is unavailable")` on a host without request-scoped evidence; return 503. See [Run Evidence](/docs/harness/extensions/run-evidence). + ## Services and routes | Log line after `Extension :` | Cause and fix | diff --git a/frontend/src/content/zh/harness/extensions/_meta.ts b/frontend/src/content/zh/harness/extensions/_meta.ts index b9f2ba678..d889e0912 100644 --- a/frontend/src/content/zh/harness/extensions/_meta.ts +++ b/frontend/src/content/zh/harness/extensions/_meta.ts @@ -19,6 +19,9 @@ const meta: MetaRecord = { "run-evidence": { title: "运行证据", }, + plugins: { + title: "全栈插件", + }, operations: { title: "运维扩展", }, diff --git a/frontend/src/content/zh/harness/extensions/index.mdx b/frontend/src/content/zh/harness/extensions/index.mdx index ca81057a0..2824f15de 100644 --- a/frontend/src/content/zh/harness/extensions/index.mdx +++ b/frontend/src/content/zh/harness/extensions/index.mdx @@ -50,6 +50,7 @@ import { Callout } from "nextra/components"; | `registry.context_compaction_observer(obs)` | 每次摘要压缩从上下文中移除消息时的 `CompactionEvent`。见[生命周期与观察者](/docs/harness/extensions/observers) | | `registry.service(service)` | 在 Gateway 持久化层就绪后启动、关闭时停止的对象,可通过[运行证据](/docs/harness/extensions/run-evidence)读取器读取运行。见[服务与路由](/docs/harness/extensions/services-and-routes) | | `registry.routers(routers)` | 在所有宿主路由之后挂载、受 Gateway 认证保护的 FastAPI 路由。见[服务与路由](/docs/harness/extensions/services-and-routes) | +| `registry.plugin(contribution)` | 实验性。浏览器模块、带认证的后端动作和模型工具。宿主不支持插件时返回 `False`。见[全栈插件](/docs/harness/extensions/plugins) | 一个扩展可以注册任意组合。[内置示例](https://github.com/bytedance/deer-flow/tree/main/examples/deerflow-extension-example)注册了一个中间件、一个任务生命周期贡献者、一个系统模型观察者、一个服务和一个路由。 @@ -99,7 +100,7 @@ plugins: ## 版本 -契约包独立于 DeerFlow 进行版本管理,宿主通过 `deerflow_extension_api.API_VERSION` 暴露其版本。本手册覆盖 `deerflow-extension-api` **0.2.1**。 +契约包独立于 DeerFlow 进行版本管理,宿主通过 `deerflow_extension_api.API_VERSION` 暴露其版本。本手册覆盖 `deerflow-extension-api` **0.2.3**。 - 1.0 之前,minor 版本可以有不兼容变更,patch 版本只做增量。1.0 之后,不兼容变更会升 major。 - 每个 Protocol 方法都有默认实现,每个可选的 dataclass 字段都有默认值,所以增量发布不会破坏已经发布的扩展。 @@ -110,7 +111,7 @@ plugins: ## 术语 - **宿主**:加载扩展的 DeerFlow Gateway 进程。 -- **贡献**:通过注册表注册的一个对象:贡献者、观察者、服务或路由。 +- **贡献**:通过注册表注册的一个对象:贡献者、观察者、服务、路由或插件。 - **贡献者**:宿主回调以获取贡献的对象,例如 `MiddlewareContributor`,每构建一个 Agent 就返回一次中间件。 - **作用域**:一份状态所属的生命周期。**应用作用域**与 Gateway 同寿;**任务作用域**只存活一次 Lead 运行或一次子 Agent 执行。 - **`ExtensionData`**:挂在某个作用域上的类型化存储,以 Python 类型为键,两个扩展不会冲突。 diff --git a/frontend/src/content/zh/harness/extensions/operations.mdx b/frontend/src/content/zh/harness/extensions/operations.mdx index 8fbcad5b9..af6949463 100644 --- a/frontend/src/content/zh/harness/extensions/operations.mdx +++ b/frontend/src/content/zh/harness/extensions/operations.mdx @@ -156,6 +156,18 @@ extension table_prefix 'runs' would hide host-owned table(s) ['runs'] from alemb 因为快照是一份拷贝,对工作目录的修改只能通过 `make extension-upgrade SOURCE=<同一路径>` 再重启才能到达 DeerFlow。 +### 带浏览器资源的插件 + +声明了 `BrowserAssets` 的插件,会在其 Python 包内附带一个 `ui_manifest.json` 以及它列出的静态文件。[书签示例](https://github.com/bytedance/deer-flow/tree/main/examples/deerflow-extension-bookmarks)把它们放在 `deerflow_extension_bookmarks/static/dist/` 下。对运维人员来说,这意味着: + +- **文件必须包含在安装内容里。** 本地目录按原样做快照,所以请在 `install` 或 `upgrade` 之前构建好 JavaScript 产物:管理器运行的是 Python 构建后端,不会执行 JavaScript 构建。对于从索引或 Git 安装的来源,这些文件必须在构建出的 wheel 里。 +- **快照规则同样适用。** 包含符号链接或疑似机密文件的本地目录会先被管理器拒绝,Gateway 根本读不到清单。 +- **Gateway 只读取一次这些文件**:在启动时 `install()` 注册插件的时候读取,之后从内存中以基于内容的 revision 提供。修改资源和修改代码一样,需要 `upgrade` 并重启。浏览器可能会保留旧 revision 中已缓存的代码,直到页面重新加载。 +- **列出的文件任何已登录用户都能读取**,包括 source map,即使插件处于禁用状态。不要在清单中发布私有源码或机密。 +- Gateway 和前端请从同一个版本部署。较旧的前端不认识 `assets-v1` 传输方式。 + +清单错误见[故障排查](/docs/harness/extensions/troubleshooting)。 + ## 升级 `upgrade` 替换一个已安装扩展的来源,并保留它的记录:`config`、`required`、`enabled` 都保持不变。 diff --git a/frontend/src/content/zh/harness/extensions/plugins.mdx b/frontend/src/content/zh/harness/extensions/plugins.mdx new file mode 100644 index 000000000..30cace308 --- /dev/null +++ b/frontend/src/content/zh/harness/extensions/plugins.mdx @@ -0,0 +1,554 @@ +--- +title: 全栈插件 +description: 实验性。一个 PluginContribution 可以同时交付带工作区页面和会话动作的浏览器代码、带认证的后端动作和模型工具。本章涵盖每个字段和校验规则、两种浏览器传输方式(内联模块和清单列出的静态资源)、浏览器 API、HTTP 端点与缓存、安全模型,并逐步讲解书签示例。 +--- + +import { Callout } from "nextra/components"; + +# 全栈插件 + + + **实验性。** 插件契约为 `api_version` 1。内联的 `BrowserModule` 传输方式随 + `deerflow-extension-api` 0.2.2 引入,基于清单的 `BrowserAssets` 传输方式随 **0.2.3** + 引入。两者共享页面和动作接口,这些接口在 1.0 之前仍可能变化。 + + +其他贡献类型改变的是宿主在幕后做的事。**插件**则增加一个用户看得见、用得上的功能:工作区里的一个页面、会话菜单里的一个入口、页面可以调用的后端动作,以及模型可以调用的工具。这一切都来自一个通过常规扩展流程安装的 Python 包,不需要重新构建 DeerFlow 前端,宿主里也没有专门针对某个插件的代码。 + +## 用户看到什么 + +| 界面 | 出现位置 | +| ------------------ | --------------------------------------------------------------------------------------------------------- | +| **扩展**标签页 | 能力中心(`/workspace/capabilities?tab=extensions`)。每个已安装插件一张只读卡片:标题、描述,以及"已启用 · 由管理员管理"或"已停用 · 由管理员管理" | +| 工作区页面 | `/workspace/extensions/{namespace}/{surface id}`,可选侧边栏入口。URL 由宿主生成,插件无法占用任意路径 | +| 会话动作 | 聊天工具栏和侧边栏每个会话菜单里的一个菜单组 | +| 模型工具 | Agent 常规工具集的一部分,与其他工具一样受分组过滤和策略约束 | + +界面里没有任何地方能安装、启用或配置插件。目录对所有人都是只读的,包括管理员。浏览器会一直沿用它发现的插件集合,直到用户手动刷新页面。 + +## 贡献声明 + +`install()` 构建一个 `PluginContribution` 并传给 `registry.plugin()`: + +| 字段 | 类型 | 默认值 | 含义 | +| ------------- | ------------------------ | ------------ | ------------------------------------------------------------------------------------- | +| `namespace` | `str` | 必填 | 唯一标识,匹配 `[a-z][a-z0-9_.-]{0,95}`,例如 `community.bookmarks`。用于 URL 和工具名 | +| `title` | `str` | 必填 | 卡片和页面标题,不能为空 | +| `description` | `str` | `""` | 卡片文字 | +| `enabled` | `bool` | **`False`** | 插件开关。为 `False` 时动作和工具拒绝运行。见[启用插件](#启用插件) | +| `fields` | `tuple[SettingsField]` | `()` | 非机密的部署设置。见[设置](#设置) | +| `frontend` | `BrowserModule \| BrowserAssets \| None` | `None` | 浏览器代码。见[浏览器代码](#浏览器代码) | +| `backend` | `tuple[BackendAction]` | `()` | 浏览器代码可以调用的动作 | +| `tools` | `tuple[ModelTool]` | `()` | 模型可以调用的工具 | +| `api_version` | `int` | `1` | 必须为 `1` | + +一个贡献至少要提供 `frontend`、`backend`、`tools` 之一。注册表会在存储任何内容之前校验整个贡献,遇到第一处违规即抛出 `ValueError`: + +| 规则 | 错误信息 | +| ------------------------------------------------------------------------------ | ------------------------------------------------------------- | +| `api_version` 为 `1` | `Unsupported plugin contract` | +| 至少有一个浏览器模块、后端动作或工具 | `A plugin must contribute a browser module, backend action or tool` | +| 工具名匹配 `[a-z][a-z0-9_]{0,63}`、互不重复,且有描述和 `async` 处理函数 | `Model tools require unique names, descriptions and async handlers` | +| 工具 schema 是合法、内联、object 类型的 JSON Schema(见[模型工具](#模型工具)) | `Invalid plugin object schema` | +| 动作名匹配 `[a-z][a-z0-9_-]{0,63}`、互不重复,且有 `async` 处理函数 | `Backend actions require unique names and async handlers` | +| `frontend` 是 `BrowserModule`、`BrowserAssets` 或 `None` | `Unsupported browser transport` | +| 内联代码非空,UTF-8 编码后不超过 512 KiB | `Browser code must be nonempty and at most 512 KiB` | +| 资源清单及其列出的每个文件都符合[资源规则](#清单规则) | `Unsupported browser asset manifest; ...`、`Browser assets must not contain symlinks` 等 | +| 命名空间、设置字段和模块标识合法(见[设置](#设置)) | `Invalid settings contribution or namespace`、`Invalid or duplicate settings field` 等 | +| 命名空间和浏览器模块标识在所有已加载插件中唯一 | `Duplicate plugin namespace`、`Duplicate browser module` | + +从 `install()` 抛出的任何异常,包括这些 `ValueError`,以及清单列出的文件不存在时抛出的 `FileNotFoundError`,都会让这个扩展整体加载失败:它注册的一切都会回滚,Gateway 日志会记录原因(见[运行时](/docs/harness/extensions/runtime))。 + +### 检查返回值 + +宿主接受贡献时,`registry.plugin()` 返回 `True`。契约的默认实现返回 `False`,不支持插件的旧宿主就是这样。一个离开界面就没有意义的插件应该明确报错,而不是只装上一半: + +```python +if registry.plugin(contribution) is not True: + raise RuntimeError("this extension requires a host with full-stack plugin support") +``` + +## 启用插件 + +插件有**两个**开关,都要打开: + +- `plugins:` 记录里的 `enabled` 决定 Gateway 是否导入这个包。扩展管理器的 `enable` 和 `disable` 命令切换的就是它。 +- `PluginContribution.enabled` 控制这个贡献本身。它默认为 `False`,而且没有运行时覆盖存储,所以从不设置它的插件会一直处于停用状态:`GET /api/plugins` 报告 `enabled: false`,浏览器不加载它的模块,动作返回 `403 Plugin disabled by administrator.`,它的工具也不会进入任何 Agent。 + +惯例是从包的私有配置里读取第二个开关,让运维人员在 `config.yaml` 里控制它: + +```python +enabled=config.get("enabled", False) is True, +``` + +```yaml +plugins: + - name: notes + use: acme_notes:install + enabled: true # 加载这个包 + config: + enabled: true # 打开这个贡献 +``` + +两者都只在启动时读取,改动任何一个都需要重启 Gateway。 + +## 设置 + +`SettingsField` 声明一个非机密的部署值,动作和工具会收到它,浏览器也可以选择性地看到: + +| 字段 | 默认值 | 含义 | +| --------------------- | -------- | ------------------------------------------------------ | +| `key` | 必填 | 匹配 `[a-z][a-z0-9_]{0,63}`。`enabled` 是保留键 | +| `title` | 必填 | 显示标签 | +| `kind` | 必填 | `"boolean"`、`"integer"` 或 `"string"` | +| `default` | 必填 | 取值。必须符合 `kind` 和下面的边界 | +| `description` | `""` | | +| `minimum` / `maximum` | `None` | 整数边界 | +| `max_length` | `256` | 字符串长度上限,1 到 4096 | + +宿主会自己加上布尔字段 `enabled`。算上它,一个插件最多 32 个字段,留给你的是 31 个。取值只来自包本身:`default` 就是每个动作、工具和浏览器收到的值。没有设置 API,也不能在线编辑,所以运维需要调整的值,请在 `install()` 里从 `config` 推导默认值。 + + + 设置不是机密存储。不要把凭据放进 `SettingsField`。在 `install()` 里从环境变量或私有 + `config` 读取机密,并保存在你自己的 Python 对象中。 + + +浏览器只能看到 `enabled` 和浏览器声明的 `public_fields` 中列出的键(`BrowserModule` 和 `BrowserAssets` 都有这个字段)。列出一个未声明的字段会导致校验失败。 + +## 后端动作 + +```python +BackendAction(name: str, handler: async (payload, context) -> Any) +``` + +浏览器经由宿主调用动作,处理函数收到: + +- `payload`:从 JSON 请求体解析出的只读映射。宿主只保证它是一个不超过 256 KiB 的 JSON **对象**,每个字段都要自己校验。 +- `context`:一个 `ActionContext`,包含 `principal`(已认证调用方的 `ExtensionPrincipal`:`user_id`、`is_admin`、`is_internal`、`roles`)和 `settings`(只读的设置映射)。 + +返回值必须可以 JSON 序列化。宿主把结果映射成 HTTP 状态码,不会暴露你的异常文本: + +| 结果 | 状态码 | 响应体 `detail` | +| ------------------------------------- | ------ | ---------------------------------------------- | +| 处理函数正常返回 | `200` | 你的返回值 | +| 处理函数抛出 `ValueError` | `422` | `Invalid plugin action input.` | +| 处理函数抛出其他异常 | `502` | `Plugin action failed.`(日志中记录异常类型) | +| 处理函数运行超过 30 秒 | `504` | `Plugin action timed out.` | +| 请求体不是 JSON 对象 | `422` | `Plugin action requires a JSON object.` | +| 请求体超过 256 KiB | `413` | `Plugin action input exceeds 256 KiB.` | +| 插件已停用 | `403` | `Plugin disabled by administrator.` | +| 未知的命名空间或动作 | `404` | `Plugin is not installed.` / `Plugin action is not installed.` | +| viewer 请求头与会话不一致 | `409` | `Account changed; reload this plugin view.` | +| 没有已认证的调用方 | `401` | `Authentication required.` | + +处理函数无法自己决定状态码,因为就连抛出 `HTTPException` 也会变成 `502`。输入错误时抛出 `ValueError`;浏览器需要错误详情时,把它放在返回体里。超时会取消处理函数,但不会回滚外部副作用,也不会中止已经在工作线程里运行的任务。 + +**授权由你负责。** 宿主负责认证调用方,但不知道哪些记录属于谁。每次读写都要按 `context.principal.user_id` 限定范围,就像书签示例在每条查询里都带上属主条件一样。 + +## 模型工具 + +```python +ModelTool(name, description, input_schema, handler, group="extensions") +``` + +- **名称。** 模型看到的是由命名空间派生的名字:`ext___`。其中命名空间被清洗为 `[a-z0-9_]` 并截断到 20 个字符,名称截断到 25 个字符,再加 12 位哈希。例如 `acme.notes` 里的 `count_notes` 会变成 `ext_acme_notes_count_notes_d8aad91f72da`。哈希覆盖完整的命名空间和名称,所以截断不会让两个工具合并;万一两个插件工具仍然同名,工具组装会直接报错而不是二选一。与普通工具重名时,保留普通工具,跳过插件工具并记录警告。 +- **Schema。** `input_schema` 必须是合法的 JSON Schema(draft 2020-12),`"type": "object"`,而且完全内联:`$ref`、`$dynamicRef`、`$recursiveRef` 都会被拒绝,因此校验永远不会解析引用或访问网络。参数名 `runtime` 和 `config` 是保留的。 +- **限制。** 宿主会按 schema 校验每次调用的输入,上限 256 KiB。处理函数必须在 30 秒内返回,JSON 编码后的结果不能超过 64 KiB。 +- **上下文。** 处理函数收到一个 `ToolContext`:`settings`、`thread_id`(不在会话中时为 `None`)和 `principal`。对工具来说,principal **只**带 `user_id`,`is_admin` 恒为 `False`,`roles` 为空。不要让工具的授权依赖它们。 +- **错误。** 任何失败都以通用工具错误的形式交给模型:`Invalid plugin tool input.`、`Plugin result exceeds 64 KiB.`、`Plugin disabled by administrator.` 或 `Plugin tool unavailable or input rejected.`。Gateway 日志会记录插件、工具和异常类型。 +- **组装与策略。** 插件工具加入常规的工具组装流程。通过 `tool_groups` 限制过的 Agent,只有在列表包含该工具的 `group`(默认为 `extensions`)时才会拿到它。宿主之后的授权和技能工具策略与其他工具一样适用。 + +工具描述就是提示词。写清楚工具返回什么、返回的是数据而不是指令,以及它是否只读。 + +## 浏览器代码 + +插件用两种传输方式之一交付浏览器代码。两者产出的东西相同:一个默认导出符合[浏览器 API](#浏览器-api) 的 ES 模块。`GET /api/plugins` 会标出每个插件的传输方式,浏览器据此决定如何加载。 + +| 传输方式 | 声明 | 引入版本 | 适用场景 | +| ----------- | ---------------- | -------- | ---------------------------------------------------------------------------- | +| `inline-v1` | `BrowserModule` | 0.2.2 | 单个不超过 512 KiB 的自包含文件:没有相对导入、CSS 文件、图片或 WASM | +| `assets-v1` | `BrowserAssets` | 0.2.3 | 清单中列出的一个目录的文件:拆分的模块、样式表、图片、字体、WASM、source map | + +两种方式中,`module` 都是匹配 `[a-z][a-z0-9.-]{0,95}` 的标识,例如 `bookmarks.v1`,绝不是 URL,并且必须与默认导出里的 `module` 相同。使用 `BrowserAssets` 的插件需要 `deerflow-extension-api>=0.2.3`,宿主的前端和后端都必须是对应版本。旧版前端无法加载 `assets-v1` 插件,但失败只影响这一个插件。 + +### 内联模块 + +```python +BrowserModule(module: str, code: str, public_fields: tuple[str, ...] = ()) +``` + +`code` 是一个自包含 ES 模块的完整文本。宿主带着会话获取它,再从临时 Blob URL 导入,所以不能有相对导入,也不能有相对于 `import.meta.url` 解析的资源。把它打进 wheel,在 `install()` 里读取: + +```python +BrowserModule("notes.v1", Path(__file__).with_name("client.mjs").read_text(encoding="utf-8")) +``` + +配置了内容安全策略(CSP)的部署必须在 `script-src` 中允许 `blob:`。 + +### 静态资源 + +```python +BrowserAssets(module: str, root: str | Path, manifest: str = "ui_manifest.json", public_fields: tuple[str, ...] = ()) +``` + +`root` 是已安装包内的一个目录,通常是 `Path(__file__).parent`。清单文件相对于 `root`,列出浏览器可以加载的每一个文件: + +```json filename="ui_manifest.json" +{ + "schema_version": 1, + "entry": "static/dist/index.mjs", + "files": [ + "static/dist/index.mjs", + "static/dist/chunks/page.mjs", + "static/dist/notes.css" + ] +} +``` + +入口模块可以用相对路径导入同级模块,并用 `new URL(..., import.meta.url)` 定位资源: + +```js filename="static/dist/index.mjs" +import { mountNotes } from "./chunks/page.mjs"; + +export default { + apiVersion: 1, + module: "notes.v1", + surfaces: [ + { + id: "notes", + slot: "page", + title: "Notes", + navigation: { label: "My notes", labelZh: "我的笔记" }, + mount: mountNotes, + }, + ], +}; +``` + +```js filename="static/dist/chunks/page.mjs" +export function mountNotes(root, context) { + const style = document.createElement("link"); + style.rel = "stylesheet"; + style.crossOrigin = "use-credentials"; + style.href = new URL("../notes.css", import.meta.url).href; + const list = document.createElement("ul"); + root.append(style, list); + context + .callBackend("list", {}) + .then(({ notes }) => { + for (const note of notes) { + const item = document.createElement("li"); + item.textContent = note; + list.append(item); + } + }) + .catch(() => {}); + return { dispose: () => root.replaceChildren() }; +} +``` + +```python +frontend=BrowserAssets("notes.v1", Path(__file__).parent), +``` + +把清单和列出的每个文件都打进 wheel。用 hatchling 打包 Python 包目录时,不需要额外配置就会包含它们。第三方依赖要打包进来:宿主不解析裸 npm 导入,也不提供共享的 React 实例。让打包工具输出相对 URL,而不是 `/assets/...` 这样的绝对路径。 + +#### 清单规则 + +注册表在 `install()` 期间读取、校验每个列出的文件,并把它们快照到内存。任何违规都会让扩展加载失败: + +| 规则 | 错误信息 | +| ------------------------------------------------------------------------------------------- | --------------------------------------------------------------- | +| 清单是只含 `schema_version`(整数 `1`)、`entry` 和 `files` 三个键的 JSON 对象 | `Unsupported browser asset manifest; expected schema_version 1` | +| 没有重复的键 | `Duplicate browser manifest key` | +| `files` 列出 1 到 256 个互不重复的合法路径 | `Browser manifest must list unique asset paths` | +| `entry` 是 `files` 之一,并以 `.js` 或 `.mjs` 结尾 | `Browser manifest entry must be a listed JavaScript module` | +| 每个文件的类型都受支持(见下) | `Unsupported browser asset file type` | +| `root` 不是符号链接,列出路径的任何一级都不是符号链接 | `Browser asset root must not be a symlink`、`Browser assets must not contain symlinks` | +| 每个列出的文件都是 `root` 内的普通文件 | `Browser asset must be a regular file inside its package`(文件不存在时抛出 `FileNotFoundError`) | +| 清单不超过 64 KiB,单个文件不超过 4 MiB,所有文件合计不超过 16 MiB | `Browser asset size limit exceeded` | + +合法路径最长 512 个字符,由 `/` 分隔的段组成;每段只能用 ASCII 字母、数字、`_`、`-` 和 `.`,并以字母、数字、`_` 或 `-` 开头。因此点文件、`.` 和 `..` 段、空段、反斜杠、百分号编码和查询字符串都不被允许。 + +支持的类型(括号内为返回的 MIME 类型):`.js` 和 `.mjs`(`text/javascript`)、`.css`、`.json` 和 `.map`(`application/json`)、`.wasm`、`.png`、`.jpg`/`.jpeg`、`.gif`、`.webp`、`.svg`、`.ico`、`.woff`、`.woff2`、`.ttf` 和 `.otf`。HTML 和服务端文件永远不会被提供。 + +#### 修订版本与缓存 + +宿主对清单和每个文件的内容哈希计算一个 SHA-256 **修订版本**,每个 URL 都包含它:`/api/plugins/{namespace}/assets/{revision}/{path}`。任何一个文件变了,所有文件的修订版本都会变。请求由启动时的快照响应,从不读取文件系统,所以修改已安装的文件在重启前不会生效。 + +资源响应带有 `Cache-Control: private, max-age=31536000, immutable` 和 `Vary: Cookie, Authorization`。未知的路径或修订版本返回 `404`,带 `private, no-store`。宿主不保留历史快照,也没有公开 CDN 的约定。升级之后,仍持有旧修订版本的页面对未缓存的内容会得到 `404`,必须刷新。反过来,插件被移除后,浏览器仍可以继续使用已缓存的代码,所以移除并不等于立即撤销缓存。 + +#### 浏览器如何加载资源 + +宿主插入一个指向入口 URL、带 `crossorigin="use-credentials"` 的原生模块脚本,再从文档的模块映射里读取导出。因此相对的静态和动态导入都会在同一个修订版本内解析,并带上会话 cookie。URL 遵循 `NEXT_PUBLIC_BACKEND_BASE_URL`,包括路径前缀。 + +- **超时。** 宿主等待 30 秒后停止等待,并把该插件标为不可用。这是等待的期限,不是取消:模块之后仍可能完成求值。顶层代码不要有副作用,UI 工作放到 `mount` 里开始。 +- **模块状态是共享的。** 一个模块实例由整个文档共享。按查看者区分的数据放在每次挂载的状态里,并在 `dispose()` 中清除。不要在模块级别跨账号切换缓存 principal 或私有结果。 +- **其他资源由你负责。** 你创建的样式表和图片要设置 `crossOrigin = "use-credentials"`,就像 `page.mjs` 那样;JSON、WASM 或二进制数据用 `fetch(url, { credentials: "include" })`。CSS 里的字体和背景 URL 并不总会带上跨域 cookie。对这些资源,优先使用同源部署,或者带凭据获取后构造 `FontFace` 或 Blob URL,并在 dispose 时释放。 +- **CSP 与 CORS。** 后端来源必须在相应的 `script-src`、`style-src`、`img-src`、`font-src` 和 `connect-src` 指令中被允许。前后端分离部署需要精确来源的带凭据 CORS,以及可用的会话 cookie。 + +### 浏览器 API + +模块的默认导出必须符合宿主的浏览器 API v1: + +```js +export default { + apiVersion: 1, // 必填 + module: "notes.v1", // 必填,与声明里的 module 相同 + icon: "file-text", // 可选 + surfaces: [/* 页面 surface,最多 16 个 */], + conversationActions(t, locale) {/* 返回一个动作组 */}, +}; +``` + +如果 `apiVersion` 或 `module` 不匹配,或者任何 surface、导航项格式不对,宿主会拒绝这个模块,并在它的卡片上显示"当前页面加载失败"。 + +### 页面 surface + +| 字段 | 规则 | +| ------------ | ------------------------------------------------------------------- | +| `id` | `[a-z][a-z0-9-]{0,63}`,模块内唯一 | +| `slot` | `"page"`(目前唯一的 slot) | +| `title` | 非空字符串 | +| `navigation` | 可选的 `{ label, labelZh?, icon? }`,添加侧边栏入口。标签最多 120 个字符 | +| `mount` | `(root, context) => { dispose }`,同步调用 | + +宿主把页面挂载到一个 Shadow DOM 根节点,并传入上下文: + +| 上下文成员 | 含义 | +| ------------------------------ | --------------------------------------------------------------------------- | +| `namespace`、`locale` | 插件命名空间和界面语言,例如 `en-US` 或 `zh-CN` | +| `settings` | `enabled` 加上 `public_fields` | +| `signal` | 卸载或切换账号时中止。传给事件监听和请求 | +| `callBackend(action, payload)` | 向本插件声明过的某个动作发 POST,解析为 JSON 响应。非 2xx 响应和未声明的动作会被拒绝 | +| `openConversation(threadId)` | 可选。先通过认证 API 解析会话再跳转;会话不存在或无权访问时拒绝,不会跳转 | + +`mount` 必须返回一个带同步 `dispose()` 的对象。卸载时宿主先中止 `signal` 再调用它,并拦截之后才到达的 `callBackend` 结果。 + +### 会话动作 + +`conversationActions(t, locale)` 必须是同步的,返回 `{ label, icon, actions }`。每个动作有 `id`、`label`、`icon`,一个返回布尔值的同步 `available(settings)`,以及 `execute(context, services)`: + +- `context.thread` 是当前会话;宿主手上已有消息时,`context.messages` 就是这些消息。 +- `services.callBackend` 与上面相同。`services.latestVisibleAnswer(context)` 返回最后一条可见的助手消息 `{ id, text }` 或 `null`。`services.conversationText(context)` 返回可见的对话文本。两者都复用宿主的导出清洗逻辑,会排除隐藏消息、工具输出和推理内容。`services.showMessage(text)` 弹出一条提示。 + +每个插件的动作都在各自的错误边界里求值。格式错误或抛出异常的动作组会被省略并记录到浏览器控制台,其他插件的动作和会话页面都不受影响。 + +### 图标 + +宿主把图标名映射到一个固定集合:`bell`、`bookmark`、`download`、`file-json` 和 `file-text`。其他名字都会显示为通用的拼图图标。 + +## HTTP 端点 + +宿主通过四个需要认证的 Gateway 路由提供插件,它们遵循常规的会话和 CSRF 策略;没有已认证的调用方时都返回 `401`: + +| 路由 | 用途 | +| ------------------------------------------------------- | ------------------------------------------------------------------------------------------- | +| `GET /api/plugins` | 所有已加载插件:`namespace`、`title`、`description`、`viewer_id`、`module`、`entry`、`transport`(`inline-v1`、`assets-v1` 或 `null`)、公开的 `settings`,以及 `backend_actions` 名称 | +| `GET /api/plugins/modules/{module}/{sha256}.mjs` | 按内容哈希寻址的内联模块。`text/javascript`、`nosniff`、`private, no-store` | +| `GET /api/plugins/{namespace}/assets/{revision}/{path}` | 清单列出的一个文件。声明的 MIME 类型、`nosniff`、`Content-Security-Policy: sandbox`,以及[修订版本与缓存](#修订版本与缓存)中描述的不可变私有缓存 | +| `POST /api/plugins/{namespace}/actions/{name}` | 调用一个后端动作(见[后端动作](#后端动作)) | + +内联哈希或资源修订版本过期时返回 `404`,需要刷新页面。两个下载路由在插件停用时仍然会提供它的代码。 + +浏览器会带上 `X-Deerflow-Plugin-Viewer` 请求头,值为它发现插件时得到的 `viewer_id`,所以在另一个账号下打开的视图发起的动作会以 `409` 被拒绝。 + +## 安全模型 + + + 浏览器模块和 Python 处理函数都是运维人员安装的受信任代码。浏览器代码运行在**主页面里**, + 拥有当前登录用户的同源能力;Shadow DOM 隔离的是 CSS,不是权限。Python 处理函数在 + Gateway 进程中以 Gateway 权限运行。 + + +宿主确实保证的: + +- 它从不从浏览器提供的路径、导入字符串或远程 URL 加载代码,只提供已安装、经内容哈希校验的内联模块,或清单列出、从启动快照读取的文件。它从不提供目录列表或未列出的文件。 +- 资源响应带有 `Content-Security-Policy: sandbox`,所以直接打开一个资源(比如内含脚本的 SVG)不能以 Gateway 的来源运行脚本。这限制的是被当作文档打开的资源,并不会把宿主加载进页面的插件 JavaScript 放进沙箱。 +- 每个端点都要求已认证的调用方,而调用方身份来自会话,不来自请求体。 +- `callBackend` 绑定在插件自己的命名空间和它声明过的动作名上。 +- 只有 `enabled` 和显式公开的字段会到达浏览器。 + +任何已认证用户都可以下载清单列出的全部资源,包括 source map,即使插件已停用。不要在清单里列出机密或私有源码。 + +仍然是插件自己的职责:按 `principal.user_id` 对自己的数据做授权,校验每个 payload,渲染用户和模型文本时做转义(用 `textContent`,绝不用 `innerHTML`),并把工具结果当作不可信数据。 + +## 逐步讲解:书签示例 + +[`examples/deerflow-extension-bookmarks`](https://github.com/bytedance/deer-flow/tree/main/examples/deerflow-extension-bookmarks)(0.2.0 版,需要 `deerflow-extension-api>=0.2.3`)是一个完整的插件:用户在会话菜单里收藏最后一条可见回答,然后在 **我的书签** 页面搜索、重命名、打开或删除书签,Agent 也可以搜索这些书签。它用到了本章的每一部分: + +1. **配置和状态。** `install()` 要求 `config.enabled`(布尔值)和一个绝对路径 `config.storage_path`,并在那里打开 SQLite 存储。容器部署时要为它挂载持久目录。 +2. **贡献声明。** 命名空间 `community.bookmarks`,`enabled=config["enabled"]`,`BrowserAssets("bookmarks.v1", Path(__file__).parent)`,五个 `BackendAction`(`save`、`search`、`get`、`rename`、`delete`),以及一个只读的 `ModelTool`:`search_bookmarks`,模型看到的名字是 `ext_community_bookmarks_search_bookmarks_`。 +3. **检查返回值。** 如果 `registry.plugin(...)` 不是 `True`,它会抛出 `RuntimeError`。 +4. **数据归属。** 每条 SQL 语句都带有 `owner = context.principal.user_id`,每个动作只接受一组确定的 payload 键。模型工具从不接受用户 ID,所以用户永远只能搜索自己的书签。 +5. **资源。** `ui_manifest.json` 列出 `static/dist/` 下的四个文件:入口 `index.mjs`、页面模块 `chunks/bookmarks.mjs`、`styles.css` 和 `bookmark.svg`。它们随 Python 包一起发布,不需要 JavaScript 构建步骤。 +6. **入口模块。** `index.mjs` 从 `./chunks/bookmarks.mjs` 导入 `mountBookmarks`,导出一个 `page` surface `library`,带 `navigation: { label: "My bookmarks", labelZh: "我的书签", icon: "bookmark" }`,地址为 `/workspace/extensions/community.bookmarks/library`;还导出一个"书签 → 收藏最后一条回答"会话动作,先调用 `services.latestVisibleAnswer`,再调用 `callBackend("save", ...)`。 +7. **页面模块。** `chunks/bookmarks.mjs` 相对于 `import.meta.url` 解析 `../styles.css` 和 `../bookmark.svg`,并用 `crossorigin="use-credentials"` 加载它们,因此在前后端分离部署下也能工作。它监听 `context.signal`,用 `textContent` 渲染文本,并在 `dispose()` 中清空自己的 DOM。 + +部署记录如下: + +```yaml +plugins: + - name: bookmarks + use: deerflow_extension_bookmarks:install + enabled: true + config: + enabled: true + storage_path: /var/lib/deerflow/bookmarks.sqlite +``` + +重启并刷新浏览器后,扩展标签页里会出现它的卡片,侧边栏多出 **我的书签**,会话菜单多出 **书签**。这个示例使用单机 SQLite 存储,不是多节点存储的范式。 + +## 最小插件 + +最小的实用形态是一个页面、两个动作和一个只读工具。这个例子使用内联传输方式,所以只有一个 `client.mjs`;要拆成多个文件,请改用[静态资源](#静态资源),就像那一节里的 `acme.notes` 那样。下面每一部分都已经在宿主的注册表和路由上实际跑过: + +```python +"""A per-user scratchpad: a page, one backend action, one read-only model tool.""" + +from __future__ import annotations + +from collections.abc import Mapping +from pathlib import Path +from typing import Any + +from deerflow_extension_api import ( + ActionContext, + BackendAction, + BrowserModule, + ModelTool, + PluginContribution, + SettingsField, + ToolContext, + extension, +) + +NOTES: dict[str, list[str]] = {} # demo only: in-memory, single process + + +async def add_note(payload: Mapping[str, Any], context: ActionContext) -> dict[str, Any]: + text = payload.get("text") + if not isinstance(text, str) or not text.strip(): + raise ValueError("text is required") # becomes HTTP 422 + notes = NOTES.setdefault(context.principal.user_id, []) + if len(notes) >= context.settings["max_notes"]: + raise ValueError("note limit reached") + notes.append(text.strip()) + return {"count": len(notes)} + + +async def list_notes(payload: Mapping[str, Any], context: ActionContext) -> dict[str, Any]: + return {"notes": NOTES.get(context.principal.user_id, [])} + + +async def count_notes(payload: Mapping[str, Any], context: ToolContext) -> dict[str, Any]: + return {"count": len(NOTES.get(context.principal.user_id, []))} + + +@extension(api="0.2.2", name="notes") +def install(registry, config: Mapping[str, Any]) -> None: + accepted = registry.plugin( + PluginContribution( + namespace="acme.notes", + title="Notes", + description="A private scratchpad for each user.", + enabled=config.get("enabled", False) is True, + fields=(SettingsField("max_notes", "Maximum notes", "integer", 50, minimum=1, maximum=500),), + frontend=BrowserModule( + "notes.v1", + Path(__file__).with_name("client.mjs").read_text(encoding="utf-8"), + public_fields=("max_notes",), + ), + backend=(BackendAction("add", add_note), BackendAction("list", list_notes)), + tools=( + ModelTool( + "count_notes", + "Count the current user's saved notes. Read-only.", + {"type": "object", "properties": {}, "additionalProperties": False}, + count_notes, + ), + ), + ) + ) + if accepted is not True: + raise RuntimeError("acme-notes requires a host with full-stack plugin support") +``` + +它的 `client.mjs` 放在 `__init__.py` 旁边,并打进 wheel: + +```js +function mountNotes(root, context) { + const list = document.createElement("ul"); + const input = document.createElement("input"); + const add = document.createElement("button"); + add.textContent = context.locale.startsWith("zh") ? "添加" : "Add"; + root.append(input, add, list); + + const render = async () => { + const { notes } = await context.callBackend("list", {}); + list.replaceChildren( + ...notes.map((note) => { + const item = document.createElement("li"); + item.textContent = note; // textContent, never innerHTML + return item; + }), + ); + }; + add.addEventListener( + "click", + async () => { + await context.callBackend("add", { text: input.value }); + input.value = ""; + await render(); + }, + { signal: context.signal }, + ); + render().catch(() => {}); + return { dispose: () => root.replaceChildren() }; +} + +export default { + apiVersion: 1, + module: "notes.v1", + surfaces: [ + { + id: "notes", + slot: "page", + title: "Notes", + navigation: { label: "My notes", labelZh: "我的笔记" }, + mount: mountNotes, + }, + ], + conversationActions(_t, locale = "en") { + const zh = locale.startsWith("zh"); + return { + label: zh ? "笔记" : "Notes", + icon: "file-text", + actions: [ + { + id: "save-answer", + label: zh ? "把最后一条回答存为笔记" : "Save last answer as a note", + icon: "file-text", + available: (settings) => settings.enabled === true, + async execute(context, services) { + const answer = await services.latestVisibleAnswer?.(context); + if (!answer) return services.showMessage(zh ? "没有可保存的回答" : "No answer to save"); + await services.callBackend("add", { text: answer.text.slice(0, 2000) }); + services.showMessage(zh ? "已保存" : "Saved"); + }, + }, + ], + }; + }, +}; +``` + +内存字典只用于示例。真实插件需要能在重启后保留、并在多个 Gateway worker 之间共享的存储。 + +## 生命周期 + +- 安装、升级、启用、停用和任何 `config` 改动都需要重启 Gateway,然后刷新浏览器。没有热加载,也没有热卸载。 +- 重启之后,已打开的页面可能指向已不存在的模块哈希、资源修订版本或动作。这些请求会明确失败(`404`),直到用户刷新页面。 +- 已停用或已移除的插件在刷新后没有侧边栏入口。直接访问它的页面 URL 会显示"扩展页面不可用",不会挂载任何内容。 + +运维命令见[运维](/docs/harness/extensions/operations)。 diff --git a/frontend/src/content/zh/harness/extensions/reference.mdx b/frontend/src/content/zh/harness/extensions/reference.mdx index c74c803bb..d5dd1a45b 100644 --- a/frontend/src/content/zh/harness/extensions/reference.mdx +++ b/frontend/src/content/zh/harness/extensions/reference.mdx @@ -1,11 +1,13 @@ --- title: 参考 -description: deerflow-extension-api 0.2.1 中的每一个公开名称,按主题分组,附签名、字段和默认值。另含兼容性规则和契约的版本历史。 +description: deerflow-extension-api 0.2.3 中的每一个公开名称,按主题分组,附签名、字段和默认值。另含兼容性规则和契约的版本历史。 --- +import { Callout } from "nextra/components"; + # 参考 -本页列出契约版本 **0.2.1** 中 `deerflow_extension_api.__all__` 的每一个名称。请从包根导入: +本页列出契约版本 **0.2.3** 中 `deerflow_extension_api.__all__` 的每一个名称。请从包根导入: ```python from deerflow_extension_api import ExtensionRegistry, MiddlewarePlacement, Placement @@ -34,7 +36,7 @@ def install(registry: ExtensionRegistry, config: Mapping[str, Any]) -> None: ... ### `ExtensionRegistry` -一个 `runtime_checkable` 的 Protocol:传给 `install()` 的只写接口。每个方法都有一个什么都不注册的默认实现。如果宿主的注册表早于某个方法,就会继承这个默认实现,于是调用成功,但贡献并没有被注册。要靠版本标记和包元数据来防止这种情况。 +一个 `runtime_checkable` 的 Protocol:传给 `install()` 的只写接口。每个方法都有一个什么都不注册的默认实现。如果宿主的注册表早于某个方法,就会继承这个默认实现,于是调用成功,但贡献并没有被注册。只有 `plugin()` 会通过返回 `False` 报告这一点;对其他方法,要靠版本标记和包元数据来防止这种情况。 | 方法 | 返回值 | 注册内容 | | ---------------------------------------------------------- | ------- | ---------------------------------------------------------------------------- | @@ -45,6 +47,7 @@ def install(registry: ExtensionRegistry, config: Mapping[str, Any]) -> None: ... | `context_compaction_observer(observer: ContextCompactionObserver)` | `None` | 摘要压缩的观察者 | | `service(service: ExtensionService)` | `None` | 与 Gateway 同寿的服务 | | `routers(routers: Sequence[Any])` | `None` | 在 `install()` 期间构建的 FastAPI 路由器 | +| `plugin(contribution: PluginContribution)` | `bool` | 全栈插件。被接受时返回 `True`,宿主不支持插件时返回 `False`。实验性 | ## 中间件 @@ -309,6 +312,18 @@ Gateway 的实现接受 1 到 2000 的 `limit` 和非负的 `after_seq`,否则 冻结的 dataclass。`RunPage`:`items: tuple[RunStatusView, ...] = ()`、`next_cursor: str | None = None`、`has_more: bool = False`。`RunEventPage`:`items: tuple[RunEventView, ...] = ()`、`next_after_seq: int | None = None`、`has_more: bool = False`。 +### `resolve_run_evidence_reader(request: object) -> RunEvidenceReader | None` + +返回绑定到贡献路由已认证调用者的读取器。面向用户的路由应使用它,而不是能看到所有用户运行的全局 `ExtensionRuntimeDeps.run_evidence_reader`。`request` 是鸭子类型。宿主不支持请求级运行证据时返回 `None`。调用者未认证或缺少 `runs:read` 权限时,Gateway 抛出 `PermissionError("run evidence requires an authenticated user with runs:read")`;它从不把管理员或内部调用者放宽到全局可见。其他解析器错误会直接传播。 + +### `require_run_evidence_reader(request: object) -> RunEvidenceReader` + +与 `resolve_run_evidence_reader()` 相同,但在不支持时抛出 `NotImplementedError("request-scoped run evidence is unavailable")`,而不是返回 `None`。把 `NotImplementedError` 映射为 HTTP 503,把 `PermissionError` 映射为 403。它从不回退到全局读取器。 + +### `RUN_EVIDENCE_READER_RESOLVER_KEY` + +`"deerflow_extension_run_evidence_reader_resolver"`。宿主安装请求级解析器所用的 `app.state` 属性名,由宿主拥有。 + ### `InvalidRunEvidenceCursor` `ValueError` 的子类,游标格式错误、不受支持或属于另一个作用域时抛出。 @@ -382,18 +397,98 @@ class ReleasePolicyProvider(Protocol): 对 `canonical_json(value)` 以 UTF-8 编码后计算的 SHA-256 十六进制摘要。 +## 全栈插件(实验性) + + + 插件契约在 0.2.3 中是实验性的。务必检查 `registry.plugin(...)` 的返回值。见[插件](/docs/harness/extensions/plugins)。 + + +### `PluginContribution` + +冻结的 dataclass。 + +| 字段 | 类型 | 默认值 | +| ------------- | ------------------------------ | ------- | +| `namespace` | `str` | 必填 | +| `title` | `str` | 必填 | +| `description` | `str` | `""` | +| `enabled` | `bool` | `False` | +| `fields` | `tuple[SettingsField, ...]` | `()` | +| `frontend` | `BrowserModule \| BrowserAssets \| None` | `None` | +| `backend` | `tuple[BackendAction, ...]` | `()` | +| `api_version` | `int` | `1` | +| `tools` | `tuple[ModelTool, ...]` | `()` | + +宿主只接受 `api_version == 1`,并要求 `frontend`、`backend`、`tools` 至少有一项。命名空间必须唯一。 + +### `BrowserModule` + +冻结的 dataclass:`module: str`、`code: str`(一个自包含的 ES 模块,非空且不超过 512 KiB)、`public_fields: tuple[str, ...] = ()`。发现接口把这种传输方式标记为 `inline-v1`。模块需要相对导入、样式表或图片时,请使用 `BrowserAssets`。 + +### `BrowserAssets` + +冻结的 dataclass,声明已安装包内的清单和静态文件。发现接口把这种传输方式标记为 `assets-v1`。 + +| 字段 | 类型 | 默认值 | +| --------------- | ----------------- | -------------------- | +| `module` | `str` | 必填 | +| `root` | `str \| Path` | 必填 | +| `manifest` | `str` | `"ui_manifest.json"` | +| `public_fields` | `tuple[str, ...]` | `()` | + +`root` 通常是 `Path(__file__).parent`。清单相对于 `root`,必须是恰好包含 `schema_version`(整数 `1`)、`entry` 和 `files` 三个键的 JSON 对象: + +```json +{ + "schema_version": 1, + "entry": "static/dist/index.mjs", + "files": ["static/dist/index.mjs", "static/dist/styles.css"] +} +``` + +`files` 列出 1 到 256 个不重复的路径,`entry` 必须是其中之一,并以 `.js` 或 `.mjs` 结尾。路径只能使用 ASCII 字母、数字、`_`、`-` 和 `.`,以 `/` 分隔,每一段必须以字母、数字、`_` 或 `-` 开头。`root` 本身不能是符号链接,列出的路径也不能经过符号链接。限制:清单 64 KiB,单个文件 4 MiB,总计 16 MiB。接受的文件类型:`.js`、`.mjs`、`.css`、`.json`、`.map`、`.wasm`、`.png`、`.jpg`、`.jpeg`、`.gif`、`.webp`、`.svg`、`.ico`、`.woff`、`.woff2`、`.ttf`、`.otf`。 + +`registry.plugin()` 会校验清单,并把列出的每个文件读入内存快照。校验错误会从 `registry.plugin()` 抛出,于是 `install()` 失败。快照的 revision 是对清单、路径和文件内容计算的 SHA-256,Gateway 从这个快照在 `GET /api/plugins/{namespace}/assets/{revision}/{path}` 提供文件。任何已认证用户都能下载列出的文件(包括 source map),即使插件处于禁用状态,所以绝不要列出机密。 + +### `BackendAction` + +冻结的 dataclass:`name: str`、`handler: Callable[[Mapping[str, Any], ActionContext], Awaitable[Any]]`。名称必须唯一,handler 必须是异步的。 + +### `ModelTool` + +冻结的 dataclass:`name: str`、`description: str`、`input_schema: Mapping[str, Any]`(内联的对象 schema)、`handler: Callable[[Mapping[str, Any], ToolContext], Awaitable[Any]]`、`group: str = "extensions"`。 + +### `ActionContext` / `ToolContext` + +冻结的 dataclass。`ActionContext`:`principal: ExtensionPrincipal`、`settings: Mapping[str, bool | int | str]`。`ToolContext` 在其基础上增加 `thread_id: str | None`。 + +### `SettingsField` + +描述一项非机密部署设置的冻结 dataclass。 + +| 字段 | 类型 | 默认值 | +| ------------- | -------------------------------------------- | ------- | +| `key` | `str` | 必填 | +| `title` | `str` | 必填 | +| `kind` | `Literal["boolean", "integer", "string"]` | 必填 | +| `default` | `bool \| int \| str` | 必填 | +| `description` | `str` | `""` | +| `minimum` | `int \| None` | `None` | +| `maximum` | `int \| None` | `None` | +| `max_length` | `int` | `256` | + ## 版本常量 ### `API_VERSION` -宿主的契约版本,为点分字符串,本页描述的契约是 `"0.2.1"`。始终等于 `backend/packages/extension-api/pyproject.toml` 中的包版本。 +宿主的契约版本,为点分字符串,本页描述的契约是 `"0.2.3"`。始终等于 `backend/packages/extension-api/pyproject.toml` 中的包版本。 ## 兼容性规则 - **增量演进。** 每个 Protocol 方法都有默认实现,每个可选的 dataclass 字段都有默认值。新增方法或字段的契约发布不会破坏基于更早版本构建的扩展。 - **1.0 之前**,minor 版本可以有不兼容变更,patch 版本只做增量。**1.0 之后**,不兼容变更会升 major。 - **`@extension(api=...)` 检查。** install 函数带有标记时,宿主只在以下情况接受它: - - 1.0 之前:major 和 minor 相同,且宿主版本不低于声明版本(`0.2.1` 宿主接受 `0.2.0` 和 `0.2.1`,拒绝 `0.2.2`、`0.1.x` 和 `0.3.x`); + - 1.0 之前:major 和 minor 相同,且宿主版本不低于声明版本(`0.2.3` 宿主接受 `0.2.0` 到 `0.2.3`,拒绝 `0.2.4`、`0.1.x` 和 `0.3.x`); - 1.0 之后:major 相同,且宿主版本不低于声明版本。 不是点分数字字符串的标记会被拒绝。没有标记的 install 函数不做检查。 @@ -409,5 +504,7 @@ class ReleasePolicyProvider(Protocol): | 0.1.2 | [#4780](https://github.com/bytedance/deer-flow/pull/4780) | `service` 和 `routers` 注册,以及 `ExtensionService` 和 `ExtensionRuntimeDeps`;打包扩展管理器 | | 0.2.0 | [#4863](https://github.com/bytedance/deer-flow/pull/4863) | `agent_assembly_observer` 和 `context_compaction_observer`,以及 `AgentAssemblyDescriptor`、`ToolDescriptor`、`MiddlewareDescriptor`、`CompactionEvent`;消息来源标记;发布策略与规范哈希;`ExtensionPrincipal`、`resolve_principal`、`require_admin` | | 0.2.1 | [#5405](https://github.com/bytedance/deer-flow/pull/5405) | `ExtensionRuntimeDeps.run_evidence_reader`,以及 `RunEvidenceReader`、`RunPage`、`RunEventPage`、`RunStatusView`、`RunEventView`、`InvalidRunEvidenceCursor` | +| 0.2.2 | [#5647](https://github.com/bytedance/deer-flow/pull/5647) | 实验性的 `registry.plugin()`,以及 `PluginContribution`、`BrowserModule`、`BackendAction`、`ModelTool`、`ActionContext`、`ToolContext`、`SettingsField` | +| 0.2.3 | [#5727](https://github.com/bytedance/deer-flow/pull/5727)、[#5685](https://github.com/bytedance/deer-flow/pull/5685) | 请求级运行证据:`resolve_run_evidence_reader`、`require_run_evidence_reader`、`RUN_EVIDENCE_READER_RESOLVER_KEY`(#5727)。打包的浏览器资源:`BrowserAssets`,`PluginContribution.frontend` 也接受它(#5685) | 至今没有任何版本移除过公开名称。 diff --git a/frontend/src/content/zh/harness/extensions/run-evidence.mdx b/frontend/src/content/zh/harness/extensions/run-evidence.mdx index c03b4c3b3..738d25a48 100644 --- a/frontend/src/content/zh/harness/extensions/run-evidence.mdx +++ b/frontend/src/content/zh/harness/extensions/run-evidence.mdx @@ -1,6 +1,6 @@ --- title: 运行证据 -description: 让服务发现有变化的运行并读取其持久化事件的只读读取器。涵盖如何获取读取器、它的三个方法及返回类型、游标语义、删除、脱敏、后端差异,以及一个轮询服务。 +description: 让服务或路由发现有变化的运行并读取其持久化事件的只读读取器。涵盖全局的服务读取器和限定调用方的请求读取器、读取器的三个方法及返回类型、游标语义、删除、脱敏、后端差异、一个轮询服务,以及一个按用户的路由。 --- import { Callout } from "nextra/components"; @@ -13,7 +13,18 @@ import { Callout } from "nextra/components"; ## 获取读取器 -Gateway 通过 `ExtensionRuntimeDeps.run_evidence_reader` 把一个读取器交给每个服务: +读取器有两种,区别只在于能看到什么: + +| 读取器 | 获取方式 | 能看到 | 用在 | +| ---------- | ------------------------------------------------------------------------- | -------------------------------- | -------------- | +| 服务读取器 | `ExtensionRuntimeDeps.run_evidence_reader`,传给 `start()` | 所有用户的运行和事件 | 服务:导出器、索引 | +| 请求读取器 | `resolve_run_evidence_reader(request)` 或 `require_run_evidence_reader(request)` | 只有已认证调用方自己的运行和事件 | 贡献的路由 | + +两者都实现下文介绍的同一个 `RunEvidenceReader` 接口。 + +### 服务读取器 + +Gateway 把一个读取器交给每个服务: ```python class MyService: @@ -26,11 +37,30 @@ class MyService: Gateway 总会提供它。`None` 表示扩展运行在一个没有实现读取器的宿主上,应当视为"不支持",而不是"没有运行"。服务的生命周期和顺序见[服务与路由](/docs/harness/extensions/services-and-routes);从 `start()` 被调用到 `stop()` 返回,读取器都可以使用。 - 读取器具有**全局**可见性:它能看到所有用户的运行和事件。服务没有请求 + 服务读取器具有**全局**可见性:它能看到所有用户的运行和事件。服务没有请求 principal,所以 Gateway 有意不把这个读取器绑定到任何用户。事件内容按存储原样返回。 - 永远不要把它的数据返回给路由的调用方。 + 永远不要把它的数据返回给调用方;路由处理函数请改用请求读取器。 +### 请求读取器 + +路由处理函数向宿主索取一个绑定到当前请求的读取器: + +| 辅助函数 | 宿主支持 | 调用方无权访问 | 宿主不支持 | +| --------------------------------------- | ------------ | ------------------------ | ---------------------------- | +| `resolve_run_evidence_reader(request)` | 返回读取器 | 抛出 `PermissionError` | 返回 `None` | +| `require_run_evidence_reader(request)` | 返回读取器 | 抛出 `PermissionError` | 抛出 `NotImplementedError` | + +在你的 HTTP 边界做映射:`PermissionError` 映射为 `403`,`None` 或 `NotImplementedError` 映射为 `503`。宿主解析器抛出的其他异常原样传播。两个辅助函数都不会回退到服务读取器。 + +Gateway 依据请求的认证信息绑定读取器,绝不依据调用方发送的任何内容: + +- 调用方必须已认证并持有 **`runs:read`** 权限。默认所有已登录用户都有。在 `config.yaml` 中设置了 `authorization.enabled` 时,由配置的提供方决定。个人访问令牌还必须带有 `runs:read` scope。 +- 读取器限定在调用方自己的用户 ID 上:只能看到以该用户为所有者记录的运行,别的都看不到。 +- 管理员和内部调用方**不会**被放宽:管理员的请求读取器只能看到管理员自己的运行,而不是所有人的。 + +一个读取器签发的游标会被作用域不同的读取器拒绝,所以签发给某个用户的游标无法被另一个用户重放。见[游标规则](#游标规则)。 + ## 读取器接口 `RunEvidenceReader` 有三个异步方法,全部只接受关键字参数: @@ -228,10 +258,78 @@ def install(registry: ExtensionRegistry, config: Mapping[str, Any]) -> None: - 遇到 `InvalidRunEvidenceCursor` 时把游标重置为 `None`,而不是让服务停下。 - 一次同步中的异常会被记录,下一次轮询从最后提交的游标继续。 +## 示例:调用方自己的运行 + +这个路由分页返回已登录用户最近发生变化的运行。它使用请求读取器,所以由宿主保证每个调用方只能看到自己的运行。它声明 `api="0.2.3"`,因为请求读取器辅助函数最早随该契约版本发布。 + +```python filename="deerflow_extension_myruns/__init__.py" +"""List the caller's own recently changed runs.""" + +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any + +from deerflow_extension_api import ( + ExtensionRegistry, + InvalidRunEvidenceCursor, + RunEvidenceReader, + extension, + require_run_evidence_reader, +) +from fastapi import APIRouter, Depends, HTTPException, Request + + +def caller_reader(request: Request) -> RunEvidenceReader: + try: + return require_run_evidence_reader(request) + except NotImplementedError as exc: + raise HTTPException(status_code=503, detail="run evidence is not available on this host") from exc + except PermissionError as exc: + raise HTTPException(status_code=403, detail=str(exc)) from exc + + +def build_router() -> APIRouter: + router = APIRouter(prefix="/api/ext-myruns", tags=["ext-myruns"]) + + @router.get("/runs") + async def my_runs( + cursor: str | None = None, + limit: int = 50, + reader: RunEvidenceReader = Depends(caller_reader), + ) -> dict[str, Any]: + try: + page = await reader.list_changed_runs(cursor=cursor, limit=min(max(limit, 1), 200)) + except InvalidRunEvidenceCursor as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + return { + "runs": [{"thread_id": run.thread_id, "run_id": run.run_id, "status": run.status} for run in page.items], + "next_cursor": page.next_cursor, + "has_more": page.has_more, + } + + return router + + +@extension(api="0.2.3", name="myruns") +def install(registry: ExtensionRegistry, config: Mapping[str, Any]) -> None: + registry.routers((build_router(),)) +``` + +| 请求 | 响应 | +| -------------------------------------- | -------------------------------------------------------------------------- | +| 没有会话 | 宿主返回 `401`,处理函数不会运行 | +| 已登录,但没有 `runs:read` | `403 {"detail": "run evidence requires an authenticated user with runs:read"}` | +| 已登录 | `200`,只包含调用方的运行,以及 `next_cursor` 和 `has_more` | +| `?cursor=` 不是签发给这个调用方的 | `400 {"detail": "invalid run evidence cursor"}`;如果是其他用户的游标,则为 `run evidence cursor scope does not match this reader` | +| 宿主不支持请求作用域的证据 | `503 {"detail": "run evidence is not available on this host"}` | + +客户端保存 `next_cursor`,并以 `?cursor=` 传回以继续翻页。游标绑定到调用方,泄露给其他用户也没有用。 + ## 常见误区 - **把空页当作不支持。** 空页表示已追平。不支持是 `run_evidence_reader is None`。 - **在工作保存之前推进游标。** 一旦崩溃,这一页会被永久跳过。请先保存再推进。 - **以为每个运行只有一条记录。** 运行每次变化都会再次出现。请以 `run_id` 为键组织产出。 - **等待删除事件。** 没有这种事件。请用 `get_run_status` 对账。 -- **把读取器的数据提供给用户。** 读取器能看到所有用户。永远不要在路由中返回它的数据。 +- **把服务读取器的数据提供给用户。** 它能看到所有用户。路由处理函数请使用 `require_run_evidence_reader(request)`,由宿主把它限定到调用方。 diff --git a/frontend/src/content/zh/harness/extensions/services-and-routes.mdx b/frontend/src/content/zh/harness/extensions/services-and-routes.mdx index 0be05dd08..c22977c28 100644 --- a/frontend/src/content/zh/harness/extensions/services-and-routes.mdx +++ b/frontend/src/content/zh/harness/extensions/services-and-routes.mdx @@ -40,7 +40,7 @@ def install(registry, config): | `app_store` | `ExtensionData` | 应用作用域的类型化存储,与中间件贡献者和生命周期钩子收到的 `app_store` 是同一个对象 | | `policy` | `HostPolicySnapshot` | 宿主实际执行的限制:token 预算设置(仅在 `token_budget.enabled` 时填充),以及来自 `subagents.max_total_per_run` 的 `max_subagents_per_run` | | `session_factory` | SQLAlchemy `async_sessionmaker` 或 `None` | Gateway 的数据库会话工厂。`database.backend` 为 `memory` 时为 `None` | -| `run_evidence_reader` | `RunEvidenceReader` 或 `None` | 对所有用户的运行及其持久化事件的只读视图。永远不要在路由中返回它的数据。见[运行证据](/docs/harness/extensions/run-evidence) | +| `run_evidence_reader` | `RunEvidenceReader` 或 `None` | 对所有用户的运行及其持久化事件的只读视图。路由处理函数请改用限定调用方的读取器。见[运行证据](/docs/harness/extensions/run-evidence) | `session_factory` 就是宿主自己的数据库连接,没有任何沙箱。用它的服务可以读写宿主的每一张表。如果扩展有自己的表,请在它的 `plugins:` 记录里声明 `table_prefix`,这样 `alembic revision --autogenerate` 就不会去动它们。 @@ -143,6 +143,10 @@ class ExtensionPrincipal: 两个辅助函数都是同步的,在同步和异步处理函数里都能用。它们与框架无关,所以要自己把结果映射成 HTTP 状态码:`None` 映射为 `401`,`PermissionError` 映射为 `403`。 +### 在路由中读取运行证据 + +不要在路由里返回服务的 `run_evidence_reader` 中的数据:它能看到所有用户。请改为调用 `require_run_evidence_reader(request)`。只要调用方持有 `runs:read` 权限,宿主就返回一个限定到该已认证调用方的读取器。把 `PermissionError` 映射为 `403`,`NotImplementedError` 映射为 `503`。规则和一个完整的路由见[运行证据](/docs/harness/extensions/run-evidence)。 + ## 示例:一个状态 API 这个扩展提供两条路由。`GET /api/ext-status/me` 对所有已登录用户开放,`POST /api/ext-status/reset` 需要管理员。服务启动之前两者都返回 `503`。 @@ -250,4 +254,4 @@ def install(registry: ExtensionRegistry, config: Mapping[str, Any]) -> None: - **通用路径。** 宿主命名空间下的路径会被拒绝,别的扩展先注册的路径会胜出。请使用你自己拥有的前缀。 - **把 `resolve_principal` 当作认证。** 宿主已经拒绝了未认证的请求。principal 用于你路由内部的授权,为 `None` 时要按拒绝处理。 - **以为令牌能带来管理员权限。** 个人访问令牌按设计永远无法通过 `require_admin`。 -- **在路由中返回运行证据。** 服务读取器是全局的:它能看到所有用户的运行。 +- **在路由中返回服务读取器的数据。** 服务读取器是全局的。路由请使用限定调用方的请求读取器。 diff --git a/frontend/src/content/zh/harness/extensions/troubleshooting.mdx b/frontend/src/content/zh/harness/extensions/troubleshooting.mdx index 431038e7d..03a94eb2f 100644 --- a/frontend/src/content/zh/harness/extensions/troubleshooting.mdx +++ b/frontend/src/content/zh/harness/extensions/troubleshooting.mdx @@ -163,6 +163,30 @@ Gateway 的环境里没有安装这个模块。请通过管理器安装,而不 你的组装观察者抛出了异常。它在 Agent 构建期间同步运行;保持轻量并且不要抛异常。 +## 插件与浏览器资源 + +`registry.plugin(...)` 内部的校验错误会直接抛出,因此会以该扩展的 `install() failed: <消息>` 出现,而该扩展的所有贡献都不会被加载。 + +| `install() failed:` 之后的消息 | 原因与修复 | +| ----------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------ | +| `[Errno 2] No such file or directory: ''` | 清单或它列出的某个文件不在已安装的包里。检查 wheel 是否包含它;对于从索引或 Git 安装的包,要检查构建出的 wheel,而不是你的源码目录 | +| `Unsupported browser asset manifest; expected schema_version 1` | 清单必须是恰好包含 `schema_version: 1`、`entry` 和 `files` 的对象 | +| `Duplicate browser manifest key` | `ui_manifest.json` 中某个键出现了两次 | +| `Browser manifest must list unique asset paths` | `files` 为空、超过 256 项、有重复路径,或包含非法路径 | +| `Browser manifest entry must be a listed JavaScript module` | `entry` 不在 `files` 中,或不以 `.js` / `.mjs` 结尾 | +| `Invalid browser asset path` | 路径中含有 `..`、点号段或点文件、空段,或 `A-Z a-z 0-9 _ - .` 以外的字符 | +| `Unsupported browser asset file type` | 列出的某个文件扩展名不在接受的类型中。HTML 永远不会被提供 | +| `Browser assets must not contain symlinks` / `Browser asset root must not be a symlink` | 把链接替换成真实文件 | +| `Browser asset size limit exceeded` | 清单超过 64 KiB、单个文件超过 4 MiB,或总计超过 16 MiB | +| `Browser code must be nonempty and at most 512 KiB` | 内联的 `BrowserModule` 为空或过大;改用 `BrowserAssets` | +| `Unsupported browser transport` | `frontend` 既不是 `BrowserModule` 也不是 `BrowserAssets` | + +插件的文件在 Gateway 启动时做快照。修改这些文件后,要升级扩展并重启 Gateway。浏览器可能会保留旧 revision 中已缓存的代码,直到页面重新加载。 + +### 面向用户的路由无法读取运行证据 + +调用者未认证或缺少 `runs:read` 时,`resolve_run_evidence_reader(request)` 会抛出 `PermissionError("run evidence requires an authenticated user with runs:read")`,应返回 403。宿主不支持请求级运行证据时,`require_run_evidence_reader(request)` 会抛出 `NotImplementedError("request-scoped run evidence is unavailable")`,应返回 503。见[运行证据](/docs/harness/extensions/run-evidence)。 + ## 服务与路由 | `Extension :` 之后的日志行 | 原因与修复 |