feat(plugins): full-stack plugin APIs and bookmarks (#5647)

* feat(plugins): add full-stack contributions and bookmarks example

* ci(plugins): provision bookmark gateway for browser tests

* fix(plugins): authenticate module downloads through configured backend

* fix(plugins): isolate contributions and localize extension UI

* fix(plugins): preserve bookmark agent routing and contain async callbacks

* fix(plugins): pin durable batch workers to app extension snapshots
This commit is contained in:
Wenchao An 2026-09-22 11:18:57 +08:00 committed by GitHub
parent 32088600aa
commit e2f19d8335
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
71 changed files with 4295 additions and 22 deletions

View File

@ -5,11 +5,27 @@ on:
branches: [ 'main', '2.0.x-dev' ] branches: [ 'main', '2.0.x-dev' ]
paths: paths:
- 'frontend/**' - 'frontend/**'
- 'backend/app/gateway/routers/plugins.py'
- 'backend/extension_test_fixtures/bookmark_plugin_gateway.py'
- 'backend/packages/extension-api/**'
- 'backend/packages/harness/deerflow/extensions/**'
- 'backend/packages/harness/deerflow/config/plugin_settings.py'
- 'backend/pyproject.toml'
- 'backend/uv.lock'
- 'examples/deerflow-extension-bookmarks/**'
- '.github/workflows/e2e-tests.yml' - '.github/workflows/e2e-tests.yml'
pull_request: pull_request:
types: [opened, synchronize, reopened, ready_for_review] types: [opened, synchronize, reopened, ready_for_review]
paths: paths:
- 'frontend/**' - 'frontend/**'
- 'backend/app/gateway/routers/plugins.py'
- 'backend/extension_test_fixtures/bookmark_plugin_gateway.py'
- 'backend/packages/extension-api/**'
- 'backend/packages/harness/deerflow/extensions/**'
- 'backend/packages/harness/deerflow/config/plugin_settings.py'
- 'backend/pyproject.toml'
- 'backend/uv.lock'
- 'examples/deerflow-extension-bookmarks/**'
- '.github/workflows/e2e-tests.yml' - '.github/workflows/e2e-tests.yml'
concurrency: concurrency:
@ -23,12 +39,28 @@ jobs:
e2e-tests: e2e-tests:
if: ${{ github.event_name != 'pull_request' || github.event.pull_request.draft == false }} if: ${{ github.event_name != 'pull_request' || github.event.pull_request.draft == false }}
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 15 timeout-minutes: 25
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v6 uses: actions/checkout@v6
# The bookmark browser test starts the real Python action/router fixture.
- name: Set up Python
uses: actions/setup-python@v6
with:
python-version: '3.12'
- name: Install uv
uses: astral-sh/setup-uv@v7
with:
# Keep aligned with backend/Dockerfile and other backend workflows.
version: "0.11.1"
- name: Install backend dependencies (bookmark plugin gateway)
working-directory: backend
run: uv sync --locked
- name: Setup Node.js - name: Setup Node.js
uses: actions/setup-node@v4 uses: actions/setup-node@v4
with: with:

View File

@ -1280,7 +1280,7 @@ A managed package declares exactly one standard PEP 621 entry point:
acme = "acme_deerflow_extension:install" acme = "acme_deerflow_extension:install"
``` ```
That callable uses the standalone `deerflow-extension-api` contract and can register five That callable uses the standalone `deerflow-extension-api` contract and can register several
contribution kinds: isolated middleware at semantic lead/subagent model or tool positions, contribution kinds: isolated middleware at semantic lead/subagent model or tool positions,
lead and subagent task-lifecycle hooks, observers for DeerFlow-owned model calls that are lead and subagent task-lifecycle hooks, observers for DeerFlow-owned model calls that are
not wrapped by middleware model-call hooks (goal, memory, title, and summarization), not wrapped by middleware model-call hooks (goal, memory, title, and summarization),
@ -1288,6 +1288,18 @@ Gateway-lifetime services, and eager FastAPI HTTP routers. The contract package
framework dependencies; extensions must declare FastAPI, LangChain, LangGraph, or other framework dependencies; extensions must declare FastAPI, LangChain, LangGraph, or other
libraries they import. libraries they import.
Full-stack contributions can additionally provide browser pages, conversation actions,
authenticated backend operations and model tools through the
[plugin APIs](docs/full-stack-plugins.md). The independent
[bookmarks example](examples/deerflow-extension-bookmarks/README.md) demonstrates
one package with persistent user data, its own sidebar page and a read-only search tool.
Reopening a bookmark resolves the conversation's current agent through the host, so
custom-agent conversations retain their original chat entry point, including older bookmarks.
Installation and activation remain deployment-controlled; Capability Center shows plugin
information and status. Browser code runs as trusted same-origin code.
The browser API and inline `BrowserModule.code` transport are experimental. The
plugin guide describes an additive path to manifests and packaged static resources.
DeerFlow allocates a task-scoped extension store only for middleware, lifecycle, or DeerFlow allocates a task-scoped extension store only for middleware, lifecycle, or
system-model observation. Services receive app-scoped runtime dependencies after Gateway system-model observation. Services receive app-scoped runtime dependencies after Gateway
persistence is ready and stop in reverse order after active runs drain. The optional persistence is ready and stop in reverse order after active runs drain. The optional
@ -1553,6 +1565,8 @@ Content-less sub-agent final messages report `No response generated` instead of
An ordinary `task` also receives a defensive snapshot of the dispatching run's current uploads. This lets eligible sub-agents use `list_uploaded_files` to find earlier-turn files without returning same-turn attachments as historical. Delayed or recovered `batch_task` workers leave this tool disabled because they have no valid turn-local upload boundary. An ordinary `task` also receives a defensive snapshot of the dispatching run's current uploads. This lets eligible sub-agents use `list_uploaded_files` to find earlier-turn files without returning same-turn attachments as historical. Delayed or recovered `batch_task` workers leave this tool disabled because they have no valid turn-local upload boundary.
Durable `batch_task` workers use one app-owned plugin snapshot for tool assembly and execution. Recovered tasks adopt the new worker's plugin snapshot after a Gateway restart; plugin objects are never stored in durable task records.
Ordinary `task` delegation and explicit durable `batch_task` execution share the startup-scoped `subagent_runtime` process capacity. Batch mode keeps large independent item sets in SQL with separate total, live, and running limits, restart recovery, bounded results, and a thread-scoped Web UI panel. The panel pages through bounded previews on demand; full stored result text is available only through the owner-scoped JSONL export, while internal execution and authorization context never enters owner-facing responses. If the batch worker is later stopped or disabled, threads with persisted batches retain read-only item inspection and JSONL export; execution controls remain disabled until the worker is running again. See `config.example.yaml` and [the implementation contract](docs/plans/2026-08-24-subagent-batch-capacity-implementation.md) for limits and recovery semantics. Ordinary `task` delegation and explicit durable `batch_task` execution share the startup-scoped `subagent_runtime` process capacity. Batch mode keeps large independent item sets in SQL with separate total, live, and running limits, restart recovery, bounded results, and a thread-scoped Web UI panel. The panel pages through bounded previews on demand; full stored result text is available only through the owner-scoped JSONL export, while internal execution and authorization context never enters owner-facing responses. If the batch worker is later stopped or disabled, threads with persisted batches retain read-only item inspection and JSONL export; execution controls remain disabled until the worker is running again. See `config.example.yaml` and [the implementation contract](docs/plans/2026-08-24-subagent-batch-capacity-implementation.md) for limits and recovery semantics.
Direct `create_deerflow_agent(...)` integrations can own the same boundary explicitly instead of relying on Gateway startup. Construct one `SubagentRuntime` and share it across every graph in that application; its `max_running`, ordinary per-run total, bound `task` tool, and optional durable-batch tools then use the same caller-owned snapshot and execution controller. A runtime with a batch repository owns a worker and must be started before graph construction and stopped during application shutdown: Direct `create_deerflow_agent(...)` integrations can own the same boundary explicitly instead of relying on Gateway startup. Construct one `SubagentRuntime` and share it across every graph in that application; its `max_running`, ordinary per-run total, bound `task` tool, and optional durable-batch tools then use the same caller-owned snapshot and execution controller. A runtime with a batch repository owns a worker and must be started before graph construction and stopped during application shutdown:

View File

@ -214,3 +214,5 @@ failure policy rather than exposing the unfiltered user-scoped catalog.
`authz.py::resolve_route_permissions()` is shared by HTTP middleware, decorator-only auth, and Live Browser WebSocket admission. It evaluates registered permissions asynchronously as `resource="route"`, targeting full `resource:action` strings. HTTP caches decisions in `AuthContext`; provider errors follow `authorization.fail_closed` (decision errors are per-permission). Disabled authorization returns all permissions without a provider. Owner and admin checks remain independent. Live requires `threads:write` with `is_internal=False` after login/Origin checks but before acceptance, owner lookup, or session acquisition; denial closes 4403, unexpected setup errors close 4501, cancellation propagates. Checks are admission-only; restart Gateway to close old connections. Tests: `test_authorization_route_permissions.py`, `test_browser_readonly_security.py`, `test_auth.py`, `test_auth_middleware.py` in `tests/`. `authz.py::resolve_route_permissions()` is shared by HTTP middleware, decorator-only auth, and Live Browser WebSocket admission. It evaluates registered permissions asynchronously as `resource="route"`, targeting full `resource:action` strings. HTTP caches decisions in `AuthContext`; provider errors follow `authorization.fail_closed` (decision errors are per-permission). Disabled authorization returns all permissions without a provider. Owner and admin checks remain independent. Live requires `threads:write` with `is_internal=False` after login/Origin checks but before acceptance, owner lookup, or session acquisition; denial closes 4403, unexpected setup errors close 4501, cancellation propagates. Checks are admission-only; restart Gateway to close old connections. Tests: `test_authorization_route_permissions.py`, `test_browser_readonly_security.py`, `test_auth.py`, `test_auth_middleware.py` in `tests/`.
Skill listing authorization mirrors the models pattern: `routers/skills.py` routes resolve `(provider, principal)` through `authz.py::resolve_skill_authorization()` — a thin sibling of `resolve_model_authorization`, both delegating to the shared `_resolve_route_scoped_authorization` core — and the shared `_filter_visible_skills` helper filters the user-scoped catalog (public + caller's custom skills) through `filter_resources(principal, "skill", ...)` by name. Three user-facing surfaces apply it: `GET /api/skills` (the frontend skill list / slash-command autocomplete), `GET /api/skills/custom`, and `GET /api/skills/{name}` — the detail endpoint returns the standard 404 for an invisible skill so it cannot become an existence oracle the filtered list closed (`get_model`'s 403 is an execution decision via `authorize("model", "use")`, which has no skills equivalent in this layer). Anonymous callers are unfiltered (mirroring `list_models`), and provider resolution/decision errors follow `authorization.fail_closed` (fail-closed → empty listing / 404, fail-open → full listing). Skill management endpoints (`install`/`upload`/`reload`/custom-skill CRUD) stay `require_admin_user`-gated, and runtime activation authorization is a separate layer. The built-in RBAC provider maps this to the per-role `skills` policy key. Tests: `tests/test_skills_listing_authorization.py`. Skill listing authorization mirrors the models pattern: `routers/skills.py` routes resolve `(provider, principal)` through `authz.py::resolve_skill_authorization()` — a thin sibling of `resolve_model_authorization`, both delegating to the shared `_resolve_route_scoped_authorization` core — and the shared `_filter_visible_skills` helper filters the user-scoped catalog (public + caller's custom skills) through `filter_resources(principal, "skill", ...)` by name. Three user-facing surfaces apply it: `GET /api/skills` (the frontend skill list / slash-command autocomplete), `GET /api/skills/custom`, and `GET /api/skills/{name}` — the detail endpoint returns the standard 404 for an invisible skill so it cannot become an existence oracle the filtered list closed (`get_model`'s 403 is an execution decision via `authorize("model", "use")`, which has no skills equivalent in this layer). Anonymous callers are unfiltered (mirroring `list_models`), and provider resolution/decision errors follow `authorization.fail_closed` (fail-closed → empty listing / 404, fail-open → full listing). Skill management endpoints (`install`/`upload`/`reload`/custom-skill CRUD) stay `require_admin_user`-gated, and runtime activation authorization is a separate layer. The built-in RBAC provider maps this to the per-role `skills` policy key. Tests: `tests/test_skills_listing_authorization.py`.
Batch workers pin `app.state.extensions`; never persist plugin snapshots.

View File

@ -34,6 +34,7 @@ from app.gateway.routers import (
mcp_tasks, mcp_tasks,
memory, memory,
models, models,
plugins,
project_documents, project_documents,
project_thread_files, project_thread_files,
projects, projects,
@ -553,6 +554,7 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
repository=batch_repo, repository=batch_repo,
config=subagent_batches_config, config=subagent_batches_config,
runtime_config=subagent_runtime_config, runtime_config=subagent_runtime_config,
extensions=getattr(app.state, "extensions", None),
) )
app.state.subagent_batch_service = batch_service app.state.subagent_batch_service = batch_service
if subagent_batches_config.enabled: if subagent_batches_config.enabled:
@ -980,6 +982,8 @@ This gateway provides runtime endpoints for agent runs plus custom endpoints for
# Assistants compatibility API (LangGraph Platform stub) # Assistants compatibility API (LangGraph Platform stub)
app.include_router(assistants_compat.router) app.include_router(assistants_compat.router)
app.include_router(plugins.router)
# Auth API is mounted at /api/v1/auth # Auth API is mounted at /api/v1/auth
app.include_router(auth.router) app.include_router(auth.router)
app.include_router(user_preferences.router) app.include_router(user_preferences.router)

View File

@ -0,0 +1,107 @@
"""Authenticated discovery and execution for deployment-installed full-stack plugins."""
import asyncio
import hashlib
import json
import logging
from types import MappingProxyType
from deerflow_extension_api.auth import resolve_principal
from fastapi import APIRouter, HTTPException, Request, Response
from deerflow.extensions.plugin_tools import plugin_settings
router = APIRouter(prefix="/api/plugins", tags=["plugins"])
logger = logging.getLogger(__name__)
def _principal(request):
principal = resolve_principal(request)
if principal is None:
raise HTTPException(401, "Authentication required.")
return principal
@router.get("")
async def list_plugins(request: Request, response: Response):
principal = _principal(request)
response.headers["Cache-Control"] = "private, no-store"
entries = []
for source, plugin in request.app.state.extensions.plugins:
settings = plugin_settings(source, plugin)
module = plugin.frontend
revision = hashlib.sha256(module.code.encode()).hexdigest() if module else None
public = ("enabled", *module.public_fields) if module else ("enabled",)
entries.append(
{
"namespace": plugin.namespace,
"title": plugin.title,
"description": plugin.description,
"viewer_id": principal.user_id,
"module": module.module if module else None,
"entry": f"/api/plugins/modules/{module.module}/{revision}.mjs" if module else None,
"settings": {key: settings[key] for key in public},
"backend_actions": [action.name for action in plugin.backend],
}
)
return entries
@router.get("/modules/{module}/{revision}.mjs")
async def plugin_module(request: Request, module: str, revision: str):
_principal(request)
for _, plugin in request.app.state.extensions.plugins:
if plugin.frontend and plugin.frontend.module == module:
code = plugin.frontend.code.encode()
if hashlib.sha256(code).hexdigest() == revision:
return Response(code, media_type="text/javascript", headers={"Cache-Control": "private, no-store", "X-Content-Type-Options": "nosniff"})
raise HTTPException(404, "Plugin module unavailable; reload the page.")
@router.post("/{namespace}/actions/{action_name}")
async def invoke_plugin_action(request: Request, namespace: str, action_name: str):
"""Invoke an installed action with the authenticated viewer and deployment settings."""
from deerflow_extension_api.auth import resolve_principal
from deerflow_extension_api.plugins import ActionContext
from deerflow.extensions.plugin_tools import plugin_settings
principal = resolve_principal(request)
if principal is None:
raise HTTPException(401, "Authentication required.")
if request.headers.get("x-deerflow-plugin-viewer") not in (None, principal.user_id):
raise HTTPException(409, "Account changed; reload this plugin view.")
found = next(((source, plugin) for source, plugin in request.app.state.extensions.plugins if plugin.namespace == namespace), None)
if found is None:
raise HTTPException(404, "Plugin is not installed.")
source, plugin = found
action = next((item for item in plugin.backend if item.name == action_name), None)
if action is None:
raise HTTPException(404, "Plugin action is not installed.")
try:
settings = await asyncio.to_thread(plugin_settings, source, plugin)
except (ValueError, OSError) as exc:
raise HTTPException(503, "Plugin settings unavailable.") from exc
if settings["enabled"] is not True:
raise HTTPException(403, "Plugin disabled by administrator.")
body = bytearray()
async for chunk in request.stream():
body.extend(chunk)
if len(body) > 256 * 1024:
raise HTTPException(413, "Plugin action input exceeds 256 KiB.")
try:
payload = json.loads(body)
if not isinstance(payload, dict):
raise ValueError("Expected an object")
except (ValueError, UnicodeDecodeError) as exc:
raise HTTPException(422, "Plugin action requires a JSON object.") from exc
try:
async with asyncio.timeout(30):
return await action.handler(MappingProxyType(payload), ActionContext(principal, MappingProxyType(settings)))
except TimeoutError as exc:
raise HTTPException(504, "Plugin action timed out.") from exc
except ValueError as exc:
raise HTTPException(422, "Invalid plugin action input.") from exc
except Exception as exc:
logger.warning("Plugin action failed: %s/%s (%s)", namespace, action_name, type(exc).__name__)
raise HTTPException(502, "Plugin action failed.") from exc

View File

@ -0,0 +1,56 @@
"""Loopback-only example preview: real plugin/router/SQLite, synthetic identity.
The tool-dispatch endpoint uses real LangGraph ToolNode with a deterministic
tool call, not an external language model or the full production Gateway.
"""
import sys
from pathlib import Path
from tempfile import TemporaryDirectory
from types import SimpleNamespace
import uvicorn
from deerflow_extension_api.auth import EXTENSION_PRINCIPAL_RESOLVER_KEY, ExtensionPrincipal
from fastapi import FastAPI, Request
from langchain_core.messages import AIMessage
from langgraph.graph import END, START, MessagesState, StateGraph
from langgraph.prebuilt import ToolNode
from app.gateway.routers.plugins import router
from deerflow.extensions.loader import ExtensionSpec, load_extensions
from deerflow.extensions.plugin_tools import build_plugin_tools
def create_app(directory):
sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "examples/deerflow-extension-bookmarks"))
extensions, diagnostics = load_extensions([ExtensionSpec(use="deerflow_extension_bookmarks:install", config={"enabled": True, "storage_path": str(Path(directory) / "bookmarks.sqlite")}, required=True)])
assert not diagnostics
app = FastAPI()
app.state.extensions = extensions
@app.middleware("http")
async def identity(request, call_next):
request.state.user = SimpleNamespace(id=request.headers.get("x-test-user", "alice"), system_role="admin" if request.headers.get("x-test-role") == "admin" else "user")
request.state.auth_source = "session"
return await call_next(request)
setattr(app.state, EXTENSION_PRINCIPAL_RESOLVER_KEY, lambda request: ExtensionPrincipal(request.state.user.id, is_admin=request.state.user.system_role == "admin"))
app.include_router(router)
@app.post("/test/model-search")
async def model_search(request: Request):
(tool,) = build_plugin_tools(extensions)
graph = StateGraph(MessagesState)
graph.add_node("tools", ToolNode([tool]))
graph.add_edge(START, "tools")
graph.add_edge("tools", END)
body = await request.json()
result = await graph.compile().ainvoke({"messages": [AIMessage(content="", tool_calls=[{"id": "example", "name": tool.name, "args": {"query": body["query"]}}])]}, context={"user_id": request.state.user.id})
return {"tool": tool.name, "content": result["messages"][-1].content}
return app
if __name__ == "__main__":
with TemporaryDirectory(prefix="deerflow-bookmark-preview-") as directory:
uvicorn.run(create_app(directory), host="127.0.0.1", port=int(sys.argv[1]), log_level="warning")

View File

@ -45,6 +45,7 @@ from deerflow_extension_api.placement import (
MiddlewarePlacement, MiddlewarePlacement,
Placement, Placement,
) )
from deerflow_extension_api.plugins import ActionContext, BackendAction, BrowserModule, ModelTool, PluginContribution, ToolContext
from deerflow_extension_api.provenance import ( from deerflow_extension_api.provenance import (
MESSAGE_CONTENT_KIND_KEY, MESSAGE_CONTENT_KIND_KEY,
MESSAGE_PRODUCER_ENTITY_ID_KEY, MESSAGE_PRODUCER_ENTITY_ID_KEY,
@ -73,13 +74,21 @@ from deerflow_extension_api.runtime_bridge import (
EXTENSION_TASK_STORE_KEY, EXTENSION_TASK_STORE_KEY,
task_store_from_runtime, task_store_from_runtime,
) )
from deerflow_extension_api.settings import SettingsField
from deerflow_extension_api.state import ExtensionData from deerflow_extension_api.state import ExtensionData
#: Contract version. Before 1.0, minors may break and patches are additive. #: Contract version. Before 1.0, minors may break and patches are additive.
#: From 1.0 on, bump the major for breaking changes. #: From 1.0 on, bump the major for breaking changes.
API_VERSION = "0.2.1" API_VERSION = "0.2.2"
__all__ = [ __all__ = [
"ActionContext",
"BackendAction",
"BrowserModule",
"ModelTool",
"PluginContribution",
"ToolContext",
"SettingsField",
"API_VERSION", "API_VERSION",
"EXTENSION_PRINCIPAL_RESOLVER_KEY", "EXTENSION_PRINCIPAL_RESOLVER_KEY",
"EXTENSION_TASK_STORE_KEY", "EXTENSION_TASK_STORE_KEY",

View File

@ -14,6 +14,7 @@ from dataclasses import dataclass, field
from enum import StrEnum from enum import StrEnum
from typing import TYPE_CHECKING, Any, Literal, Protocol, TypeVar, runtime_checkable from typing import TYPE_CHECKING, Any, Literal, Protocol, TypeVar, runtime_checkable
from deerflow_extension_api.plugins import PluginContribution
from deerflow_extension_api.state import ExtensionData from deerflow_extension_api.state import ExtensionData
if TYPE_CHECKING: # pragma: no cover - typing only if TYPE_CHECKING: # pragma: no cover - typing only
@ -189,6 +190,10 @@ class ExtensionRegistry(Protocol):
(attribution, positional rollback, build) that is deliberately absent here. (attribution, positional rollback, build) that is deliberately absent here.
""" """
def plugin(self, contribution: PluginContribution) -> bool:
"""Return True when accepted; False means this host lacks plugin UI support."""
return False
def middlewares(self, contributor: MiddlewareContributor) -> None: def middlewares(self, contributor: MiddlewareContributor) -> None:
return None return None

View File

@ -0,0 +1,98 @@
"""Unified, optional browser/backend contributions from one trusted package.
Backend actions run in the Gateway process. The host supplies current settings
and an authenticated principal for each admitted call; this is not a sandbox.
"""
from __future__ import annotations
from collections.abc import Awaitable, Callable, Mapping
from dataclasses import dataclass
from typing import Any
from deerflow_extension_api.auth import ExtensionPrincipal
from deerflow_extension_api.settings import FrontendBinding, SettingsContribution, SettingsField, SettingValue
@dataclass(frozen=True)
class ActionContext:
principal: ExtensionPrincipal
settings: Mapping[str, SettingValue]
@dataclass(frozen=True)
class BackendAction:
name: str
handler: Callable[[Mapping[str, Any], ActionContext], Awaitable[Any]]
@dataclass(frozen=True)
class ToolContext(ActionContext):
"""Host-bound identity for a model tool call.
Resource ownership remains the plugin provider's responsibility,
using principal.user_id.
"""
thread_id: str | None
@dataclass(frozen=True)
class ModelTool:
name: str
description: str
input_schema: Mapping[str, Any]
handler: Callable[[Mapping[str, Any], ToolContext], Awaitable[Any]]
group: str = "extensions"
@dataclass(frozen=True)
class BrowserModule:
"""Experimental single-file browser transport, not the final asset package API.
A future versioned packaged-asset transport will coexist with this inline
form; see docs/full-stack-plugins.md for the compatibility direction.
"""
module: str
code: str
public_fields: tuple[str, ...] = ()
def __post_init__(self) -> None:
object.__setattr__(self, "public_fields", tuple(self.public_fields))
@dataclass(frozen=True)
class PluginContribution:
"""One identity, one enabled switch, optional settings and implementations.
The host owns the boolean ``enabled`` field. Other fields are non-secret
settings, private to the backend unless explicitly projected by BrowserModule.
Supply at least one browser module, backend action, or model tool. Backend implementations
are installed through the existing operator-controlled Python loader.
"""
namespace: str
title: str
description: str = ""
enabled: bool = False
fields: tuple[SettingsField, ...] = ()
frontend: BrowserModule | None = None
backend: tuple[BackendAction, ...] = ()
api_version: int = 1
tools: tuple[ModelTool, ...] = ()
def __post_init__(self) -> None:
object.__setattr__(self, "fields", tuple(self.fields))
object.__setattr__(self, "backend", tuple(self.backend))
object.__setattr__(self, "tools", tuple(self.tools))
def settings_contribution(self) -> SettingsContribution:
return SettingsContribution(
namespace=self.namespace,
title=self.title,
description=self.description,
fields=(SettingsField("enabled", "启用 / Enabled", "boolean", self.enabled), *self.fields),
applies="request-and-page-load" if (self.backend or self.tools) and self.frontend else "next-request" if self.backend or self.tools else "page-load",
frontend=FrontendBinding(self.frontend.module, ("enabled", *self.frontend.public_fields)) if self.frontend else None,
)

View File

@ -0,0 +1,53 @@
"""Declarative, non-secret deployment parameters for plugin contributions.
Values are supplied by the deployment-installed plugin; no online editing.
Settings are not a code-loading API or a secret store.
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Literal
SettingValue = bool | int | str
@dataclass(frozen=True)
class FrontendBinding:
"""Bind settings to a trusted browser module loaded at page startup.
Module identifiers are names, never remote script URLs. Only explicitly
listed non-secret fields are projected to authenticated browser clients.
"""
module: str
public_fields: tuple[str, ...]
def __post_init__(self) -> None:
object.__setattr__(self, "public_fields", tuple(self.public_fields))
@dataclass(frozen=True)
class SettingsField:
key: str
title: str
kind: Literal["boolean", "integer", "string"]
default: SettingValue
description: str = ""
minimum: int | None = None
maximum: int | None = None
max_length: int = 256
@dataclass(frozen=True)
class SettingsContribution:
namespace: str
title: str
fields: tuple[SettingsField, ...]
description: str = ""
scope: Literal["deployment"] = "deployment"
applies: Literal["next-run", "page-load", "next-request", "request-and-page-load"] = "next-run"
frontend: FrontendBinding | None = None
def __post_init__(self) -> None:
object.__setattr__(self, "fields", tuple(self.fields))

View File

@ -1,6 +1,6 @@
[project] [project]
name = "deerflow-extension-api" name = "deerflow-extension-api"
version = "0.2.1" version = "0.2.2"
description = "Public contracts for DeerFlow extensions" description = "Public contracts for DeerFlow extensions"
requires-python = ">=3.12" requires-python = ">=3.12"
# Keep the contract package import-light and independent from the host. Public # Keep the contract package import-light and independent from the host. Public

View File

@ -0,0 +1,50 @@
"""Validation of deployment-owned plugin configuration; no online overrides."""
import re
from typing import Any
from deerflow_extension_api.settings import FrontendBinding, SettingsContribution, SettingsField
def validate_value(field: SettingsField, value: Any) -> None:
expected = {"boolean": bool, "integer": int, "string": str}.get(field.kind)
if expected is None or type(value) is not expected:
raise ValueError(f"{field.key}: expected {field.kind}")
if field.kind == "integer" and ((field.minimum is not None and value < field.minimum) or (field.maximum is not None and value > field.maximum)):
raise ValueError(f"{field.key}: outside allowed range")
if field.kind == "string" and len(value) > field.max_length:
raise ValueError(f"{field.key}: maximum length is {field.max_length}")
def validate_contribution(item: SettingsContribution) -> None:
if not isinstance(item, SettingsContribution) or not re.fullmatch(r"[a-z][a-z0-9_.-]{0,95}", item.namespace):
raise ValueError("Invalid settings contribution or namespace")
if item.scope != "deployment" or item.applies not in {"next-run", "page-load", "next-request", "request-and-page-load"}:
raise ValueError("Unsupported settings scope or application boundary")
if not item.title or not 1 <= len(item.fields) <= 32:
raise ValueError("Settings require a title and between 1 and 32 fields")
seen = set()
for field in item.fields:
if not isinstance(field, SettingsField) or not re.fullmatch(r"[a-z][a-z0-9_]{0,63}", field.key) or field.key in seen:
raise ValueError("Invalid or duplicate settings field")
seen.add(field.key)
if not 1 <= field.max_length <= 4096:
raise ValueError("Invalid string length bound")
if field.minimum is not None and field.maximum is not None and field.minimum > field.maximum:
raise ValueError("Invalid numeric bounds")
validate_value(field, field.default)
if item.frontend is not None:
binding = item.frontend
if not isinstance(binding, FrontendBinding) or not re.fullmatch(r"[a-z][a-z0-9.-]{0,95}", binding.module):
raise ValueError("Invalid frontend module identifier")
if len(set(binding.public_fields)) != len(binding.public_fields) or not set(binding.public_fields) <= seen:
raise ValueError("Frontend fields must reference unique declared settings")
enabled = next((field for field in item.fields if field.key == "enabled"), None)
if enabled is None or enabled.kind != "boolean" or "enabled" not in binding.public_fields:
raise ValueError("Frontend extensions require a public boolean enabled field")
if item.applies not in {"page-load", "request-and-page-load"}:
raise ValueError("Frontend extensions use page-load settings")
def defaults(item: SettingsContribution) -> dict[str, Any]:
return {field.key: field.default for field in item.fields}

View File

@ -377,3 +377,14 @@ that the current host silently ignores.
`test_extension_manager.py` creates temporary Git repositories for local extension sources. `test_extension_manager.py` creates temporary Git repositories for local extension sources.
Temporary commits use an empty repository-local hook directory. They must not run developer or CI Git hooks. Temporary commits use an empty repository-local hook directory. They must not run developer or CI Git hooks.
Tests for hook behavior must create and invoke their own hook fixtures. Tests for hook behavior must create and invoke their own hook fixtures.
## Full-stack contributions
`registry.plugin(PluginContribution(...))` registers optional browser code, backend actions
and model tools under one deployment-owned namespace. The public method defaults to False
for older hosts; accepted contributions share source attribution and positional rollback.
`plugins.py` in Gateway serves descriptors, hashed JS assets and authenticated action calls.
No online settings write API is added. `plugin_tools.py` joins normal tool assembly with
the run's extension snapshot; task delegation passes that snapshot explicitly. Browser
public-field projection is an allowlist. Package code is trusted, not sandboxed. See
`docs/full-stack-plugins.md` and the independently packaged bookmark example.

View File

@ -0,0 +1,113 @@
"""Unified plugins participate in the ordinary tool assembly and authorization path."""
from __future__ import annotations
import asyncio
import hashlib
import json
import logging
import re
from collections.abc import Mapping
from copy import deepcopy
from types import MappingProxyType
from deerflow_extension_api.auth import ExtensionPrincipal
from deerflow_extension_api.plugins import ToolContext
from jsonschema import Draft202012Validator
from langchain.tools import ToolRuntime
from langchain_core.tools import StructuredTool, ToolException
from deerflow.config.plugin_settings import defaults
from deerflow.runtime.user_context import resolve_runtime_user_id
logger = logging.getLogger(__name__)
def plugin_settings(source, plugin):
"""Unified plugins use their deployment-owned manifest; no runtime override."""
return defaults(plugin.settings_contribution())
def validate_schema(schema, *, tool=False):
try:
plain = json.loads(json.dumps(schema))
if plain.get("type") != "object":
raise ValueError("Plugin schemas must describe objects")
# No reference resolution or network access during validation.
def check(value):
if isinstance(value, dict):
if any(key in value for key in ("$ref", "$dynamicRef", "$recursiveRef")):
raise ValueError("Inline plugin schemas are required")
for child in value.values():
check(child)
elif isinstance(value, list):
for child in value:
check(child)
check(plain)
if tool and {"runtime", "config"} & plain.get("properties", {}).keys():
raise ValueError("Reserved tool argument")
Draft202012Validator.check_schema(plain)
except Exception as exc:
raise ValueError("Invalid plugin object schema") from exc
def plugin_tool_name(namespace, name):
digest = hashlib.sha256(f"{namespace}:{name}".encode()).hexdigest()[:12]
return f"ext_{re.sub('[^a-z0-9_]', '_', namespace)[:20]}_{name[:25]}_{digest}"
def _build_tool(source, plugin, declaration):
schema = deepcopy(dict(declaration.input_schema))
validator = Draft202012Validator(schema)
async def invoke(runtime: ToolRuntime, **payload):
try:
settings = await asyncio.to_thread(plugin_settings, source, plugin)
if settings["enabled"] is not True:
raise ToolException("Plugin disabled by administrator.")
if len(json.dumps(payload, allow_nan=False).encode()) > 256 * 1024 or not validator.is_valid(payload):
raise ToolException("Invalid plugin tool input.")
context = runtime.context if isinstance(runtime.context, Mapping) else {}
tool_context = ToolContext(
ExtensionPrincipal(resolve_runtime_user_id(runtime)),
MappingProxyType(settings),
context.get("thread_id"),
)
async with asyncio.timeout(30):
result = await declaration.handler(MappingProxyType(payload), tool_context)
encoded = json.dumps(result, ensure_ascii=False, allow_nan=False)
if len(encoded.encode()) > 64 * 1024:
raise ToolException("Plugin result exceeds 64 KiB.")
return encoded
except ToolException:
raise
except Exception as exc:
logger.warning("Plugin tool failed: %s/%s (%s)", plugin.namespace, declaration.name, type(exc).__name__)
raise ToolException("Plugin tool unavailable or input rejected.") from None
return StructuredTool(name=plugin_tool_name(plugin.namespace, declaration.name), description=declaration.description, args_schema=schema, coroutine=invoke, handle_tool_error=True)
def build_plugin_tools(loaded, *, groups=None, reserved_names=()):
tools = []
names = set(reserved_names)
for source, plugin in loaded.plugins:
if not plugin.tools:
continue
try:
if plugin_settings(source, plugin)["enabled"] is not True:
continue
except Exception as exc:
logger.warning("Plugin policy unavailable: %s (%s)", plugin.namespace, type(exc).__name__)
continue
for declaration in plugin.tools:
if groups is not None and declaration.group not in groups:
continue
tool = _build_tool(source, plugin, declaration)
if tool.name in names:
raise ValueError(f"Plugin tool name collision: {tool.name}")
names.add(tool.name)
tools.append(tool)
return tools

View File

@ -7,6 +7,8 @@ runtime projection.
from __future__ import annotations from __future__ import annotations
import inspect
import re
from collections.abc import Iterator, Sequence from collections.abc import Iterator, Sequence
from contextlib import contextmanager from contextlib import contextmanager
from dataclasses import dataclass from dataclasses import dataclass
@ -22,6 +24,7 @@ from deerflow_extension_api import (
TaskLifecycleContributor, TaskLifecycleContributor,
) )
from deerflow_extension_api import ExtensionRegistry as ExtensionRegistryContract from deerflow_extension_api import ExtensionRegistry as ExtensionRegistryContract
from deerflow_extension_api.plugins import PluginContribution
_Entry = tuple[str, Any] _Entry = tuple[str, Any]
@ -50,6 +53,7 @@ class LoadedExtensions:
context_compaction_observers: tuple[tuple[str, ContextCompactionObserver], ...] = () context_compaction_observers: tuple[tuple[str, ContextCompactionObserver], ...] = ()
services: tuple[tuple[str, ExtensionService], ...] = () services: tuple[tuple[str, ExtensionService], ...] = ()
routers: tuple[tuple[str, Any], ...] = () routers: tuple[tuple[str, Any], ...] = ()
plugins: tuple[tuple[str, PluginContribution], ...] = ()
# Precomputed attributes, not methods: hook sites read one attribute to # Precomputed attributes, not methods: hook sites read one attribute to
# short-circuit, so the zero-extension path constructs nothing. # short-circuit, so the zero-extension path constructs nothing.
@ -77,6 +81,7 @@ class ExtensionRegistry(ExtensionRegistryContract):
self._context_compaction_observers: list[_Entry] = [] self._context_compaction_observers: list[_Entry] = []
self._services: list[_Entry] = [] self._services: list[_Entry] = []
self._routers: list[_Entry] = [] self._routers: list[_Entry] = []
self._plugins: list[_Entry] = []
self._current_source: str | None = None self._current_source: str | None = None
@contextmanager @contextmanager
@ -94,6 +99,38 @@ class ExtensionRegistry(ExtensionRegistryContract):
raise RuntimeError("registration must happen inside ExtensionRegistry.attributed_to(...)") raise RuntimeError("registration must happen inside ExtensionRegistry.attributed_to(...)")
return self._current_source return self._current_source
def plugin(self, contribution: PluginContribution) -> bool:
if not isinstance(contribution, PluginContribution) or contribution.api_version != 1:
raise ValueError("Unsupported plugin contract")
if not contribution.frontend and not contribution.backend and not contribution.tools:
raise ValueError("A plugin must contribute a browser module, backend action or tool")
from deerflow.extensions.plugin_tools import validate_schema
tool_names = set()
for tool in contribution.tools:
if not re.fullmatch(r"[a-z][a-z0-9_]{0,63}", tool.name) or tool.name in tool_names or not tool.description or not inspect.iscoroutinefunction(tool.handler):
raise ValueError("Model tools require unique names, descriptions and async handlers")
validate_schema(tool.input_schema, tool=True)
tool_names.add(tool.name)
names = set()
for action in contribution.backend:
if not re.fullmatch(r"[a-z][a-z0-9_-]{0,63}", action.name) or action.name in names or not inspect.iscoroutinefunction(action.handler):
raise ValueError("Backend actions require unique names and async handlers")
names.add(action.name)
if contribution.frontend and (not contribution.frontend.code or len(contribution.frontend.code.encode()) > 512 * 1024):
raise ValueError("Browser code must be nonempty and at most 512 KiB")
# Validate everything before writing the plugin bucket; loader rollback also
# covers a later failure elsewhere in this package's install function.
from deerflow.config.plugin_settings import validate_contribution
validate_contribution(contribution.settings_contribution())
if any(item.namespace == contribution.namespace for _, item in self._plugins):
raise ValueError("Duplicate plugin namespace")
if contribution.frontend and any(item.frontend and item.frontend.module == contribution.frontend.module for _, item in self._plugins):
raise ValueError("Duplicate browser module")
self._plugins.append((self._source(), contribution))
return True
def middlewares(self, contributor: MiddlewareContributor) -> None: def middlewares(self, contributor: MiddlewareContributor) -> None:
self._middlewares.append((self._source(), contributor)) self._middlewares.append((self._source(), contributor))
@ -137,10 +174,11 @@ class ExtensionRegistry(ExtensionRegistryContract):
self._context_compaction_observers, self._context_compaction_observers,
self._services, self._services,
self._routers, self._routers,
self._plugins,
): ):
bucket[:] = [entry for entry in bucket if entry[0] != source] bucket[:] = [entry for entry in bucket if entry[0] != source]
def mark(self) -> tuple[int, int, int, int, int, int, int]: def mark(self) -> tuple[int, ...]:
"""Snapshot bucket lengths so one install() can be undone positionally.""" """Snapshot bucket lengths so one install() can be undone positionally."""
return ( return (
len(self._middlewares), len(self._middlewares),
@ -150,9 +188,10 @@ class ExtensionRegistry(ExtensionRegistryContract):
len(self._context_compaction_observers), len(self._context_compaction_observers),
len(self._services), len(self._services),
len(self._routers), len(self._routers),
len(self._plugins),
) )
def rollback_to(self, mark: tuple[int, int, int, int, int, int, int]) -> None: def rollback_to(self, mark: tuple[int, ...]) -> None:
"""Undo every registration made since ``mark``. """Undo every registration made since ``mark``.
Positional rather than source-keyed: two specs may legitimately share Positional rather than source-keyed: two specs may legitimately share
@ -168,6 +207,7 @@ class ExtensionRegistry(ExtensionRegistryContract):
self._context_compaction_observers, self._context_compaction_observers,
self._services, self._services,
self._routers, self._routers,
self._plugins,
), ),
mark, mark,
strict=True, strict=True,
@ -184,6 +224,7 @@ class ExtensionRegistry(ExtensionRegistryContract):
context_compaction_observers=tuple(self._context_compaction_observers), context_compaction_observers=tuple(self._context_compaction_observers),
services=tuple(self._services), services=tuple(self._services),
routers=tuple(self._routers), routers=tuple(self._routers),
plugins=tuple(self._plugins),
has_middleware_contributors=bool(self._middlewares), has_middleware_contributors=bool(self._middlewares),
has_task_lifecycle=bool(self._task_lifecycle), has_task_lifecycle=bool(self._task_lifecycle),
has_system_model_observers=bool(self._system_model_observers), has_system_model_observers=bool(self._system_model_observers),

View File

@ -10,6 +10,7 @@ from typing import Any
from deerflow.config.app_config import AppConfig, get_app_config from deerflow.config.app_config import AppConfig, get_app_config
from deerflow.config.subagent_batches_config import SubagentBatchesConfig from deerflow.config.subagent_batches_config import SubagentBatchesConfig
from deerflow.config.subagent_runtime_config import SubagentRuntimeConfig from deerflow.config.subagent_runtime_config import SubagentRuntimeConfig
from deerflow.extensions import LoadedExtensions, get_loaded_extensions
from deerflow.subagents.batch_acceptance import check_batch_acceptance from deerflow.subagents.batch_acceptance import check_batch_acceptance
from deerflow.subagents.batch_runtime import BatchSubmitRequest from deerflow.subagents.batch_runtime import BatchSubmitRequest
from deerflow.subagents.capacity import SubagentExecutionCapacity from deerflow.subagents.capacity import SubagentExecutionCapacity
@ -47,12 +48,16 @@ class SubagentBatchService:
runtime_config: SubagentRuntimeConfig, runtime_config: SubagentRuntimeConfig,
app_config: AppConfig | None = None, app_config: AppConfig | None = None,
execution_capacity: SubagentExecutionCapacity | None = None, execution_capacity: SubagentExecutionCapacity | None = None,
extensions: LoadedExtensions | None = None,
) -> None: ) -> None:
self._repository = repository self._repository = repository
self._config = config self._config = config
self._runtime_config = runtime_config self._runtime_config = runtime_config
self._app_config = app_config self._app_config = app_config
self._execution_capacity = execution_capacity self._execution_capacity = execution_capacity
# One worker owns one generation, including recovered durable items.
# Never persist this Python object in the serializable execution_spec.
self._extensions = extensions if extensions is not None else get_loaded_extensions()
self._lease_owner = f"{socket.gethostname()}:{uuid.uuid4().hex}" self._lease_owner = f"{socket.gethostname()}:{uuid.uuid4().hex}"
self._stop = asyncio.Event() self._stop = asyncio.Event()
self._poller: asyncio.Task[None] | None = None self._poller: asyncio.Task[None] | None = None
@ -206,6 +211,7 @@ class SubagentBatchService:
subagent_enabled=False, subagent_enabled=False,
include_upload_tool=False, include_upload_tool=False,
app_config=app_config, app_config=app_config,
extensions=self._extensions,
) )
# Revalidate durable state before launching: cancel_batch may have # Revalidate durable state before launching: cancel_batch may have
# terminalized this item (or its lease may have been lost) while # terminalized this item (or its lease may have been lost) while
@ -240,6 +246,7 @@ class SubagentBatchService:
authz_attributes=spec.get("authz_attributes"), authz_attributes=spec.get("authz_attributes"),
knowledge_scope=spec.get("knowledge_scope"), knowledge_scope=spec.get("knowledge_scope"),
execution_capacity=self._execution_capacity, execution_capacity=self._execution_capacity,
extensions=self._extensions,
acceptance_criteria=item.get("acceptance_criteria"), acceptance_criteria=item.get("acceptance_criteria"),
) )
prompt = f"Durable batch item key: {item['item_key']}\nThis item may be retried after a worker crash. Keep side effects idempotent and use the item key as the idempotency identity.\n\n{item['prompt']}" prompt = f"Durable batch item key: {item['item_key']}\nThis item may be retried after a worker crash. Keep side effects idempotent and use the item key as the idempotency identity.\n\n{item['prompt']}"

View File

@ -910,6 +910,8 @@ async def task_tool(
available_tools_kwargs["app_config"] = resolved_app_config available_tools_kwargs["app_config"] = resolved_app_config
# Assemble off-loop: tool assembly may block on MCP cache initialization, # Assemble off-loop: tool assembly may block on MCP cache initialization,
# which must not stall the calling event loop (issue #5172). # which must not stall the calling event loop (issue #5172).
if run_extensions is not None:
available_tools_kwargs["extensions"] = run_extensions
tools = await run_assembly(get_available_tools, **available_tools_kwargs) tools = await run_assembly(get_available_tools, **available_tools_kwargs)
# Create executor # Create executor

View File

@ -111,6 +111,7 @@ def get_available_tools(
include_upload_tool: bool = True, include_upload_tool: bool = True,
include_conversation_reader: bool = False, include_conversation_reader: bool = False,
app_config: AppConfig | None = None, app_config: AppConfig | None = None,
extensions=None,
chat_model: BaseChatModel | None = None, chat_model: BaseChatModel | None = None,
) -> list[BaseTool]: ) -> list[BaseTool]:
"""Get all available tools from config. """Get all available tools from config.
@ -278,7 +279,14 @@ def get_available_tools(
# Deduplicate by tool name — config-loaded tools take priority, followed by # Deduplicate by tool name — config-loaded tools take priority, followed by
# built-ins, MCP tools, and ACP tools. Duplicate names cause the LLM to # built-ins, MCP tools, and ACP tools. Duplicate names cause the LLM to
# receive ambiguous or concatenated function schemas (issue #1803). # receive ambiguous or concatenated function schemas (issue #1803).
all_tools = [_ensure_sync_invocable_tool(t) for t in loaded_tools + builtin_tools + mcp_tools + acp_tools] from deerflow.extensions import get_agent_build_extensions
from deerflow.extensions.plugin_tools import build_plugin_tools
ordinary_tools = loaded_tools + builtin_tools + mcp_tools + acp_tools
# Keep plugin-vs-plugin validation strict. Host/plugin collisions use the
# ordinary-first deduplication below, without dropping unrelated tools.
plugin_tools = build_plugin_tools(extensions if extensions is not None else get_agent_build_extensions(), groups=groups)
all_tools = [_ensure_sync_invocable_tool(t) for t in ordinary_tools + plugin_tools]
seen_names: set[str] = set() seen_names: set[str] = set()
unique_tools: list[BaseTool] = [] unique_tools: list[BaseTool] = []
for t in all_tools: for t in all_tools:

View File

@ -13,10 +13,11 @@ dependencies = [
# the contract version it implements, extensions declare ranges. A range # the contract version it implements, extensions declare ranges. A range
# here would let pip resolve a newer contract package than this harness # here would let pip resolve a newer contract package than this harness
# implements, making newer extensions look supported at runtime. # implements, making newer extensions look supported at runtime.
"deerflow-extension-api==0.2.1", "deerflow-extension-api==0.2.2",
"dotenv>=0.9.9", "dotenv>=0.9.9",
"exa-py>=1.0.0", "exa-py>=1.0.0",
"httpx>=0.28.0", "httpx>=0.28.0",
"jsonschema>=4.26.0",
"kubernetes>=30.0.0", "kubernetes>=30.0.0",
# Lower bound reflects what the lockfile resolves and tests run against # Lower bound reflects what the lockfile resolves and tests run against
# (langgraph 1.2.9 pulls langchain >=1.3 transitively). # (langgraph 1.2.9 pulls langchain >=1.3 transitively).

View File

@ -0,0 +1,118 @@
"""Pin durable worker plugin tools and execution to one app generation."""
from copy import deepcopy
from types import SimpleNamespace
from unittest.mock import AsyncMock
import pytest
from deerflow_extension_api.plugins import ModelTool, PluginContribution
from test_extension_task_store_runtime import _CapturingSubagent
from test_extension_task_store_runtime import _subagent_env as _subagent_env
from deerflow.config.subagent_batches_config import SubagentBatchesConfig
from deerflow.config.subagent_runtime_config import SubagentRuntimeConfig
from deerflow.extensions import get_loaded_extensions, set_loaded_extensions
from deerflow.extensions.plugin_tools import plugin_tool_name
from deerflow.extensions.registry import ExtensionRegistry
from deerflow.subagents import batch_service as service_module
from deerflow.tools import tools as assembly
def _generation(name):
async def identify(payload, context):
return name
registry = ExtensionRegistry()
with registry.attributed_to("probe"):
registry.plugin(PluginContribution(namespace=f"probe.{name}", title=name, enabled=True, tools=(ModelTool("identify", "Identify generation", {"type": "object", "properties": {}, "additionalProperties": False}, identify),)))
return registry.build()
@pytest.mark.asyncio
@pytest.mark.parametrize("replacement,explicit_snapshot,late_replace,empty", [(False, False, False, False), (True, False, False, False), (True, True, False, False), (False, False, True, False), (True, True, True, True)])
async def test_batch_keeps_its_construction_snapshot(monkeypatch, _subagent_env, replacement, explicit_snapshot, late_replace, empty):
original = get_loaded_extensions()
a, b = _generation("alpha"), _generation("beta")
if empty:
a = ExtensionRegistry().build()
monkeypatch.setattr("deerflow.extensions._loaded", a)
if explicit_snapshot:
set_loaded_extensions(b)
config = SimpleNamespace(tools=[], models=[], acp_agents={}, get_model_config=lambda name: None, authorization=SimpleNamespace(enabled=False), tool_search=SimpleNamespace(enabled=False))
seen = {}
captured = {}
repo = SimpleNamespace(renew_item_lease=AsyncMock(return_value={"valid": True}), finalize_item=AsyncMock(return_value=True))
service = service_module.SubagentBatchService(repository=repo, config=SubagentBatchesConfig(), runtime_config=SubagentRuntimeConfig(), app_config=config, **({"extensions": a} if explicit_snapshot else {}))
item = {
"id": "item-1",
"item_key": "key-1",
"prompt": "identify",
"batch": {
"id": "batch-1",
"thread_id": "thread-1",
"user_id": "user-1",
"run_id": None,
"execution_spec": {"subagent_config": {"name": "researcher", "description": "d", "system_prompt": "p"}, "parent_model": "model-a", "mcp_plugins": [], "tool_groups": ["extensions"]},
},
}
real_assembly = assembly.get_available_tools
monkeypatch.setattr(assembly, "BUILTIN_TOOLS", [])
monkeypatch.setattr(assembly, "is_host_bash_allowed", lambda config: False)
monkeypatch.setattr(assembly, "is_mcp_task_runtime_available", lambda: False)
monkeypatch.setattr("deerflow.mcp.cache.get_cached_mcp_tools", lambda: [])
monkeypatch.setattr(service_module, "resolve_subagent_model_name", lambda *args, **kwargs: "model-a")
def assemble(**kwargs):
kwargs["include_mcp"] = False
tools = real_assembly(**kwargs)
captured["tool_names"] = [t.name for t in tools]
return tools
monkeypatch.setattr("deerflow.tools.get_available_tools", assemble)
class CaptureExecutor:
def __init__(self, **kwargs):
captured["executor_kwargs"] = kwargs
def execute_async(self, prompt, task_id=None):
return "exec-1"
monkeypatch.setattr(service_module, "SubagentExecutor", CaptureExecutor)
monkeypatch.setattr(service_module, "SubagentStatus", _subagent_env.SubagentStatus)
monkeypatch.setattr(service_module, "get_background_task_result", lambda _: SimpleNamespace(status=service_module.SubagentStatus.COMPLETED, result="done", error=None, stop_reason=None, token_usage_records=None))
monkeypatch.setattr(service_module, "cleanup_background_task", lambda _: None)
async def initial(self, task):
return ({}, self.tools, None)
def create(self, tools, *, deferred_setup=None, extensions=None):
seen["extensions"] = extensions
return _CapturingSubagent(seen)
monkeypatch.setattr(_subagent_env.SubagentExecutor, "_build_initial_state", initial)
monkeypatch.setattr(_subagent_env.SubagentExecutor, "_create_agent", create)
try:
set_loaded_extensions(b if replacement else a)
await service._execute_item(item)
assert repo.finalize_item.await_args.kwargs["succeeded"]
if late_replace:
set_loaded_extensions(b)
executor = _subagent_env.SubagentExecutor(**captured["executor_kwargs"])
result = await executor._aexecute("identify")
assert result.status == _subagent_env.SubagentStatus.COMPLETED, result.error
assert captured["tool_names"] == ([] if empty else [plugin_tool_name("probe.alpha", "identify")])
assert seen["extensions"] is a
# Replay the same durable record under a new worker after a restart.
# The record carries no Python snapshot; the new worker owns generation B.
original_spec = deepcopy(item["batch"]["execution_spec"])
restarted = service_module.SubagentBatchService(repository=repo, config=SubagentBatchesConfig(), runtime_config=SubagentRuntimeConfig(), app_config=config, extensions=b)
set_loaded_extensions(a)
await restarted._execute_item(item)
executor = _subagent_env.SubagentExecutor(**captured["executor_kwargs"])
result = await executor._aexecute("identify")
assert result.status == _subagent_env.SubagentStatus.COMPLETED, result.error
assert seen["extensions"] is b
assert captured["tool_names"] == [plugin_tool_name("probe.beta", "identify")]
assert item["batch"]["execution_spec"] == original_spec
finally:
set_loaded_extensions(original)

View File

@ -0,0 +1,84 @@
"""Standalone Pi-inspired plugin: persistence, ownership and real tool dispatch."""
import asyncio
import json
from pathlib import Path
import pytest
from deerflow_extension_api.auth import ExtensionPrincipal
from deerflow_extension_api.plugins import ActionContext
from langchain_core.messages import AIMessage
from langgraph.graph import END, START, MessagesState, StateGraph
from langgraph.prebuilt import ToolNode
from deerflow.extensions.loader import ExtensionSpec, load_extensions
from deerflow.extensions.plugin_tools import build_plugin_tools
@pytest.fixture
def bookmarks(tmp_path, monkeypatch):
monkeypatch.syspath_prepend(str(Path(__file__).resolve().parents[2] / "examples/deerflow-extension-bookmarks"))
def load():
loaded, diagnostics = load_extensions([ExtensionSpec(use="deerflow_extension_bookmarks:install", config={"enabled": True, "storage_path": str(tmp_path / "bookmarks.sqlite")})])
assert not diagnostics
return loaded
loaded = load()
((_, plugin),) = loaded.plugins
actions = {action.name: action.handler for action in plugin.backend}
return loaded, actions, load
def user(name):
return ActionContext(ExtensionPrincipal(name), {"enabled": True})
@pytest.mark.asyncio
async def test_save_search_rename_delete_are_owner_scoped_and_persisted(bookmarks):
_, actions, reload_plugin = bookmarks
payload = {"thread_id": "thread-1", "message_id": "answer-1", "label": "Release notes", "text": "ORCHID launches on Friday."}
saved = await actions["save"](payload, user("alice"))
# Retrying save does not create duplicate bookmarks.
assert (await actions["save"](payload, user("alice")))["id"] == saved["id"]
assert (await actions["search"]({"query": "orchid"}, user("alice")))["items"][0]["text"] == payload["text"]
assert (await actions["search"]({"query": ""}, user("bob")))["items"] == []
for action, args in [("rename", {"id": saved["id"], "label": "Stolen"}), ("delete", {"id": saved["id"]})]:
with pytest.raises(ValueError):
await actions[action](args, user("bob"))
await actions["rename"]({"id": saved["id"], "label": "Launch plan"}, user("alice"))
((_, plugin),) = reload_plugin().plugins
search = next(a.handler for a in plugin.backend if a.name == "search")
assert (await search({"query": "Launch plan"}, user("alice")))["items"][0]["label"] == "Launch plan"
await actions["delete"]({"id": saved["id"]}, user("alice"))
assert (await search({"query": ""}, user("alice")))["items"] == []
@pytest.mark.asyncio
async def test_concurrent_duplicate_save_and_payload_identity_rejection(bookmarks):
_, actions, _ = bookmarks
payload = {"thread_id": "t", "message_id": "m", "label": "One", "text": "Hello"}
values = await asyncio.gather(*(actions["save"](payload, user("alice")) for _ in range(6)))
assert len({v["id"] for v in values}) == 1
with pytest.raises(ValueError):
await actions["save"]({**payload, "user_id": "bob"}, user("alice"))
with pytest.raises(ValueError):
await actions["save"]({**payload, "text": "x" * 12001}, user("alice"))
# Query is literal text, not SQL wildcard syntax.
assert (await actions["search"]({"query": "%"}, user("alice")))["items"] == []
@pytest.mark.asyncio
async def test_model_tool_reads_only_authenticated_users_bookmarks(bookmarks):
loaded, actions, _ = bookmarks
await actions["save"]({"thread_id": "t", "message_id": "m", "label": "Release", "text": "ORCHID Friday"}, user("alice"))
(tool,) = build_plugin_tools(loaded)
assert "user_id" not in tool.tool_call_schema.get("properties", {})
graph = StateGraph(MessagesState)
graph.add_node("tools", ToolNode([tool]))
graph.add_edge(START, "tools")
graph.add_edge("tools", END)
runtime = graph.compile()
for owner, count in [("alice", 1), ("bob", 0)]:
result = await runtime.ainvoke({"messages": [AIMessage(content="", tool_calls=[{"name": tool.name, "args": {"query": "ORCHID"}, "id": "c"}])]}, context={"user_id": owner})
assert len(json.loads(result["messages"][-1].content)["items"]) == count

View File

@ -342,7 +342,7 @@ def test_runtime_api_version_matches_the_installed_contract_package():
"""Every additive contract slice bumps both gates together.""" """Every additive contract slice bumps both gates together."""
from importlib.metadata import version from importlib.metadata import version
assert API_VERSION == "0.2.1" assert API_VERSION == "0.2.2"
assert API_VERSION == version("deerflow-extension-api") assert API_VERSION == version("deerflow-extension-api")

View File

@ -36,6 +36,13 @@ def test_every_contract_kind_added_for_the_0_2_series_is_exported():
import deerflow_extension_api as api import deerflow_extension_api as api
for name in ( for name in (
"ActionContext",
"BackendAction",
"BrowserModule",
"ModelTool",
"PluginContribution",
"SettingsField",
"ToolContext",
"AgentAssemblyDescriptor", "AgentAssemblyDescriptor",
"AgentAssemblyObserver", "AgentAssemblyObserver",
"CompactionEvent", "CompactionEvent",
@ -68,5 +75,6 @@ def test_registry_protocol_declares_every_registration_method():
"context_compaction_observer", "context_compaction_observer",
"service", "service",
"routers", "routers",
"plugin",
): ):
assert hasattr(ExtensionRegistry, method), f"ExtensionRegistry must declare {method}()" assert hasattr(ExtensionRegistry, method), f"ExtensionRegistry must declare {method}()"

View File

@ -142,6 +142,12 @@ _MOCKED_SUBAGENT_MODULES = (
@pytest.fixture @pytest.fixture
def _subagent_env(): def _subagent_env():
"""Import the real executor behind tests/conftest.py's cycle-breaking mock.""" """Import the real executor behind tests/conftest.py's cycle-breaking mock."""
# Load the real leaf before replacing its parent package with a cycle-breaking
# mock; otherwise isolated execution depends on earlier test collection.
import importlib
importlib.import_module("deerflow.agents.middlewares.audit_context")
importlib.import_module("deerflow.authz.principal")
original_modules = {name: sys.modules.get(name) for name in _MOCKED_SUBAGENT_MODULES} original_modules = {name: sys.modules.get(name) for name in _MOCKED_SUBAGENT_MODULES}
original_executor = sys.modules.get("deerflow.subagents.executor") original_executor = sys.modules.get("deerflow.subagents.executor")
missing = object() missing = object()

View File

@ -525,3 +525,46 @@ def test_lifespan_preserves_flush_budget_when_retrieval_warm_is_still_running()
assert shutdown_elapsed < 1.0 assert shutdown_elapsed < 1.0
manager.shutdown_flush.assert_called_once_with(5.0) manager.shutdown_flush.assert_called_once_with(5.0)
manager.close.assert_not_called() manager.close.assert_not_called()
@pytest.mark.asyncio
async def test_lifespan_pins_batch_service_to_app_extensions(monkeypatch):
import deerflow.extensions as extensions
from app.gateway.app import lifespan
from deerflow.config.subagent_batches_config import SubagentBatchesConfig
from deerflow.config.subagent_runtime_config import SubagentRuntimeConfig
from deerflow.extensions.registry import ExtensionRegistry
app = FastAPI()
snapshot = ExtensionRegistry().build()
app.state.extensions = snapshot
monkeypatch.setattr(extensions, "_loaded", ExtensionRegistry().build())
startup_config = MagicMock()
startup_config.log_level = "INFO"
startup_config.memory.enabled = False
startup_config.scheduler.enabled = False
startup_config.mcp_tasks.enabled = False
startup_config.subagent_batches = SubagentBatchesConfig(enabled=True)
startup_config.subagent_runtime = SubagentRuntimeConfig()
channel_service = MagicMock()
channel_service.get_status.return_value = {}
@asynccontextmanager
async def runtime(app, _config):
app.state.subagent_batch_repo = object()
yield
with (
patch("app.gateway.app.get_app_config", return_value=startup_config),
patch("app.gateway.app.get_gateway_config", return_value=MagicMock(host="x", port=0)),
patch("app.gateway.app.langgraph_runtime", runtime),
patch("app.gateway.app.auth.close_oidc_service", AsyncMock()),
patch("app.channels.service.start_channel_service", AsyncMock(return_value=channel_service)),
patch("app.channels.service.stop_channel_service", AsyncMock()),
patch("deerflow.skills.projection.ensure_public_skill_projection"),
patch("deerflow.agents.memory.get_memory_manager", return_value=MagicMock()),
patch("deerflow.subagents.batch_service.SubagentBatchService.start", AsyncMock()),
patch("deerflow.subagents.batch_service.SubagentBatchService.stop", AsyncMock()),
):
async with lifespan(app):
assert app.state.subagent_batch_service._extensions is snapshot

View File

@ -0,0 +1,152 @@
"""One installation owns its settings, browser artifact and gated backend actions."""
from dataclasses import replace
from types import SimpleNamespace
import pytest
from deerflow_extension_api.auth import EXTENSION_PRINCIPAL_RESOLVER_KEY, ExtensionPrincipal
from deerflow_extension_api.plugins import BackendAction, BrowserModule, PluginContribution
from deerflow_extension_api.settings import SettingsField
from fastapi import FastAPI
from fastapi.testclient import TestClient
from app.gateway.routers.plugins import router
from deerflow.extensions.registry import ExtensionRegistry
@pytest.fixture
def plugin_client():
calls = []
async def check(payload, context):
calls.append((payload, context))
return {"length": len(payload["text"]), "limit": context.settings["limit"]}
plugin = PluginContribution(
namespace="community.check",
title="Check",
fields=(SettingsField("limit", "Limit", "integer", 20, minimum=1, maximum=100),),
frontend=BrowserModule("check.v1", 'export default {apiVersion:1,module:"check.v1"};'),
backend=(BackendAction("check", check),),
)
registry = ExtensionRegistry()
with registry.attributed_to("test:install"):
assert registry.plugin(plugin) is True
app = FastAPI()
app.state.extensions = registry.build()
role = {"value": "admin"}
@app.middleware("http")
async def identity(request, call_next):
request.state.user = SimpleNamespace(id="user-1", system_role=role["value"])
request.state.auth_source = "session"
return await call_next(request)
setattr(app.state, EXTENSION_PRINCIPAL_RESOLVER_KEY, lambda request: ExtensionPrincipal("user-1", is_admin=role["value"] == "admin"))
app.include_router(router)
with TestClient(app) as http:
yield http, role, calls, plugin
def test_one_card_and_deployment_owned_switch(plugin_client):
http, role, calls, plugin = plugin_client
(page,) = http.get("/api/plugins").json()
assert page["namespace"] == "community.check"
assert page["settings"] == {"enabled": False}
asset = http.get(page["entry"])
assert asset.text.startswith("export default")
assert asset.headers["x-content-type-options"] == "nosniff"
assert asset.headers["content-type"].startswith("text/javascript")
assert "limit" not in page["settings"]
url = "/api/plugins/community.check"
assert http.patch(url, json={"changes": {"enabled": True}}).status_code == 404
assert http.post(url + "/reset", json={}).status_code == 404
assert http.post(url + "/actions/check", json={"text": "hello"}).status_code == 403
assert not calls
registry = ExtensionRegistry()
with registry.attributed_to("test:install"):
registry.plugin(replace(plugin, enabled=True))
http.app.state.extensions = registry.build()
role["value"] = "member"
assert http.post(url + "/actions/check", json={"text": "hello"}).json() == {"length": 5, "limit": 20}
assert calls[0][1].principal.user_id == "user-1"
assert http.post(url + "/actions/check", json={"text": "old-account"}, headers={"x-deerflow-plugin-viewer": "other-account"}).status_code == 409
with pytest.raises(TypeError):
calls[0][1].settings["enabled"] = False
def test_backend_only_and_frontend_only_share_registration_and_rollback(plugin_client):
_, _, _, plugin = plugin_client
registry = ExtensionRegistry()
with registry.attributed_to("example"):
registry.plugin(replace(plugin, namespace="community.backend", frontend=None))
mark = registry.mark()
registry.plugin(replace(plugin, namespace="community.frontend", backend=()))
assert len(registry.build().plugins) == 2
registry.rollback_to(mark)
assert len(registry.build().plugins) == 1
before = registry.mark()
with pytest.raises(ValueError):
registry.plugin(replace(plugin, namespace="community.invalid", backend=(plugin.backend[0], plugin.backend[0])))
assert registry.mark() == before
registry.discard("example")
assert not registry.build().plugins
def test_unauthenticated_backend_call_fails_closed(plugin_client):
http, _, calls, _ = plugin_client
setattr(http.app.state, EXTENSION_PRINCIPAL_RESOLVER_KEY, lambda request: None)
assert http.post("/api/plugins/community.check/actions/check", json={}).status_code == 401
assert http.get("/api/plugins").status_code == 401
assert http.get("/api/plugins/modules/check.v1/" + "a" * 64 + ".mjs").status_code == 401
assert not calls
def test_input_limits_and_handler_errors_do_not_leak_details(plugin_client):
http, _, calls, plugin = plugin_client
async def fail(payload, context):
raise RuntimeError("private-provider-secret")
registry = ExtensionRegistry()
with registry.attributed_to("example"):
registry.plugin(replace(plugin, enabled=True, backend=(BackendAction("fail", fail),)))
http.app.state.extensions = registry.build()
url = "/api/plugins/community.check/actions/fail"
assert http.post(url, content="bad-json").status_code == 422
assert http.post(url, json=[]).status_code == 422
assert http.post(url, json={"text": "x" * (256 * 1024)}).status_code == 413
failure = http.post(url, json={})
assert failure.status_code == 502
assert "private-provider-secret" not in failure.text
assert not calls
def test_backend_only_plugin_is_visible_without_loading_a_browser_module(plugin_client):
http, role, _, plugin = plugin_client
registry = ExtensionRegistry()
with registry.attributed_to("example"):
registry.plugin(replace(plugin, frontend=None, fields=(), enabled=True))
http.app.state.extensions = registry.build()
role["value"] = "member"
(item,) = http.get("/api/plugins").json()
assert item["module"] is None and item["entry"] is None
assert item["backend_actions"] == ["check"]
assert item["settings"] == {"enabled": True}
def test_conflicting_manifest_cannot_half_register(plugin_client):
_, _, _, plugin = plugin_client
registry = ExtensionRegistry()
with registry.attributed_to("example"):
registry.plugin(plugin)
mark = registry.mark()
for invalid in (
plugin,
replace(plugin, namespace="community.other"),
replace(plugin, namespace="community.invalid", frontend=None, backend=()),
replace(plugin, namespace="community.invalid", fields=(SettingsField("enabled", "Override", "boolean", True),)),
):
with pytest.raises(ValueError):
registry.plugin(invalid)
assert registry.mark() == mark

View File

@ -0,0 +1,125 @@
"""Exercise actual LangGraph tool dispatch, not just manifest serialization."""
import json
from dataclasses import replace
from types import SimpleNamespace
import pytest
from deerflow_extension_api.plugins import ModelTool, PluginContribution
from langchain_core.messages import AIMessage
from langgraph.graph import END, START, MessagesState, StateGraph
from langgraph.prebuilt import ToolNode
from deerflow.extensions.plugin_tools import build_plugin_tools
from deerflow.extensions.registry import ExtensionRegistry
@pytest.fixture
def installed():
calls = []
async def search(payload, context):
calls.append(context)
return {"query": payload["query"], "user": context.principal.user_id}
plugin = PluginContribution(
namespace="community.search",
title="Search",
enabled=True,
tools=(ModelTool("search", "Search selected documents", {"type": "object", "properties": {"query": {"type": "string"}}, "required": ["query"], "additionalProperties": False}, search),),
)
registry = ExtensionRegistry()
with registry.attributed_to("test"):
registry.plugin(plugin)
return registry.build(), plugin, calls
@pytest.mark.asyncio
async def test_real_tool_node_binds_identity_and_deployment_policy(installed):
loaded, plugin, calls = installed
(tool,) = build_plugin_tools(loaded)
assert "runtime" not in json.dumps(tool.tool_call_schema)
graph = StateGraph(MessagesState)
graph.add_node("tools", ToolNode([tool]))
graph.add_edge(START, "tools")
graph.add_edge("tools", END)
agent = graph.compile()
async def invoke(args):
return (
await agent.ainvoke(
{"messages": [AIMessage(content="", tool_calls=[{"id": "c", "name": tool.name, "args": args}])]},
context={"user_id": "trusted-user", "thread_id": "thread-a"},
)
)["messages"][-1]
result = await invoke({"query": "hello"})
assert json.loads(result.content) == {"query": "hello", "user": "trusted-user"}
assert calls[0].thread_id == "thread-a"
with pytest.raises(TypeError):
calls[0].settings["enabled"] = False
assert (await invoke({"query": "x", "user_id": "victim"})).status == "error"
assert len(calls) == 1
registry = ExtensionRegistry()
with registry.attributed_to("test"):
registry.plugin(replace(plugin, enabled=False))
assert build_plugin_tools(registry.build()) == []
def test_registration_rejects_invalid_schema_without_partial_install(installed):
_, plugin, _ = installed
registry = ExtensionRegistry()
with registry.attributed_to("test"):
for schema in ({"type": "string"}, {"type": "object", "$ref": "https://example.test/schema"}, {"type": "object", "properties": {"runtime": {"type": "string"}}}):
with pytest.raises(ValueError):
registry.plugin(replace(plugin, tools=(replace(plugin.tools[0], input_schema=schema),)))
assert not registry.build().plugins
def test_group_filter_and_name_collision_fail_closed(installed):
loaded, _, _ = installed
assert build_plugin_tools(loaded, groups=["web"]) == []
(tool,) = build_plugin_tools(loaded, groups=["extensions"])
with pytest.raises(ValueError, match="collision"):
build_plugin_tools(loaded, reserved_names={tool.name})
@pytest.mark.parametrize("source", ["config", "builtin", "mcp", "acp"])
def test_assembly_keeps_ordinary_and_unaffected_plugin_tools_on_collision(installed, monkeypatch, caplog, source):
from langchain_core.tools import Tool
from deerflow.config.extensions_config import ExtensionsConfig
from deerflow.extensions.plugin_tools import plugin_tool_name
from deerflow.tools import tools as assembly
loaded, plugin, _ = installed
name = plugin_tool_name(plugin.namespace, "search")
ordinary = Tool(name=name, description="Ordinary tool", func=lambda query: "ordinary result")
healthy = replace(plugin, namespace="community.healthy")
registry = ExtensionRegistry()
with registry.attributed_to("test"):
registry.plugin(plugin)
registry.plugin(healthy)
config = SimpleNamespace(
tools=[SimpleNamespace(name=name, use="test:ordinary", group="extensions")] if source == "config" else [],
models=[],
acp_agents={"test": {}} if source == "acp" else {},
)
monkeypatch.setattr(assembly, "BUILTIN_TOOLS", [ordinary] if source == "builtin" else [])
monkeypatch.setattr(assembly, "is_mcp_task_runtime_available", lambda: False)
monkeypatch.setattr(assembly, "is_host_bash_allowed", lambda config: False)
monkeypatch.setattr(assembly, "resolve_variable", lambda *args: ordinary)
monkeypatch.setattr(ExtensionsConfig, "from_file", lambda: SimpleNamespace(get_enabled_mcp_servers=lambda: {"test": {}}))
monkeypatch.setattr("deerflow.mcp.cache.get_cached_mcp_tools", lambda: [ordinary])
monkeypatch.setattr("deerflow.tools.builtins.invoke_acp_agent_tool.build_invoke_acp_agent_tool", lambda agents: ordinary)
result = assembly.get_available_tools(app_config=config, extensions=registry.build(), include_mcp=source == "mcp", include_upload_tool=False)
assert [tool.name for tool in result] == [name, plugin_tool_name(healthy.namespace, "search")]
assert result[0] is ordinary
assert result[0].invoke("hello") == "ordinary result"
assert "Duplicate tool name" in caplog.text
# A host collision must not weaken the strict plugin-vs-plugin check.
duplicate_snapshot = replace(loaded, plugins=loaded.plugins + loaded.plugins)
with pytest.raises(ValueError, match="collision"):
assembly.get_available_tools(app_config=config, extensions=duplicate_snapshot, include_mcp=False, include_upload_tool=False)

View File

@ -437,12 +437,15 @@ def test_task_tool_forwards_the_run_extension_snapshot_to_executor(monkeypatch):
) )
monkeypatch.setattr(task_tool_module, "get_stream_writer", lambda: lambda _event: None) monkeypatch.setattr(task_tool_module, "get_stream_writer", lambda: lambda _event: None)
monkeypatch.setattr(task_tool_module.asyncio, "sleep", _no_sleep) monkeypatch.setattr(task_tool_module.asyncio, "sleep", _no_sleep)
monkeypatch.setattr("deerflow.tools.get_available_tools", lambda **kwargs: []) assemble_tools = MagicMock(return_value=[])
monkeypatch.setattr("deerflow.tools.get_available_tools", assemble_tools)
_run_task_tool(runtime=runtime, description="test", prompt="p", subagent_type="general-purpose", tool_call_id="tc-ext") _run_task_tool(runtime=runtime, description="test", prompt="p", subagent_type="general-purpose", tool_call_id="tc-ext")
assert captured["executor_kwargs"]["extensions"] is loaded assert captured["executor_kwargs"]["extensions"] is loaded
assert assemble_tools.call_args.kwargs["extensions"] is loaded
def test_task_tool_installs_and_closes_narrow_middleware_recorder(monkeypatch): def test_task_tool_installs_and_closes_narrow_middleware_recorder(monkeypatch):
journal = MagicMock() journal = MagicMock()
@ -504,12 +507,15 @@ def test_task_tool_omits_extensions_without_a_run_snapshot(monkeypatch):
) )
monkeypatch.setattr(task_tool_module, "get_stream_writer", lambda: lambda _event: None) monkeypatch.setattr(task_tool_module, "get_stream_writer", lambda: lambda _event: None)
monkeypatch.setattr(task_tool_module.asyncio, "sleep", _no_sleep) monkeypatch.setattr(task_tool_module.asyncio, "sleep", _no_sleep)
monkeypatch.setattr("deerflow.tools.get_available_tools", lambda **kwargs: []) assemble_tools = MagicMock(return_value=[])
monkeypatch.setattr("deerflow.tools.get_available_tools", assemble_tools)
_run_task_tool(runtime=runtime, description="test", prompt="p", subagent_type="general-purpose", tool_call_id="tc-no-ext") _run_task_tool(runtime=runtime, description="test", prompt="p", subagent_type="general-purpose", tool_call_id="tc-no-ext")
assert "extensions" not in captured["executor_kwargs"] assert "extensions" not in captured["executor_kwargs"]
assert "extensions" not in assemble_tools.call_args.kwargs
def test_bound_task_tool_forwards_explicit_execution_capacity(monkeypatch): def test_bound_task_tool_forwards_explicit_execution_capacity(monkeypatch):
runtime = _make_runtime() runtime = _make_runtime()

4
backend/uv.lock generated
View File

@ -918,7 +918,7 @@ extensions = []
[[package]] [[package]]
name = "deerflow-extension-api" name = "deerflow-extension-api"
version = "0.2.1" version = "0.2.2"
source = { editable = "packages/extension-api" } source = { editable = "packages/extension-api" }
[[package]] [[package]]
@ -942,6 +942,7 @@ dependencies = [
{ name = "firecrawl-py" }, { name = "firecrawl-py" },
{ name = "html5lib" }, { name = "html5lib" },
{ name = "httpx" }, { name = "httpx" },
{ name = "jsonschema" },
{ name = "kubernetes" }, { name = "kubernetes" },
{ name = "langchain" }, { name = "langchain" },
{ name = "langchain-anthropic" }, { name = "langchain-anthropic" },
@ -1028,6 +1029,7 @@ requires-dist = [
{ name = "html5lib", specifier = ">=1.1" }, { name = "html5lib", specifier = ">=1.1" },
{ name = "httpx", specifier = ">=0.28.0" }, { name = "httpx", specifier = ">=0.28.0" },
{ name = "jieba", marker = "extra == 'memory-zh'", specifier = ">=0.42.1" }, { name = "jieba", marker = "extra == 'memory-zh'", specifier = ">=0.42.1" },
{ name = "jsonschema", specifier = ">=4.26.0" },
{ name = "kubernetes", specifier = ">=30.0.0" }, { name = "kubernetes", specifier = ">=30.0.0" },
{ name = "langchain", specifier = ">=1.3" }, { name = "langchain", specifier = ">=1.3" },
{ name = "langchain-anthropic", specifier = ">=1.4.1" }, { name = "langchain-anthropic", specifier = ">=1.4.1" },

159
docs/full-stack-plugins.md Normal file
View File

@ -0,0 +1,159 @@
# Full-stack plugin contributions
A deployment-installed Python extension can register a `PluginContribution` with
optional browser code, authenticated backend actions and model tools. This extends
the existing `install(registry, config)` workflow. MCP and Skills keep their existing
APIs and lifecycles. Public contracts live in `deerflow_extension_api` (0.2.2).
The browser contribution API in this slice is experimental. `BrowserModule(code=...)`
is an MVP transport for validating page/action host interfaces, not the final asset
packaging contract or a requirement that all future plugins ship one JavaScript file.
## What users see
Capability Center has an **Extensions** tab with read-only information and deployment
status. A plugin can add a conversation action, its own workspace page and an optional
sidebar entry. The [bookmarks example](../examples/deerflow-extension-bookmarks/README.md)
uses all three: save the last visible answer, then search, rename or delete it under
**My bookmarks**. Existing notification and Markdown/JSON export behavior is unchanged.
## Registration and execution
`registry.plugin(...)` returns `True` when accepted. Its default public protocol
implementation returns `False` on a host without support, so packages must check the
result. A declaration needs a unique namespace and at least one browser module,
backend action or tool. Validation happens before registration; install failures use
the existing positional rollback and source attribution.
- `BrowserModule(module, code, public_fields=())` contains a self-contained ES module,
at most 512 KiB. It may export `surfaces` and `conversationActions` with `apiVersion: 1`.
- `BackendAction(name, handler)` declares an async handler receiving a JSON object and
`ActionContext(principal, settings)`. The principal comes from host authentication.
- `ModelTool(name, description, input_schema, handler, group="extensions")` declares
an async handler with a `ToolContext` that additionally carries the thread ID.
Object schemas must be inline: reference resolution is rejected. Tools enter the
ordinary host tool assembly, including group filtering and later authorization.
- `SettingsField` describes a non-secret deployment value. There is no online override
store or settings write API. Browser clients see only `enabled` and explicitly
listed public fields. Plugin code remains responsible for business authorization.
Tool names are namespace-derived and collision-checked. Tool inputs are bounded to
256 KiB, outputs to 64 KiB and execution to 30 seconds. Backend actions accept object
inputs up to 256 KiB and have a 30-second timeout. Cancellation does not guarantee
rollback of external effects or already-running worker-thread operations.
Durable `batch_task` workers pin the Gateway app's extension snapshot at startup
and use it for both plugin tools and subagent execution. Recovered items use the
new worker's snapshot after restart; no Python snapshot is stored in the durable
`execution_spec`. Standalone batch services without an explicit snapshot capture
the process default once at construction.
## Browser API
The authenticated host exposes:
| Route | Purpose |
| ------------------------------------------------ | --------------------------------------------------------------- |
| `GET /api/plugins` | Deployed descriptors, public configuration and declared actions |
| `GET /api/plugins/modules/{module}/{sha256}.mjs` | Installed code with a content revision |
| `POST /api/plugins/{namespace}/actions/{name}` | Invoke one declared backend action |
The host does not accept filesystem paths, import strings or arbitrary remote URLs
from the browser. Asset responses use JavaScript content type, `nosniff` and private
no-store caching. Existing Gateway session/CSRF policies apply; PATs do not gain a new
route allowlist. The browser sends its descriptor's viewer ID so the action route can
reject a stale view after account changes, in addition to normal request authentication.
Module downloads honor `NEXT_PUBLIC_BACKEND_BASE_URL`, including a path prefix,
and use the host's authenticated fetch helper before importing a temporary Blob URL.
The URL is released after import, including on failure. Browser modules must be
self-contained: relative imports and assets resolved against `import.meta.url` are
unsupported. Deployments with a Content Security Policy must allow `blob:` in
`script-src` and the configured backend in `connect-src`; split-origin backends must
allow credentialed CORS from the frontend, as for other host API calls.
A page surface declares `id`, `slot: "page"`, `title`, `mount(root, context)` and
optional `navigation: { label, labelZh?, icon? }`. The host generates the URL
`/workspace/extensions/{namespace}/{id}` and mounts only that registered page.
`mount` returns a synchronous `dispose()` callback. The context includes locale,
public settings, an abort signal and a namespace-bound `callBackend` helper.
The optional `openConversation(threadId)` host helper reads current conversation
metadata through the authenticated API and uses the host's normal/custom-agent
routing rules. Missing or inaccessible conversations reject without navigating;
unmount/account changes abort pending reads and prevent late navigation. Cleanup
aborts outstanding work and fences late callbacks. Plugin async work should observe
the signal and release resources in `dispose()`.
Conversation actions receive a conversation context and host services. The
`latestVisibleAnswer` and `conversationText` services reuse the existing export
sanitizer; sidebar reads go through the authenticated conversation API. Plugin code
must still escape user/model text when rendering it.
Factories and availability callbacks must be synchronous; invalid Promise returns
are rejected and their rejections consumed. The host validates each locale-dependent action group and evaluates availability
inside a per-plugin error boundary. A malformed or throwing contribution is omitted
without removing healthy plugin actions or failing the conversation page. Plugin tool
names that collide with ordinary tools follow the host's ordinary-first deduplication;
unrelated tools remain available. Duplicate names within the plugin tool set still fail
strict validation.
## Packaged assets and compatibility direction
RFC #5510 proposes a manifest plus packaged static resources (`ui_manifest.json` and
`static/dist/...`). That remains the intended direction for larger plugins. The current
single-file transport cannot naturally support relative chunks, separate CSS, images,
fonts, WASM, source maps or `import.meta.url` assets, and holds the module as a Python
string. Its `no-store` response intentionally provides no immutable cache reuse.
A follow-up should add a distinct, versioned packaged-asset declaration alongside the
inline form, rather than silently changing the meaning of `BrowserModule.code`:
- A validated manifest identifies the entry module and permitted files under a
package-owned asset root. The root comes from the installed package, never a browser
supplied filesystem path.
- Namespace/revision-scoped URLs, for example
`/api/plugins/{namespace}/assets/{revision}/{path}`, must confine canonical paths to
that root, reject traversal and escaping symlinks, and serve only manifest-listed
files with correct MIME types and `nosniff`.
- Revisioned assets should support immutable caching. Private assets must retain
authentication and private-cache policy; public/CDN caching needs an explicit public
distribution contract. Cache invalidation and removal semantics must be specified.
- Entry modules and relative dependencies must share an authenticated loading design
for both same-origin and split-origin deployments. The current Blob importer cannot
simply be reused for relative chunks; an authenticated same-origin asset proxy is
one option to evaluate.
- Discovery should negotiate the supported transport/version and reject unsupported
transports clearly. Existing inline v1 packages should keep working while the new
transport reuses the namespace, page/action interfaces and deployment lifecycle.
These are compatibility requirements for the follow-up, not implemented asset APIs.
The stable packaging contract requires review before plugin authors rely on it. Neither
transport should require rebuilding DeerFlow's frontend for each compatible plugin.
## Trust and lifecycle
**Browser and Python plugins are trusted operator-installed code.** Browser modules
run in the main page. Shadow DOM scopes CSS; it is not a security sandbox, and a
plugin can access same-origin browser capabilities. No shared React instance is
promised: plugins mount their own DOM rather than providing a component for the
host React tree. Sandboxed iframes, right-side panels, composer selection and version
negotiation beyond API v1 checks are follow-up designs discussed in #5510 and #5539.
Install, remove, enable/disable or upgrade with the existing deployment/CLI workflow
and restart the service. Python packages must be delivered into the actual execution
environment. Once this host contract is installed, a new compatible plugin does not
require plugin-specific host frontend compilation. Container delivery still needs a
persistent package installation or an image containing the package.
Browsers retain their discovered plugin set until manual refresh; there is no automatic
refresh or hot-unload guarantee. Old UI does not guarantee old backend code is retained
across a service restart. A changed asset revision or removed action fails explicitly
and requires reload. Disabled/unloaded pages have no navigation entry after refresh;
visiting an unavailable page directly does not mount a plugin.
## Validation scope
The bookmark E2E test runs the production frontend against real Python action handlers
and SQLite, with synthetic authentication and scripted LangGraph ToolNode calls.
It covers persistence, owner isolation, read-only deployment management and the full
page workflow. It does not claim live model behavior or a full production deployment.
The example uses single-host storage, not a multi-node persistence contract.

Binary file not shown.

After

Width:  |  Height:  |  Size: 113 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

View File

@ -0,0 +1,102 @@
# Conversation bookmarks — independent plugin example
A Pi-inspired example of a complete feature in one package: save the last visible
assistant answer from a conversation menu, then search, rename, read or delete
bookmarks in its own **My bookmarks** page, reached from the workspace sidebar.
Capability Center only displays the plugin's information and deployment status.
The read-only `search_bookmarks`
model tool searches the authenticated user's saved excerpts. Each user sees only
their own data, including when the Agent calls the tool.
**Requires a host with the full-stack plugin contract and extension-api 0.2.2.** Pi's original labels session entries; this web
adaptation is independently implemented. See [attribution](THIRD_PARTY_NOTICES.md).
## Deployment
Install this directory using the existing trusted Python extension manager, or
build and install its wheel into the Gateway environment. Install the matching
local extension-api/harness packages first. From `backend/`:
```sh
uv run deerflow extensions install ../examples/deerflow-extension-bookmarks --yes
```
Then set the deployment-owned `config` on the registered plugin entry before
restarting Gateway. Example configuration:
```yaml
plugins:
- use: deerflow_extension_bookmarks:install
enabled: true
config:
enabled: true
storage_path: /var/lib/deerflow/bookmarks.sqlite
```
The outer `enabled` controls package loading; `config.enabled` controls its
contribution. Installation, changes to either switch, code upgrades and deployment
configuration require Gateway restart. Mount a persistent writable directory for
`storage_path`. The browser module is packaged in the wheel and served by the
Gateway; no plugin-specific frontend or Docker image compilation is needed when
the compatible host and dependencies are already installed. Python packages must
still be present in the actual Gateway environment.
The extension catalog is **read-only for everyone**, including administrators.
It contains the card and plugin details. There is no online plugin configuration write API or override database. The SQLite
file above stores user bookmarks, not deployment settings.
## Try it
1. Enable the package at deployment, restart Gateway, then refresh the browser.
2. Open a conversation with a visible answer. Select **Bookmarks → Save last answer**
in the conversation menu (also available in the sidebar conversation menu).
3. Open **My bookmarks** from the sidebar. Its direct URL is
`/workspace/extensions/community.bookmarks/library`, and survives refresh.
4. Search text or labels, edit a name, open the source conversation, or confirm deletion.
5. Ask the Agent to find a saved answer. Its normal tool assembly contains a
namespace-qualified `search_bookmarks` tool; the existing tool/group/skill policy
still applies. Agents restricted to other tool groups do not receive it.
## Boundaries
- Answers are user-submitted saved copies, not server-certified transcript records.
Hidden messages, tool output and reasoning are excluded by the host's existing
export sanitizer before the UI submits the visible answer. No remote model or
external service is called while saving/managing bookmarks.
- Original conversation links do not grant access. Normal thread authorization
still applies; deletion of an original thread does not delete a saved copy.
- Up to 200 bookmarks per user, 12,000 characters per answer; searches return at
most 10 excerpts (1,000 characters each) and a total count. Narrow the query for
more matches; the page can load the full saved answer. Duplicate saves are
idempotent and do not overwrite an existing label.
- Storage uses SQLite and blocking I/O is offloaded from the event loop. This is a
single-host example, not a multi-node storage contract or tenant administration UI.
- Plugin browser/Python code is trusted deployment code. Shadow DOM scopes CSS,
not security privileges. Model search is read-only and never accepts a user ID.
- This example does not implement hot unloading or retaining old Python code across
service restarts.
## Host contract exercised
`PluginContribution` packages one `BrowserModule`, five `BackendAction`s and one
`ModelTool`. The module contributes a conversation action and a `page` DOM surface.
The page declares `navigation: { label: "My bookmarks", labelZh: "我的书签", icon: "bookmark" }`.
The host builds the route from the namespace and surface ID and adds the optional
sidebar entry; the plugin cannot claim arbitrary host URLs. Disabled/unloaded pages
have no navigation entry and show an unavailable state on direct visits after reload.
The host loads generic descriptors, supplies the sanitized visible-answer service,
binds authenticated backend calls, and mounts/disposes the page. There is no
bookmark-specific page, router, database table or business branch in host code.
Automated coverage: `backend/tests/test_bookmark_plugin.py`,
`frontend/tests/e2e/bookmark-plugin.spec.ts`, plus shared API and lifecycle tests.
Browser E2E uses a real Python plugin/router and SQLite with synthetic authentication;
its model-call fixture uses real LangGraph ToolNode dispatch with a scripted call,
not a live language model or the complete production Gateway.
Returning to a conversation uses the host's `openConversation(threadId)` helper,
which resolves its current agent from authenticated thread metadata. This also
repairs navigation for existing bookmarks without migrating the SQLite schema.
A missing/inaccessible conversation shows an error instead of opening the default
agent. Hosts without this optional navigation helper cannot reopen conversations
from this version of the example.

View File

@ -0,0 +1,32 @@
# Reference and license
This is an original Python/browser adaptation inspired by Pi's `bookmark.ts`.
Pi marks the last assistant entry with a label for its session tree. This example
adds a web library, per-user SQLite persistence and a read-only model search tool;
it is not a drop-in Pi extension and does not embed Pi's runtime.
Reference: https://github.com/earendil-works/pi/blob/36b60d2e8985899743c4cf5bd5f8929832a3f05d/packages/coding-agent/examples/extensions/bookmark.ts
Upstream license retained for attribution:
MIT License
Copyright (c) 2025 Mario Zechner
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@ -0,0 +1,184 @@
"""Pi bookmark idea adapted to a multi-user web host; no host-internal imports."""
import asyncio
import sqlite3
import uuid
from contextlib import closing
from pathlib import Path
from deerflow_extension_api import extension
from deerflow_extension_api.plugins import (
BackendAction,
BrowserModule,
ModelTool,
PluginContribution,
)
def text(payload, key, maximum, *, empty=False):
value = payload.get(key)
if (
not isinstance(value, str)
or len(value) > maximum
or (not empty and not value.strip())
):
raise ValueError("Invalid bookmark field")
return value.strip()
class Bookmarks:
"""Plugin-owned business data, not a host configuration override store."""
def __init__(self, path):
self.path = Path(path)
if not self.path.is_absolute():
raise ValueError("storage_path must be an absolute deployment-owned path")
self.path.parent.mkdir(parents=True, exist_ok=True)
with closing(self.connect()) as db, db:
db.execute("""CREATE TABLE IF NOT EXISTS bookmarks (
id TEXT PRIMARY KEY, owner TEXT NOT NULL, thread_id TEXT NOT NULL,
message_id TEXT NOT NULL, label TEXT NOT NULL, text TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE(owner, thread_id, message_id))""")
def connect(self):
db = sqlite3.connect(self.path, timeout=10)
db.row_factory = sqlite3.Row
return db
def apply(self, action, payload, owner):
fields = {
"save": {"thread_id", "message_id", "label", "text"},
"search": {"query"},
"rename": {"id", "label"},
"delete": {"id"},
"get": {"id"},
}
if not owner or set(payload) != fields[action]:
raise ValueError("Invalid bookmark request")
# SQL parameters + mandatory owner predicate on EVERY data operation.
with closing(self.connect()) as db, db:
if action == "save":
thread = text(payload, "thread_id", 128)
message = text(payload, "message_id", 256)
label = text(payload, "label", 120)
content = text(payload, "text", 12000)
db.execute("BEGIN IMMEDIATE")
existing = db.execute(
"SELECT id FROM bookmarks WHERE owner=? AND thread_id=? AND message_id=?",
(owner, thread, message),
).fetchone()
if existing:
return {"id": existing["id"]}
if (
db.execute(
"SELECT count(*) FROM bookmarks WHERE owner=?", (owner,)
).fetchone()[0]
>= 200
):
raise ValueError("Bookmark capacity reached")
identifier = uuid.uuid4().hex
db.execute(
"INSERT INTO bookmarks (id,owner,thread_id,message_id,label,text) VALUES (?,?,?,?,?,?)",
(identifier, owner, thread, message, label, content),
)
return {"id": identifier}
if action == "search":
query = text(payload, "query", 200, empty=True).lower()
rows = db.execute(
"SELECT id,thread_id,message_id,label,text,created_at FROM bookmarks WHERE owner=? ORDER BY created_at DESC, rowid DESC",
(owner,),
).fetchall()
# Bounded collection; Python casefold supports Unicode and treats %/_ literally.
matches = [
dict(row)
for row in rows
if query.casefold()
in (row["label"] + "\n" + row["text"]).casefold()
]
return {
"items": [
{
**row,
"text": row["text"][:1000],
"truncated": len(row["text"]) > 1000,
}
for row in matches[:10]
],
"total": len(matches),
}
identifier = text(payload, "id", 64)
row = db.execute(
"SELECT id,thread_id,message_id,label,text,created_at FROM bookmarks WHERE id=? AND owner=?",
(identifier, owner),
).fetchone()
if row is None:
raise ValueError("Bookmark unavailable")
if action == "get":
return dict(row)
if action == "rename":
db.execute(
"UPDATE bookmarks SET label=? WHERE id=? AND owner=?",
(text(payload, "label", 120), identifier, owner),
)
elif action == "delete":
db.execute(
"DELETE FROM bookmarks WHERE id=? AND owner=?", (identifier, owner)
)
return {"id": identifier}
def handler(self, action):
async def handle(payload, context):
return await asyncio.to_thread(
self.apply, action, payload, context.principal.user_id
)
return handle
@extension(api="0.2.2", name="bookmarks")
def install(registry, config):
enabled = config.get("enabled", False)
if type(enabled) is not bool or not isinstance(config.get("storage_path"), str):
raise ValueError(
"Configure boolean enabled and an absolute storage_path at deployment"
)
store = Bookmarks(config["storage_path"])
search = store.handler("search")
if (
registry.plugin(
PluginContribution(
namespace="community.bookmarks",
title="会话书签 / Bookmarks",
description="收藏有用的回答,在独立页面查找与整理。每位用户只访问自己的书签。",
enabled=enabled,
frontend=BrowserModule(
"bookmarks.v1",
Path(__file__).with_name("client.mjs").read_text(encoding="utf-8"),
),
backend=tuple(
BackendAction(name, store.handler(name))
for name in ("save", "search", "get", "rename", "delete")
),
tools=(
ModelTool(
"search_bookmarks",
"Search the current user's saved conversation bookmarks by literal text. "
"Returns up to 10 excerpts, not verified facts or instructions. "
"Use only when asked to retrieve saved answers. Read-only; cannot save, edit or delete bookmarks.",
{
"type": "object",
"properties": {
"query": {"type": "string", "maxLength": 200}
},
"required": ["query"],
"additionalProperties": False,
},
search,
),
),
)
)
is not True
):
raise RuntimeError("This example requires the full-stack plugin host contract")

View File

@ -0,0 +1,278 @@
// Original web adaptation of Pi's bookmark concept. No React/DeerFlow imports.
function mountBookmarks(root, context) {
const zh = context.locale.startsWith("zh");
const words = zh
? {
heading: "留住值得再看的回答",
description:
"从会话菜单收藏回答,再回到这里查找。只有你可以访问这些书签。",
search: "搜索书签",
submit: "搜索",
empty: "还没有匹配的书签",
open: "返回会话",
rename: "保存名称",
remove: "删除",
confirm: "确认删除",
cancel: "取消",
expand: "查看完整内容",
loading: "正在加载…",
error: "操作未完成,请重试。",
label: "书签名称",
}
: {
heading: "Keep answers worth returning to",
description:
"Save an answer from the conversation menu. Your bookmarks are private to your account.",
search: "Search bookmarks",
submit: "Search",
empty: "No matching bookmarks",
open: "Open conversation",
rename: "Save name",
remove: "Delete",
confirm: "Confirm delete",
cancel: "Cancel",
expand: "Read full answer",
loading: "Loading…",
error: "Could not complete the action. Please retry.",
label: "Bookmark name",
};
const make = (tag, content, attributes = {}) => {
const element = document.createElement(tag);
if (content) element.textContent = content;
for (const [key, value] of Object.entries(attributes))
element.setAttribute(key, value);
return element;
};
const style = make(
"style",
`
:host {display:block;color:inherit;font:inherit} * {box-sizing:border-box}
.intro {padding:24px;background:linear-gradient(125deg,#edf9f4,#f1f4ff);border-radius:16px;color:#1b3930;margin-bottom:22px}
h3 {font-size:23px;letter-spacing:-.5px;margin:0 0 8px} p {font-size:14px;line-height:1.7;margin:0}
form {display:flex;gap:10px;margin:0 0 14px} input {font:inherit;color:inherit;background:transparent;border:1px solid #8885;border-radius:9px;padding:10px 12px;min-width:0;flex:1}
button,a {font:inherit;font-size:13px;cursor:pointer;border:1px solid #8885;border-radius:8px;padding:8px 12px;background:transparent;color:inherit;text-decoration:none}
button:disabled {opacity:.5;cursor:wait} button:hover,a:hover {background:#8881} .search-button {background:#216549;color:white} .search-button:hover {background:#194f39}
article {border:1px solid #8884;border-radius:14px;padding:20px;margin:14px 0} .name {display:flex;gap:8px;flex-wrap:wrap;margin-bottom:15px}
pre {white-space:pre-wrap;overflow-wrap:anywhere;font:inherit;font-size:14px;line-height:1.8;margin:0 0 16px;max-height:320px;overflow:auto}
.actions {display:flex;gap:9px;align-items:center;flex-wrap:wrap} .meta {font-size:12px;opacity:.6;margin-bottom:12px}
[role=status],[role=alert] {font-size:13px;margin:8px 0} [role=alert] {color:#bd4040}
`,
);
const intro = make("div", "", { class: "intro" });
intro.append(make("h3", words.heading), make("p", words.description));
const form = make("form");
const query = make("input", "", {
type: "search",
placeholder: words.search,
"aria-label": words.search,
maxlength: "200",
});
const search = make("button", words.submit, {
type: "submit",
class: "search-button",
});
form.append(query, search);
const status = make("p", "", { role: "status" });
const error = make("p", "", { role: "alert" });
const list = make("div");
root.append(style, intro, form, status, error, list);
let generation = 0;
let disposed = false;
const active = () => !disposed && !context.signal.aborted;
async function run(button, operation) {
button.disabled = true;
error.textContent = "";
try {
await operation();
} catch {
if (active()) error.textContent = words.error;
} finally {
if (active()) button.disabled = false;
}
}
async function load() {
const current = ++generation;
status.textContent = words.loading;
try {
const data = await context.callBackend("search", { query: query.value });
if (!active() || current !== generation) return;
status.textContent = data.total
? zh
? `共 ${data.total} 条 · 显示前 10 条,可搜索缩小范围`
: `${data.total} saved · Showing up to 10; search to narrow results`
: words.empty;
list.replaceChildren();
for (const item of data.items) {
const card = make("article");
const row = make("div", "", { class: "name" });
const label = make("input", "", {
"aria-label": words.label,
maxlength: "120",
});
label.value = item.label;
const rename = make("button", words.rename, { type: "button" });
rename.addEventListener(
"click",
() =>
void run(rename, async () => {
await context.callBackend("rename", {
id: item.id,
label: label.value,
});
await load();
}),
{ signal: context.signal },
);
row.append(label, rename);
const content = make("pre", item.text);
const actions = make("div", "", { class: "actions" });
// The host resolves current routing metadata, including for old bookmarks.
const open = make("button", words.open, { type: "button" });
open.addEventListener(
"click",
() =>
void run(open, async () => {
if (!context.openConversation)
throw new Error("Host conversation navigation is unavailable");
await context.openConversation(item.thread_id);
}),
{ signal: context.signal },
);
actions.append(open);
if (item.truncated) {
const expand = make("button", words.expand, { type: "button" });
expand.addEventListener(
"click",
() =>
void run(expand, async () => {
const answer = await context.callBackend("get", {
id: item.id,
});
if (active()) {
content.textContent = answer.text;
expand.remove();
}
}),
{ signal: context.signal },
);
actions.append(expand);
}
const remove = make("button", words.remove, { type: "button" });
const cancel = make("button", words.cancel, { type: "button" });
cancel.hidden = true;
cancel.addEventListener(
"click",
() => {
remove.textContent = words.remove;
cancel.hidden = true;
},
{ signal: context.signal },
);
remove.addEventListener(
"click",
() => {
if (cancel.hidden) {
remove.textContent = words.confirm;
cancel.hidden = false;
return;
}
void run(remove, async () => {
await context.callBackend("delete", { id: item.id });
await load();
});
},
{ signal: context.signal },
);
actions.append(remove, cancel);
card.append(
row,
make("div", item.created_at, { class: "meta" }),
content,
actions,
);
list.append(card);
}
} catch (cause) {
if (active() && current === generation) status.textContent = "";
throw cause;
}
}
form.addEventListener(
"submit",
(event) => {
event.preventDefault();
void run(search, load);
},
{ signal: context.signal },
);
void run(search, load);
return {
dispose() {
disposed = true;
generation++;
root.replaceChildren();
},
};
}
export default {
apiVersion: 1,
module: "bookmarks.v1",
icon: "bookmark",
surfaces: [
{
id: "library",
slot: "page",
title: "Bookmarks",
navigation: {
label: "My bookmarks",
labelZh: "我的书签",
icon: "bookmark",
},
mount: mountBookmarks,
},
],
conversationActions(_t, locale = "en") {
const zh = locale.startsWith("zh");
return {
label: zh ? "书签" : "Bookmarks",
icon: "bookmark",
actions: [
{
id: "save-answer",
label: zh ? "收藏最后一条回答" : "Save last answer",
icon: "bookmark",
available: (settings) => settings.enabled === true,
async execute(context, services) {
const answer = await services.latestVisibleAnswer(context);
if (!answer) {
services.showMessage(
zh ? "暂无可收藏的回答" : "No visible answer to save",
);
return;
}
if (answer.text.length > 12000) {
services.showMessage(
zh
? "回答超过 12,000 字符,暂不支持收藏。"
: "Answers longer than 12,000 characters cannot be bookmarked yet.",
);
return;
}
await services.callBackend("save", {
thread_id: context.thread.thread_id,
message_id: answer.id,
label: answer.text.replace(/\s+/g, " ").trim().slice(0, 80),
text: answer.text,
});
services.showMessage(
zh
? "已收藏,可从侧边栏打开“我的书签”查看"
: "Saved. Open My bookmarks in the sidebar.",
);
},
},
],
};
},
};

View File

@ -0,0 +1,18 @@
[project]
name = "deerflow-extension-bookmarks"
version = "0.1.0"
description = "Pi-inspired conversation bookmarks for the DeerFlow full-stack plugin API"
requires-python = ">=3.12"
license = "MIT"
license-files = ["THIRD_PARTY_NOTICES.md"]
dependencies = ["deerflow-extension-api>=0.2.2,<0.3"]
[project.entry-points."deerflow.extensions"]
bookmarks = "deerflow_extension_bookmarks:install"
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel]
packages = ["deerflow_extension_bookmarks"]

View File

@ -262,3 +262,24 @@ Draft credentials stay in editor state, never query cache or browser storage; bl
keeps the saved key, explicit removal sends an empty key. Saving invalidates both the keeps the saved key, explicit removal sends an empty key. Saving invalidates both the
admin catalog and `MODELS_QUERY_KEY`. Editor unmount aborts probes and fences late admin catalog and `MODELS_QUERY_KEY`. Editor unmount aborts probes and fences late
callbacks. Static demos and non-admin users must not query the management API. callbacks. Static demos and non-admin users must not query the management API.
## Full-stack plugin UI
`core/extensions/` loads authenticated deployment-installed ES modules from `/api/plugins`.
Module downloads use the configured backend base and authenticated fetch, then import
and release a Blob URL; packages must be self-contained (no relative module/assets).
This inline transport is experimental; packaged-asset compatibility is documented in
`docs/full-stack-plugins.md`. Host copy belongs in the typed locale dictionaries.
Conversation action factories, shapes and availability callbacks are guarded per plugin;
only validated value snapshots reach the toolbar/sidebar render paths.
`PluginNavigation` and the dynamic workspace extension route consume page declarations;
Capability Center details only show metadata and status. Conversation action slots augment
normal/custom-agent toolbars and sidebar menus without replacing native export or notification.
Plugin views use mount/dispose and abort signals; Shadow DOM is CSS isolation, not a sandbox.
Descriptors are user-keyed page snapshots, refreshed manually. Backend calls bind the plugin's
namespace, action allowlist and expected viewer identity. See `docs/full-stack-plugins.md`.
Plugin page `openConversation(threadId)` resolves authenticated thread metadata
with `pathOfThread`; do not let plugins hardcode default-agent routes. The page's
abort signal fences late navigation after unmount/account changes. Synchronous
conversation-action callbacks reject Promise returns while consuming rejections.

View File

@ -30,6 +30,7 @@ import {
} from "@/components/workspace/sidecar"; } from "@/components/workspace/sidecar";
import { ThreadArchiveStatus } from "@/components/workspace/thread-archive-status"; import { ThreadArchiveStatus } from "@/components/workspace/thread-archive-status";
import { ThreadBackgroundTasks } from "@/components/workspace/thread-background-tasks"; import { ThreadBackgroundTasks } from "@/components/workspace/thread-background-tasks";
import { ThreadExtensionActions } from "@/components/workspace/thread-extension-actions";
import { ThreadSubagentBatches } from "@/components/workspace/thread-subagent-batches"; import { ThreadSubagentBatches } from "@/components/workspace/thread-subagent-batches";
import { ThreadTitle } from "@/components/workspace/thread-title"; import { ThreadTitle } from "@/components/workspace/thread-title";
import { TodoList } from "@/components/workspace/todo-list"; import { TodoList } from "@/components/workspace/todo-list";
@ -417,6 +418,7 @@ export default function AgentChatPage() {
<SidecarTrigger /> <SidecarTrigger />
{browserEnabled && <BrowserTrigger />} {browserEnabled && <BrowserTrigger />}
<ExportTrigger threadId={threadId} /> <ExportTrigger threadId={threadId} />
<ThreadExtensionActions threadId={threadId} />
<ArtifactTrigger /> <ArtifactTrigger />
</div> </div>
</header> </header>

View File

@ -0,0 +1,10 @@
import { PluginPage } from "@/components/workspace/plugin-page";
export default async function ExtensionPage({
params,
}: {
params: Promise<{ namespace: string; surface_id: string }>;
}) {
const { namespace, surface_id } = await params;
return <PluginPage namespace={namespace} surfaceId={surface_id} />;
}

View File

@ -9,6 +9,7 @@ import { ModelLoadErrorBanner } from "@/components/workspace/model-load-error-ba
import { SettingsDialogHost } from "@/components/workspace/settings"; import { SettingsDialogHost } from "@/components/workspace/settings";
import { WorkspaceSettingsDeepLink } from "@/components/workspace/workspace-settings-deep-link"; import { WorkspaceSettingsDeepLink } from "@/components/workspace/workspace-settings-deep-link";
import { WorkspaceSidebar } from "@/components/workspace/workspace-sidebar"; import { WorkspaceSidebar } from "@/components/workspace/workspace-sidebar";
import { ExtensionPageBootstrap } from "@/core/extensions/hooks";
import { UserPreferencesBoundary } from "@/core/settings/user-preferences-boundary"; import { UserPreferencesBoundary } from "@/core/settings/user-preferences-boundary";
function parseSidebarOpenCookie( function parseSidebarOpenCookie(
@ -34,6 +35,7 @@ export async function WorkspaceContent({
return ( return (
<QueryClientProvider> <QueryClientProvider>
<UserPreferencesBoundary> <UserPreferencesBoundary>
<ExtensionPageBootstrap />
<SidebarProvider className="h-screen" defaultOpen={initialSidebarOpen}> <SidebarProvider className="h-screen" defaultOpen={initialSidebarOpen}>
<WorkspaceSidebar /> <WorkspaceSidebar />
<SidebarInset className="min-w-0"> <SidebarInset className="min-w-0">

View File

@ -13,6 +13,9 @@ import { useI18n } from "@/core/i18n/hooks";
const PluginGallery = dynamic(() => const PluginGallery = dynamic(() =>
import("./plugin-gallery").then((module) => module.PluginGallery), import("./plugin-gallery").then((module) => module.PluginGallery),
); );
const ExtensionGallery = dynamic(() =>
import("./extension-gallery").then((module) => module.ExtensionGallery),
);
const SkillGallery = dynamic(() => const SkillGallery = dynamic(() =>
import("./skill-gallery").then((module) => module.SkillGallery), import("./skill-gallery").then((module) => module.SkillGallery),
); );
@ -33,7 +36,13 @@ export function CapabilityCenter() {
const params = useSearchParams(); const params = useSearchParams();
const router = useRouter(); const router = useRouter();
const pathname = usePathname(); const pathname = usePathname();
const tab = params.get("tab") === "skills" ? "skills" : "plugins"; const requestedTab = params.get("tab");
const tab =
requestedTab === "skills"
? "skills"
: requestedTab === "extensions"
? "extensions"
: "plugins";
const [query, setQuery] = useState(""); const [query, setQuery] = useState("");
function changeTab(value: string) { function changeTab(value: string) {
setQuery(""); setQuery("");
@ -64,14 +73,18 @@ export function CapabilityCenter() {
disabled={!hydrated} disabled={!hydrated}
className="bg-muted/30 h-10 rounded-xl pl-9 shadow-none" className="bg-muted/30 h-10 rounded-xl pl-9 shadow-none"
aria-label={ aria-label={
tab === "plugins" tab === "extensions"
? t.capabilities.searchPlugins ? t.extensions.search
: t.capabilities.searchSkills : tab === "skills"
? t.capabilities.searchSkills
: t.capabilities.searchPlugins
} }
placeholder={ placeholder={
tab === "plugins" tab === "extensions"
? t.capabilities.searchPlugins ? t.extensions.search
: t.capabilities.searchSkills : tab === "skills"
? t.capabilities.searchSkills
: t.capabilities.searchPlugins
} }
value={query} value={query}
onChange={(event) => setQuery(event.target.value)} onChange={(event) => setQuery(event.target.value)}
@ -82,15 +95,23 @@ export function CapabilityCenter() {
<TabsList variant="line" className="h-12 gap-7"> <TabsList variant="line" className="h-12 gap-7">
<TabsTrigger value="plugins" className="gap-2 px-1 pb-4 text-sm"> <TabsTrigger value="plugins" className="gap-2 px-1 pb-4 text-sm">
<BlocksIcon className="size-4" /> <BlocksIcon className="size-4" />
{t.capabilities.plugins} {t.capabilities.toolsAndIntegrations}
</TabsTrigger> </TabsTrigger>
<TabsTrigger value="skills" className="gap-2 px-1 pb-4 text-sm"> <TabsTrigger value="skills" className="gap-2 px-1 pb-4 text-sm">
<SparklesIcon className="size-4" /> <SparklesIcon className="size-4" />
{t.capabilities.skills} {t.capabilities.skills}
</TabsTrigger> </TabsTrigger>
<TabsTrigger
value="extensions"
className="gap-2 px-1 pb-4 text-sm"
>
{t.extensions.title}
</TabsTrigger>
</TabsList> </TabsList>
</Tabs> </Tabs>
{tab === "plugins" ? ( {tab === "extensions" ? (
<ExtensionGallery query={query} />
) : tab !== "skills" ? (
<PluginGallery query={query} /> <PluginGallery query={query} />
) : ( ) : (
<SkillGallery query={query} /> <SkillGallery query={query} />

View File

@ -0,0 +1,125 @@
"use client";
import { ArrowLeftIcon, ChevronRightIcon } from "lucide-react";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { Button } from "@/components/ui/button";
import { useFrontendExtensions } from "@/core/extensions/hooks";
import { extensionIcon } from "@/core/extensions/registry";
import { useI18n } from "@/core/i18n/hooks";
import { PluginRow } from "./plugin-directory";
export function ExtensionGallery({ query = "" }: { query?: string }) {
const { t } = useI18n();
const publicQuery = useFrontendExtensions();
const params = useSearchParams();
const router = useRouter();
const pathname = usePathname();
const selected = params.get("extension");
const entries = publicQuery.data ?? [];
const source = publicQuery;
function select(namespace?: string) {
const next = new URLSearchParams(params);
next.set("tab", "extensions");
if (namespace) next.set("extension", namespace);
else next.delete("extension");
router.replace(`${pathname}?${next.toString()}`, { scroll: false });
}
if (source.isPending) return <p role="status">{t.extensions.loading}</p>;
if (source.isError)
return (
<div role="alert">
<p>{t.extensions.unavailable}</p>
<Button variant="outline" onClick={() => void source.refetch()}>
{t.extensions.retry}
</Button>
</div>
);
const reload = (
<Button variant="outline" onClick={() => window.location.reload()}>
{t.extensions.reloadAll}
</Button>
);
if (selected) {
const entry = entries.find((item) => item.namespace === selected);
return (
<div className="space-y-6">
{reload}
<Button variant="ghost" onClick={() => select()}>
<ArrowLeftIcon />
{t.extensions.all}
</Button>
{!entry ? (
<p>{t.extensions.notInstalled}</p>
) : (
<div className="max-w-3xl space-y-4">
<h2 className="text-2xl font-semibold">{entry.title}</h2>
<p className="text-muted-foreground">{entry.description}</p>
<p>
{entry.settings.enabled === true
? t.extensions.enabledManaged
: t.extensions.disabledManaged}
</p>
</div>
)}
</div>
);
}
const visible = entries.filter((entry) =>
`${entry.title} ${entry.description}`
.toLowerCase()
.includes(query.trim().toLowerCase()),
);
return (
<div className="space-y-5">
{reload}
<p className="text-muted-foreground text-sm">
{t.extensions.deploymentHint}
</p>
<div className="grid gap-x-10 md:grid-cols-2">
{visible.map((entry) => {
const loaded = publicQuery.data?.find(
(item) => item.namespace === entry.namespace,
);
const Icon = extensionIcon(loaded?.extension?.icon);
return (
<PluginRow
key={entry.namespace}
name={entry.title}
description={entry.description}
icon={
<div className="bg-muted flex size-12 shrink-0 items-center justify-center rounded-xl">
<Icon className="size-6" />
</div>
}
label={
loaded?.error
? t.extensions.moduleUnavailable
: entry.settings.enabled === true
? t.capabilities.enabled
: t.capabilities.disabled
}
onDetails={() => select(entry.namespace)}
detailsLabel={t.extensions.view(entry.title)}
>
<Button
variant="ghost"
size="icon"
aria-label={t.extensions.open(entry.title)}
onClick={() => select(entry.namespace)}
>
<ChevronRightIcon />
</Button>
</PluginRow>
);
})}
</div>
{!visible.length && (
<p role="status" className="text-muted-foreground py-8">
{t.extensions.noResults}
</p>
)}
</div>
);
}

View File

@ -30,6 +30,7 @@ import {
} from "@/components/workspace/sidecar"; } from "@/components/workspace/sidecar";
import { ThreadArchiveStatus } from "@/components/workspace/thread-archive-status"; import { ThreadArchiveStatus } from "@/components/workspace/thread-archive-status";
import { ThreadBackgroundTasks } from "@/components/workspace/thread-background-tasks"; import { ThreadBackgroundTasks } from "@/components/workspace/thread-background-tasks";
import { ThreadExtensionActions } from "@/components/workspace/thread-extension-actions";
import { ThreadScheduledTasksLink } from "@/components/workspace/thread-scheduled-tasks-link"; import { ThreadScheduledTasksLink } from "@/components/workspace/thread-scheduled-tasks-link";
import { ThreadSubagentBatches } from "@/components/workspace/thread-subagent-batches"; import { ThreadSubagentBatches } from "@/components/workspace/thread-subagent-batches";
import { ThreadTitle } from "@/components/workspace/thread-title"; import { ThreadTitle } from "@/components/workspace/thread-title";
@ -494,6 +495,7 @@ export default function ChatPage() {
<SidecarTrigger /> <SidecarTrigger />
{browserEnabled && <BrowserTrigger />} {browserEnabled && <BrowserTrigger />}
<ExportTrigger threadId={threadId} /> <ExportTrigger threadId={threadId} />
<ThreadExtensionActions threadId={threadId} />
<ArtifactTrigger /> <ArtifactTrigger />
</div> </div>
</header> </header>

View File

@ -0,0 +1,121 @@
"use client";
import { useState } from "react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { resolveConversationActions } from "@/core/extensions/actions";
import type {
ConversationAction,
ConversationActionContext,
FrontendContribution,
} from "@/core/extensions/contracts";
import {
useFrontendServices,
useFrontendExtensions,
} from "@/core/extensions/hooks";
import {
extensionIcon,
activeFrontendExtensions,
} from "@/core/extensions/registry";
import { bindFrontendServices } from "@/core/extensions/services";
import { useI18n } from "@/core/i18n/hooks";
import { Tooltip } from "./tooltip";
/** Shared host slot used by the chat toolbar AND every sidebar conversation. */
export function ConversationExtensionActions({
context,
placement = "toolbar",
}: {
context: ConversationActionContext;
placement?: "toolbar" | "menu";
}) {
const { t, locale } = useI18n();
const query = useFrontendExtensions();
const services = useFrontendServices();
const [busy, setBusy] = useState(false);
const entries = query.isError ? [] : (query.data ?? []);
async function execute(
action: ConversationAction,
contribution: FrontendContribution,
) {
setBusy(true);
try {
await action.execute(
context,
bindFrontendServices(services, contribution),
);
} catch {
toast.error(t.extensions.actionFailed);
} finally {
setBusy(false);
}
}
return activeFrontendExtensions(entries).map(
({ contribution, extension }) => {
if (context.messages?.length === 0) return null;
const group = resolveConversationActions(
extension,
contribution,
t,
locale,
);
if (!group) return null;
const actions = group.actions;
const GroupIcon = extensionIcon(group.icon);
const items = actions.map((action) => {
const Icon = extensionIcon(action.icon);
return (
<DropdownMenuItem
key={action.id}
disabled={busy}
onSelect={() => void execute(action, contribution)}
>
<Icon className="text-muted-foreground" />
<span>{action.label}</span>
</DropdownMenuItem>
);
});
if (placement === "menu")
return (
<DropdownMenuSub key={contribution.namespace}>
<DropdownMenuSubTrigger>
<GroupIcon className="text-muted-foreground" />
<span>{group.label}</span>
</DropdownMenuSubTrigger>
<DropdownMenuSubContent>{items}</DropdownMenuSubContent>
</DropdownMenuSub>
);
return (
<DropdownMenu key={contribution.namespace}>
<Tooltip content={group.label}>
<DropdownMenuTrigger asChild>
<Button
aria-label={group.label}
className="text-muted-foreground hover:text-foreground"
variant="ghost"
disabled={busy}
>
<GroupIcon />
<span className="hidden sm:inline">{group.label}</span>
</Button>
</DropdownMenuTrigger>
</Tooltip>
<DropdownMenuContent align="end">{items}</DropdownMenuContent>
</DropdownMenu>
);
},
);
}

View File

@ -0,0 +1,51 @@
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import {
SidebarGroup,
SidebarGroupLabel,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
} from "@/components/ui/sidebar";
import { useFrontendExtensions } from "@/core/extensions/hooks";
import { pluginPages, pluginPageTitle } from "@/core/extensions/pages";
import { extensionIcon } from "@/core/extensions/registry";
import { useI18n } from "@/core/i18n/hooks";
export function PluginNavigation() {
const query = useFrontendExtensions();
const pathname = usePathname();
const { locale, t } = useI18n();
const pages = pluginPages(query.data ?? []).filter(
({ surface }) => surface.navigation,
);
if (!pages.length) return null;
return (
<SidebarGroup>
<SidebarGroupLabel>{t.extensions.navigation}</SidebarGroupLabel>
<SidebarMenu>
{pages.map(({ surface, href, icon }) => {
const Icon = extensionIcon(icon);
const title = pluginPageTitle(surface, locale);
return (
<SidebarMenuItem key={href}>
<SidebarMenuButton
asChild
isActive={pathname === href}
tooltip={title}
>
<Link href={href}>
<Icon />
<span>{title}</span>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
);
})}
</SidebarMenu>
</SidebarGroup>
);
}

View File

@ -0,0 +1,72 @@
"use client";
import Link from "next/link";
import { Button } from "@/components/ui/button";
import { SidebarTrigger } from "@/components/ui/sidebar";
import { useFrontendExtensions } from "@/core/extensions/hooks";
import { pluginPages, pluginPageTitle } from "@/core/extensions/pages";
import { useI18n } from "@/core/i18n/hooks";
import { PluginSurfaces } from "./plugin-surfaces";
export function PluginPage({
namespace,
surfaceId,
}: {
namespace: string;
surfaceId: string;
}) {
const query = useFrontendExtensions();
const { locale, t } = useI18n();
const page = pluginPages(query.data ?? []).find(
({ contribution, surface }) =>
contribution.namespace === namespace && surface.id === surfaceId,
);
const title = page
? pluginPageTitle(page.surface, locale)
: t.extensions.pageUnavailable;
return (
<div className="bg-background flex h-full min-h-0 flex-col">
<div className="text-muted-foreground flex h-14 shrink-0 items-center gap-3 border-b px-4 text-xs md:px-8">
<SidebarTrigger className="md:hidden" />
<span>{t.breadcrumb.workspace}</span>
<span>/</span>
<span>{title}</span>
</div>
<main className="flex-1 overflow-y-auto">
<div className="mx-auto max-w-6xl space-y-6 px-5 py-8 md:px-10">
{query.isPending ? (
<p role="status">{t.extensions.pageLoading}</p>
) : (
<>
<h1 className="text-2xl font-semibold">{title}</h1>
{page ? (
<PluginSurfaces
slot="page"
namespace={namespace}
surfaceId={surfaceId}
/>
) : (
<div className="space-y-4">
<p>{t.extensions.pageUnavailableHint}</p>
<Button
variant="outline"
onClick={() => window.location.reload()}
>
{t.extensions.reload}
</Button>
<Button variant="ghost" asChild>
<Link href="/workspace/capabilities?tab=extensions">
{t.extensions.viewAll}
</Link>
</Button>
</div>
)}
</>
)}
</div>
</main>
</div>
);
}

View File

@ -0,0 +1,106 @@
"use client";
import { useRouter } from "next/navigation";
import { useEffect, useRef, useState } from "react";
import { useAuth } from "@/core/auth/AuthProvider";
import type { PluginSurface, SurfaceSlot } from "@/core/extensions/contracts";
import {
useFrontendExtensions,
useFrontendServices,
} from "@/core/extensions/hooks";
import {
activeFrontendExtensions,
type LoadedContribution,
} from "@/core/extensions/registry";
import {
bindFrontendServices,
openConversation,
} from "@/core/extensions/services";
import { mountSurface } from "@/core/extensions/surfaces";
import { useI18n } from "@/core/i18n/hooks";
function Surface({
entry,
surface,
threadId,
}: {
entry: LoadedContribution;
surface: PluginSurface;
threadId?: string;
}) {
const ref = useRef<HTMLDivElement>(null);
const router = useRouter();
const { locale, t } = useI18n();
const { user } = useAuth();
const services = useFrontendServices();
const currentServices = useRef(services);
currentServices.current = services;
const [failed, setFailed] = useState(false);
useEffect(() => {
if (!ref.current) return;
const abort = new AbortController();
const cleanup = mountSurface(
ref.current,
surface,
{
namespace: entry.namespace,
locale,
settings: entry.settings,
threadId,
openConversation: (id, signal) =>
openConversation(id, (path) => router.push(path), signal),
callBackend: bindFrontendServices(
currentServices.current,
entry,
abort.signal,
).callBackend,
},
() => setFailed(true),
);
return () => {
abort.abort();
cleanup();
};
}, [entry, surface, locale, threadId, user?.id, router]);
return (
<section aria-label={surface.title}>
{failed && <p role="alert">{t.extensions.viewFailed}</p>}
<div ref={ref} />
</section>
);
}
export function PluginSurfaces({
slot,
namespace,
surfaceId,
threadId,
}: {
slot: SurfaceSlot;
namespace?: string;
surfaceId?: string;
threadId?: string;
}) {
const query = useFrontendExtensions();
const { user } = useAuth();
return activeFrontendExtensions(query.data ?? [])
.filter(
({ contribution }) => !namespace || contribution.namespace === namespace,
)
.flatMap(({ contribution: entry, extension }) =>
(extension.surfaces ?? [])
.filter(
(surface) =>
surface.slot === slot && (!surfaceId || surface.id === surfaceId),
)
.map((surface) => (
<Surface
key={`${user?.id}:${threadId}:${entry.namespace}:${surface.id}`}
entry={entry}
surface={surface}
threadId={threadId}
/>
)),
);
}

View File

@ -45,6 +45,7 @@ import {
SidebarMenuButton, SidebarMenuButton,
SidebarMenuItem, SidebarMenuItem,
} from "@/components/ui/sidebar"; } from "@/components/ui/sidebar";
import { ConversationExtensionActions } from "@/components/workspace/conversation-extension-actions";
import { getAPIClient } from "@/core/api"; import { getAPIClient } from "@/core/api";
import { useAuth } from "@/core/auth/AuthProvider"; import { useAuth } from "@/core/auth/AuthProvider";
import { hasPermission, PERMISSIONS } from "@/core/auth/permissions"; import { hasPermission, PERMISSIONS } from "@/core/auth/permissions";
@ -281,6 +282,10 @@ export function ThreadSidebarItem({
side={"right"} side={"right"}
align={"start"} align={"start"}
> >
<ConversationExtensionActions
context={{ thread }}
placement="menu"
/>
<DropdownMenuItem onSelect={handleTogglePin}> <DropdownMenuItem onSelect={handleTogglePin}>
{pinned ? ( {pinned ? (
<PinOff className="text-muted-foreground" /> <PinOff className="text-muted-foreground" />

View File

@ -0,0 +1,20 @@
"use client";
import type { AgentThread } from "@/core/threads/types";
import { ConversationExtensionActions } from "./conversation-extension-actions";
import { useThread } from "./messages/context";
export function ThreadExtensionActions({ threadId }: { threadId: string }) {
const { thread } = useThread();
const agentThread = {
thread_id: threadId,
updated_at: new Date().toISOString(),
values: thread.values,
} as AgentThread;
return (
<ConversationExtensionActions
context={{ thread: agentThread, messages: thread.messages }}
/>
);
}

View File

@ -10,6 +10,7 @@ import {
} from "@/components/ui/sidebar"; } from "@/components/ui/sidebar";
import { WorkspaceChannelsList } from "./channels/workspace-channels-list"; import { WorkspaceChannelsList } from "./channels/workspace-channels-list";
import { PluginNavigation } from "./plugin-navigation";
import { ProjectsSection } from "./projects-section"; import { ProjectsSection } from "./projects-section";
import { RecentChatList } from "./recent-chat-list"; import { RecentChatList } from "./recent-chat-list";
import { ThreadDeleteDialogProvider } from "./thread-delete-dialog"; import { ThreadDeleteDialogProvider } from "./thread-delete-dialog";
@ -29,6 +30,7 @@ export function WorkspaceSidebar({
</SidebarHeader> </SidebarHeader>
<SidebarContent> <SidebarContent>
<WorkspaceNavChatList /> <WorkspaceNavChatList />
<PluginNavigation />
<WorkspaceChannelsList /> <WorkspaceChannelsList />
{isSidebarOpen && ( {isSidebarOpen && (
<> <>

View File

@ -0,0 +1,72 @@
import type { Translations } from "@/core/i18n";
import type {
ConversationAction,
ConversationActionGroup,
FrontendContribution,
FrontendExtension,
} from "./contracts";
/** Evaluate locale-dependent plugin callbacks inside a per-contribution boundary. */
export function resolveConversationActions(
extension: FrontendExtension,
contribution: FrontendContribution,
t: Translations,
locale: string,
): ConversationActionGroup | undefined {
try {
const group = extension.conversationActions?.(t, locale);
if (group == null) return;
const { label, icon, actions } = group;
if (
typeof label !== "string" ||
!label.trim() ||
typeof icon !== "string" ||
!Array.isArray(actions)
) {
// A misdeclared async factory must not leak a rejected Promise.
void Promise.resolve(group).catch(() => undefined);
throw new Error("Invalid conversation action group");
}
const ids = new Set<string>();
const visible: ConversationAction[] = [];
for (const action of actions) {
const { id, label, icon, available, execute } = action;
if (
typeof id !== "string" ||
!id.trim() ||
ids.has(id) ||
typeof label !== "string" ||
!label.trim() ||
typeof icon !== "string" ||
typeof available !== "function" ||
typeof execute !== "function"
)
throw new Error("Invalid conversation action");
ids.add(id);
const enabled = available.call(action, contribution.settings);
if (typeof enabled !== "boolean") {
// Reject async availability while observing any eventual rejection.
void Promise.resolve(enabled).catch(() => undefined);
throw new Error("Invalid action availability");
}
if (enabled)
visible.push({
id,
label,
icon,
available: (settings) => available.call(action, settings),
execute: (context, services) =>
execute.call(action, context, services),
});
}
// Copy validated values so plugin getters are not evaluated again by React.
return visible.length ? { label, icon, actions: visible } : undefined;
} catch (error) {
console.warn(
`Plugin conversation actions unavailable: ${contribution.namespace}`,
error,
);
return undefined;
}
}

View File

@ -0,0 +1,28 @@
import { z } from "zod";
import { fetch } from "@/core/api/fetcher";
import { getBackendBaseURL } from "@/core/config";
const contributions = z.array(
z.object({
namespace: z.string(),
viewer_id: z.string().nullable().optional(),
module: z.string().nullable(),
backend_actions: z.array(z.string()).optional(),
entry: z.string().nullable(),
title: z.string(),
description: z.string(),
settings: z.record(z.union([z.boolean(), z.number(), z.string()])),
}),
);
export const frontendExtensionsQueryKey = ["frontend-extensions"] as const;
export async function fetchFrontendExtensions() {
const response = await fetch(`${getBackendBaseURL()}/api/plugins`, {
cache: "no-store",
});
if (!response.ok)
throw new Error(`Frontend extensions unavailable (${response.status})`);
return contributions.parse(await response.json());
}

View File

@ -0,0 +1,83 @@
import type { Message } from "@langchain/langgraph-sdk";
import type { Translations } from "@/core/i18n";
import type { AgentThread } from "@/core/threads/types";
export type ExtensionSettings = Readonly<
Record<string, boolean | number | string>
>;
export type SurfaceSlot = "page";
export type SurfaceContext = {
namespace: string;
locale: string;
settings: ExtensionSettings;
threadId?: string;
signal: AbortSignal;
callBackend: FrontendServices["callBackend"];
/** Host resolves current thread ownership before navigating; unavailable on older hosts. */
openConversation?: (threadId: string) => Promise<void>;
};
export type PluginSurface = {
id: string;
slot: SurfaceSlot;
title: string;
/** Optional sidebar entry for a page surface; the host owns its URL. */
navigation?: { label: string; labelZh?: string; icon?: string };
/** Mount synchronously; async work must observe context.signal. */
mount: (
root: HTMLElement,
context: SurfaceContext,
) => { dispose: () => void };
};
export type FrontendContribution = {
viewer_id?: string | null;
namespace: string;
module: string | null;
entry: string | null;
title: string;
description: string;
settings: ExtensionSettings;
backend_actions?: string[];
};
export type ConversationActionContext = {
thread: AgentThread;
messages?: Message[];
};
export type FrontendServices = {
callBackend: (
action: string,
payload: Record<string, unknown>,
) => Promise<unknown>;
conversationText: (context: ConversationActionContext) => Promise<string>;
latestVisibleAnswer?: (
context: ConversationActionContext,
) => Promise<{ id: string; text: string } | null>;
showMessage: (message: string) => void;
};
export type ConversationAction = {
id: string;
label: string;
icon: string;
available: (settings: ExtensionSettings) => boolean;
execute: (
context: ConversationActionContext,
services: FrontendServices,
) => Promise<void>;
};
export type ConversationActionGroup = {
label: string;
icon: string;
actions: ConversationAction[];
};
/** Browser package API v1. Modules are installed by trusted deployment operators. */
export interface FrontendExtension {
apiVersion: 1;
module: string;
icon?: string;
surfaces?: PluginSurface[];
conversationActions?: (
t: Translations,
locale?: string,
) => ConversationActionGroup;
}

View File

@ -0,0 +1,37 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { toast } from "sonner";
import { useAuth } from "@/core/auth/AuthProvider";
import { fetchFrontendExtensions, frontendExtensionsQueryKey } from "./api";
import { loadFrontendExtensions } from "./registry";
import { conversationText, latestVisibleAnswer } from "./services";
export function useFrontendExtensions() {
const { user } = useAuth();
return useQuery({
queryKey: [...frontendExtensionsQueryKey, user?.id],
queryFn: async () =>
loadFrontendExtensions(await fetchFrontendExtensions()),
enabled: !!user,
staleTime: Infinity,
gcTime: Infinity,
refetchOnMount: false,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
retry: false,
});
}
/** Eagerly capture the page's plugin set, even before its first chat is opened. */
export function ExtensionPageBootstrap() {
useFrontendExtensions();
return null;
}
export function useFrontendServices() {
return {
conversationText,
latestVisibleAnswer,
showMessage: (message: string) => toast.message(message),
};
}

View File

@ -0,0 +1,29 @@
import type { PluginSurface } from "./contracts";
import { activeFrontendExtensions, type LoadedContribution } from "./registry";
/** Plugins register identifiers, never arbitrary host paths or external links. */
export function pluginPagePath(namespace: string, surfaceId: string) {
return `/workspace/extensions/${encodeURIComponent(namespace)}/${encodeURIComponent(surfaceId)}`;
}
export function pluginPageTitle(surface: PluginSurface, locale: string) {
return (
(locale.startsWith("zh") ? surface.navigation?.labelZh : undefined) ??
surface.navigation?.label ??
surface.title
);
}
export function pluginPages(entries: LoadedContribution[]) {
return activeFrontendExtensions(entries).flatMap(
({ contribution, extension }) =>
(extension.surfaces ?? [])
.filter((surface) => surface.slot === "page")
.map((surface) => ({
contribution,
surface,
href: pluginPagePath(contribution.namespace, surface.id),
icon: surface.navigation?.icon ?? extension.icon,
})),
);
}

View File

@ -0,0 +1,129 @@
import {
BellIcon,
BookmarkIcon,
DownloadIcon,
FileJsonIcon,
FileTextIcon,
PuzzleIcon,
type LucideIcon,
} from "lucide-react";
import { fetch } from "@/core/api/fetcher";
import { getBackendBaseURL } from "@/core/config";
import type { FrontendContribution, FrontendExtension } from "./contracts";
export function extensionIcon(name?: string): LucideIcon {
const icons: Record<string, LucideIcon> = {
bell: BellIcon,
bookmark: BookmarkIcon,
download: DownloadIcon,
"file-json": FileJsonIcon,
"file-text": FileTextIcon,
};
return name && Object.hasOwn(icons, name) ? icons[name]! : PuzzleIcon;
}
export type LoadedContribution = FrontendContribution & {
extension?: FrontendExtension;
error?: string;
};
export type ModuleImporter = (url: string) => Promise<{ default: unknown }>;
const importModule: ModuleImporter = (url) =>
import(/* webpackIgnore: true */ url) as Promise<{ default: unknown }>;
export async function loadFrontendExtensions(
entries: FrontendContribution[],
importer: ModuleImporter = importModule,
): Promise<LoadedContribution[]> {
return Promise.all(
entries.map(async (entry) => {
if (entry.settings.enabled !== true || entry.module === null)
return entry;
try {
// Only fetch installed Gateway assets, using the same base and credentials
// as discovery/actions. Cross-origin import() would omit session cookies.
const expected = `/api/plugins/modules/${entry.module}/`;
if (
!entry.entry?.startsWith(expected) ||
!/^[a-f0-9]{64}\.mjs$/.test(entry.entry.slice(expected.length))
)
throw new Error("Invalid installed module entry");
const response = await fetch(`${getBackendBaseURL()}${entry.entry}`, {
cache: "no-store",
});
if (!response.ok)
throw new Error(`Plugin module unavailable (${response.status})`);
// BrowserModule is a self-contained ES module; it has no relative imports.
const moduleURL = URL.createObjectURL(
new Blob([await response.text()], { type: "text/javascript" }),
);
let loadedModule: FrontendExtension;
try {
loadedModule = (await importer(moduleURL))
.default as FrontendExtension;
} finally {
URL.revokeObjectURL(moduleURL);
}
if (
loadedModule?.apiVersion !== 1 ||
loadedModule.module !== entry.module ||
(loadedModule.conversationActions !== undefined &&
typeof loadedModule.conversationActions !== "function")
)
throw new Error("Incompatible browser extension");
if (loadedModule.surfaces !== undefined) {
const seen = new Set<string>();
if (
!Array.isArray(loadedModule.surfaces) ||
loadedModule.surfaces.length > 16
)
throw new Error("Invalid plugin surfaces");
for (const surface of loadedModule.surfaces) {
if (
!surface ||
!/^[a-z][a-z0-9-]{0,63}$/.test(surface.id) ||
seen.has(surface.id) ||
surface.slot !== "page" ||
typeof surface.title !== "string" ||
!surface.title.trim() ||
typeof surface.mount !== "function"
)
throw new Error("Invalid plugin surface");
if (surface.navigation !== undefined) {
const nav = surface.navigation;
if (
surface.slot !== "page" ||
!nav ||
typeof nav.label !== "string" ||
!nav.label.trim() ||
nav.label.length > 120 ||
(nav.labelZh !== undefined &&
(typeof nav.labelZh !== "string" ||
!nav.labelZh.trim() ||
nav.labelZh.length > 120)) ||
(nav.icon !== undefined && typeof nav.icon !== "string")
)
throw new Error("Invalid plugin navigation");
}
seen.add(surface.id);
}
}
return { ...entry, extension: loadedModule };
} catch (error) {
console.warn(`Browser extension ${entry.module} unavailable`, error);
return {
...entry,
extension: undefined,
error: "Module failed to load. Reload the page to retry.",
};
}
}),
);
}
export function activeFrontendExtensions(entries: LoadedContribution[]) {
return entries.flatMap((contribution) =>
contribution.settings.enabled === true && contribution.extension
? [{ contribution, extension: contribution.extension }]
: [],
);
}

View File

@ -0,0 +1,86 @@
import { getAPIClient } from "@/core/api";
import { fetch } from "@/core/api/fetcher";
import { getBackendBaseURL } from "@/core/config";
import { formatThreadAsJSON } from "@/core/threads/export";
import type { AgentThreadState } from "@/core/threads/types";
import { pathOfThread } from "@/core/threads/utils";
import type {
ConversationActionContext,
FrontendContribution,
FrontendServices,
} from "./contracts";
export type HostServices = Omit<FrontendServices, "callBackend">;
/** Namespace comes from the installed page snapshot, never from action input. */
export function bindFrontendServices(
base: HostServices,
entry: FrontendContribution,
signal?: AbortSignal,
): FrontendServices {
return {
...base,
async callBackend(action, payload) {
if (!entry.backend_actions?.includes(action))
throw new Error("Backend action not declared by this plugin");
const response = await fetch(
`${getBackendBaseURL()}/api/plugins/${encodeURIComponent(entry.namespace)}/actions/${encodeURIComponent(action)}`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
...(entry.viewer_id
? { "X-Deerflow-Plugin-Viewer": entry.viewer_id }
: {}),
},
body: JSON.stringify(payload),
...(signal ? { signal } : {}),
},
);
if (!response.ok)
throw new Error(`Plugin action unavailable (${response.status})`);
return response.json() as Promise<unknown>;
},
};
}
export async function conversationText(context: ConversationActionContext) {
const data = await visibleConversation(context);
return data.messages.map((message) => message.content).join("\n\n");
}
async function visibleConversation(context: ConversationActionContext) {
const messages =
context.messages ??
(
await getAPIClient().threads.getState<AgentThreadState>(
context.thread.thread_id,
)
).values?.messages ??
[];
// Reuse the export's visibility and internal-marker rules for both host slots.
return JSON.parse(formatThreadAsJSON(context.thread, messages)) as {
messages: { id?: string; type: string; content: string }[];
};
}
export async function latestVisibleAnswer(context: ConversationActionContext) {
const { messages } = await visibleConversation(context);
const answer = [...messages]
.reverse()
.find((message) => message.type === "ai" && !!message.id);
return answer ? { id: answer.id!, text: answer.content } : null;
}
/** Resolve live routing metadata through the authenticated host API, including legacy bookmarks. */
export async function openConversation(
threadId: string,
navigate: (path: string) => void,
signal?: AbortSignal,
) {
signal?.throwIfAborted();
const thread = await getAPIClient().threads.get(threadId, { signal });
signal?.throwIfAborted();
navigate(pathOfThread(thread));
}

View File

@ -0,0 +1,52 @@
import type { PluginSurface, SurfaceContext } from "./contracts";
/** Shadow DOM scopes styles, not privileges: packages are administrator-trusted code. */
export function mountSurface(
container: HTMLElement,
surface: PluginSurface,
context: Omit<SurfaceContext, "signal" | "openConversation"> & {
openConversation?: (threadId: string, signal: AbortSignal) => Promise<void>;
},
onError: () => void,
) {
const abort = new AbortController();
const shadow =
container.shadowRoot ?? container.attachShadow({ mode: "open" });
const root = document.createElement("div");
shadow.replaceChildren(root);
let controller: { dispose: () => void } | undefined;
const cleanup = () => {
if (abort.signal.aborted) return;
abort.abort();
try {
controller?.dispose();
} catch {
/* Contain plugin cleanup failures. */
}
root.remove();
};
try {
controller = surface.mount(root, {
...context,
signal: abort.signal,
openConversation: context.openConversation
? async (threadId) => {
abort.signal.throwIfAborted();
await context.openConversation!(threadId, abort.signal);
}
: undefined,
async callBackend(action, payload) {
abort.signal.throwIfAborted();
const result = await context.callBackend(action, payload);
abort.signal.throwIfAborted();
return result;
},
});
if (typeof controller?.dispose !== "function")
throw new Error("Invalid surface controller");
} catch {
cleanup();
onError();
}
return cleanup;
}

View File

@ -17,7 +17,36 @@ export const enUS: Translations = {
localName: "English", localName: "English",
}, },
extensions: {
title: "Extensions",
navigation: "Extensions",
search: "Search extensions by name or purpose",
loading: "Loading extensions…",
pageLoading: "Loading extension…",
unavailable: "Extensions unavailable.",
retry: "Retry",
reload: "Reload",
reloadAll: "Reload extensions (refresh page)",
all: "All extensions",
notInstalled: "This extension is not installed.",
enabledManaged: "Enabled · Managed by your administrator",
disabledManaged: "Disabled · Managed by your administrator",
deploymentHint:
"Interface and browser features update on manual reload. Installation, activation and configuration are managed through deployment configuration or the CLI.",
moduleUnavailable: "Page module unavailable",
noResults: "No matching installed extensions.",
pageUnavailable: "Extension page unavailable",
pageUnavailableHint:
"This page is not registered, or its plugin is disabled or unavailable.",
viewAll: "View extensions",
viewFailed: "Plugin view unavailable. Reload to retry.",
actionFailed: "Extension action unavailable. Try again.",
view: (name) => `View ${name}`,
open: (name) => `Open ${name}`,
},
capabilities: { capabilities: {
toolsAndIntegrations: "Tools & integrations",
icon: { icon: {
title: "Plugin icon", title: "Plugin icon",
upload: "Upload plugin icon", upload: "Upload plugin icon",

View File

@ -6,7 +6,34 @@ export interface Translations {
localName: string; localName: string;
}; };
extensions: {
title: string;
navigation: string;
search: string;
loading: string;
pageLoading: string;
unavailable: string;
retry: string;
reload: string;
reloadAll: string;
all: string;
notInstalled: string;
enabledManaged: string;
disabledManaged: string;
deploymentHint: string;
moduleUnavailable: string;
noResults: string;
pageUnavailable: string;
pageUnavailableHint: string;
viewAll: string;
viewFailed: string;
actionFailed: string;
view: (name: string) => string;
open: (name: string) => string;
};
capabilities: { capabilities: {
toolsAndIntegrations: string;
icon: { icon: {
title: string; title: string;
upload: string; upload: string;

View File

@ -17,7 +17,35 @@ export const zhCN: Translations = {
localName: "中文", localName: "中文",
}, },
extensions: {
title: "扩展插件",
navigation: "扩展",
search: "按名称或用途搜索扩展",
loading: "正在加载扩展…",
pageLoading: "正在加载扩展…",
unavailable: "扩展暂不可用。",
retry: "重试",
reload: "重新加载",
reloadAll: "重新加载扩展(刷新页面)",
all: "全部扩展",
notInstalled: "此扩展未安装或已移除。",
enabledManaged: "已启用 · 由管理员管理",
disabledManaged: "已停用 · 由管理员管理",
deploymentHint:
"界面和浏览器功能在手动刷新后更新;安装、启停和配置由部署管理员通过配置文件或 CLI 管理。",
moduleUnavailable: "当前页面加载失败",
noResults: "没有匹配的已安装扩展。",
pageUnavailable: "扩展页面不可用",
pageUnavailableHint: "此页面未注册,或插件已停用、未能加载。",
viewAll: "查看扩展",
viewFailed: "扩展界面加载失败,请刷新重试。",
actionFailed: "暂时无法执行扩展操作,请重试。",
view: (name) => `查看 ${name}`,
open: (name) => `打开 ${name}`,
},
capabilities: { capabilities: {
toolsAndIntegrations: "工具与集成",
icon: { icon: {
title: "插件图标", title: "插件图标",
upload: "上传插件图标", upload: "上传插件图标",

View File

@ -0,0 +1,457 @@
import { spawn, type ChildProcess } from "node:child_process";
import { createHash } from "node:crypto";
import { mkdir } from "node:fs/promises";
import { createServer } from "node:net";
import path from "node:path";
import { expect, test } from "@playwright/test";
import { mockLangGraphAPI, MOCK_THREAD_ID } from "./utils/mock-api";
let gateway: ChildProcess;
let gatewayURL: string;
test.beforeAll(async ({ request }) => {
const probe = createServer();
await new Promise<void>((resolve) => probe.listen(0, "127.0.0.1", resolve));
const address = probe.address();
if (!address || typeof address === "string")
throw new Error("Missing test port");
await new Promise<void>((resolve) => probe.close(() => resolve()));
gatewayURL = `http://127.0.0.1:${address.port}`;
const backend = path.resolve(process.cwd(), "../backend");
gateway = spawn(
path.join(backend, ".venv/bin/python"),
[
"-m",
"extension_test_fixtures.bookmark_plugin_gateway",
String(address.port),
],
{ cwd: backend, stdio: "pipe" },
);
let diagnostics = "";
gateway.stderr?.on("data", (chunk) => {
diagnostics += String(chunk);
});
await expect
.poll(
async () => {
if (gateway.exitCode !== null) throw new Error(diagnostics);
return request
.get(`${gatewayURL}/api/plugins`)
.then((r) => r.status())
.catch(() => 0);
},
{ timeout: 20_000 },
)
.toBe(200);
});
test.afterAll(async () => {
if (gateway?.exitCode === null) {
const exited = new Promise<void>((resolve) =>
gateway.once("exit", () => resolve()),
);
gateway.kill("SIGTERM");
await exited;
}
});
for (const source of ["default", "custom-toolbar", "custom-sidebar"]) {
test(`bookmark package (${source}): save, reopen original agent, tool lookup, isolation and delete`, async ({
page,
request,
}) => {
test.setTimeout(90_000);
const frontendURL =
process.env.PLAYWRIGHT_BASE_URL ?? "http://localhost:3000";
const backendBase = process.env.NEXT_PUBLIC_BACKEND_BASE_URL ?? "";
const backendURL = new URL(backendBase || frontendURL, frontendURL);
await page.context().addCookies([
{
name: "plugin_test_session",
value: "synthetic",
url: backendURL.origin,
},
]);
await page.setViewportSize({ width: 1360, height: 1050 });
const agentName = source === "default" ? undefined : "researcher";
const conversationURL = agentName
? `/workspace/agents/${agentName}/chats/${MOCK_THREAD_ID}`
: `/workspace/chats/${MOCK_THREAD_ID}`;
mockLangGraphAPI(page, {
agents: agentName
? [{ name: agentName, description: "Research assistant" }]
: [],
threads: [
{
thread_id: MOCK_THREAD_ID,
title: "Plugin architecture",
agent_name: agentName,
messages: [
{
id: "u",
type: "human",
content: "What should the plugin host provide?",
},
{
id: "a",
type: "ai",
content:
"ORCHID plugin design: one package provides a custom page, backend actions and model tools. Deployment owns installation and activation.",
additional_kwargs: { reasoning_content: "PRIVATE REASONING" },
},
{
id: "hidden",
type: "ai",
content: "HIDDEN ANSWER",
additional_kwargs: { hide_from_ui: true },
},
],
},
],
});
let savedPayload: Record<string, unknown> | undefined;
const moduleRequests: string[] = [];
await page.route("**/api/plugins**", async (route) => {
const url = new URL(route.request().url());
const cors = {
"access-control-allow-origin": new URL(frontendURL).origin,
"access-control-allow-credentials": "true",
"access-control-allow-methods": "GET, POST, OPTIONS",
"access-control-allow-headers":
"content-type, x-deerflow-plugin-viewer, x-csrf-token",
};
if (route.request().method() === "OPTIONS") {
await route.fulfill({ status: 204, headers: cors });
return;
}
if (url.pathname.includes("/modules/")) {
moduleRequests.push(url.href);
if (
!(await route.request().allHeaders()).cookie?.includes(
"plugin_test_session=synthetic",
)
) {
await route.fulfill({ status: 401, headers: cors });
return;
}
}
if (url.pathname.endsWith("/actions/save"))
savedPayload = route.request().postDataJSON();
const response = await route.fetch({
url: gatewayURL + url.pathname.slice(url.pathname.indexOf("/api/")),
headers: {
...route.request().headers(),
"x-test-user": "alice",
"x-test-role": "admin",
},
});
await route.fulfill({
response,
headers: { ...response.headers(), ...cors },
});
});
await page.goto(conversationURL);
const libraryURL = "/workspace/extensions/community.bookmarks/library";
const libraryLink = page.getByRole("link", {
name: "My bookmarks",
exact: true,
});
await expect(libraryLink).toHaveAttribute("href", libraryURL);
expect(moduleRequests).toHaveLength(1);
const modulePrefix = new URL(
`${backendBase.replace(/\/+$/, "")}/api/plugins/modules/`,
frontendURL,
).href;
expect(moduleRequests[0]?.startsWith(modulePrefix)).toBe(true);
if (source === "custom-sidebar") {
await page
.locator(`a[data-sidebar="menu-button"][href="${conversationURL}"]`)
.locator("xpath=..")
.getByRole("button", { name: "More" })
.click();
await page
.getByRole("menuitem", { name: "Bookmarks", exact: true })
.hover();
} else {
await page
.getByRole("button", { name: "Bookmarks", exact: true })
.click();
}
await page
.getByRole("menuitem", { name: "Save last answer", exact: true })
.click();
await expect(
page.getByText("Saved. Open My bookmarks in the sidebar.", {
exact: true,
}),
).toBeVisible();
expect(savedPayload?.message_id).toBe("a");
expect(JSON.stringify(savedPayload)).not.toContain("PRIVATE");
expect(JSON.stringify(savedPayload)).not.toContain("HIDDEN");
await page.goto("/workspace/capabilities?tab=extensions");
await expect(page.getByRole("switch")).toHaveCount(0);
if (process.env.EXTENSION_SCREENSHOT_DIR) {
await mkdir(process.env.EXTENSION_SCREENSHOT_DIR, { recursive: true });
await page.screenshot({
path: path.join(
process.env.EXTENSION_SCREENSHOT_DIR,
"bookmarks-directory.png",
),
fullPage: true,
});
}
await page
.getByRole("button", { name: "View 会话书签 / Bookmarks", exact: true })
.click();
await expect(
page.getByRole("searchbox", { name: "Search bookmarks" }),
).toHaveCount(0);
await expect(
page.getByText("Enabled · Managed by your administrator", {
exact: true,
}),
).toBeVisible();
await libraryLink.click();
await expect(page).toHaveURL(new RegExp(`${libraryURL}$`));
await expect(
page.getByRole("heading", { name: "My bookmarks", exact: true }),
).toBeVisible();
await expect(
page.getByText("Keep answers worth returning to", { exact: true }),
).toBeVisible();
const name = page.getByRole("textbox", {
name: "Bookmark name",
exact: true,
});
await expect(name).toBeVisible();
await name.fill("Plugin interface decisions");
await page.getByRole("button", { name: "Save name", exact: true }).click();
await page.reload();
await expect(name).toHaveValue("Plugin interface decisions");
await page
.getByRole("searchbox", { name: "Search bookmarks", exact: true })
.fill("ORCHID");
await page.getByRole("button", { name: "Search", exact: true }).click();
await expect(page.getByText(/1 saved/)).toBeVisible();
await expect(
page.getByRole("button", { name: "Search", exact: true }),
).toBeEnabled();
await page
.getByRole("button", { name: "Open conversation", exact: true })
.click();
await expect(page).toHaveURL(new RegExp(`${conversationURL}$`));
// Verify continuing the reopened conversation still invokes its originating agent.
const runRequest = page.waitForRequest(
(request) =>
request.method() === "POST" && request.url().includes("/runs/stream"),
);
await page.getByRole("textbox").fill("Continue this conversation");
await page.getByRole("textbox").press("Enter");
const runPayload = (await runRequest).postDataJSON();
expect(runPayload.assistant_id).toBe(agentName ?? "lead_agent");
if (agentName) expect(runPayload.context.agent_name).toBe(agentName);
await libraryLink.click();
await expect(name).toHaveValue("Plugin interface decisions");
await expect(page.getByRole("switch")).toHaveCount(0);
await expect(
page.getByRole("button", { name: "Save plugin settings" }),
).toHaveCount(0);
const modelResponse = await request.post(
`${gatewayURL}/test/model-search`,
{
headers: { "x-test-user": "alice" },
data: { query: "ORCHID" },
},
);
const modelResult = await modelResponse.json();
expect(modelResult.tool).toContain("search_bookmarks");
const items = JSON.parse(modelResult.content).items;
expect(items).toHaveLength(1);
expect(items[0].label).toBe("Plugin interface decisions");
const bob = await request.post(`${gatewayURL}/test/model-search`, {
headers: { "x-test-user": "bob" },
data: { query: "ORCHID" },
});
expect(JSON.parse((await bob.json()).content).items).toEqual([]);
expect(
(
await request.post(
`${gatewayURL}/api/plugins/community.bookmarks/actions/delete`,
{ headers: { "x-test-user": "bob" }, data: { id: items[0].id } },
)
).status(),
).toBe(422);
expect(
(
await request.patch(`${gatewayURL}/api/plugins/community.bookmarks`, {
headers: { "x-test-role": "admin" },
data: { revision: "fake", changes: { enabled: false } },
})
).status(),
).toBe(404);
if (process.env.EXTENSION_SCREENSHOT_DIR)
await page.screenshot({
path: path.join(
process.env.EXTENSION_SCREENSHOT_DIR,
"bookmarks-detail.png",
),
fullPage: true,
});
await page.getByRole("button", { name: "Delete", exact: true }).click();
await page
.getByRole("button", { name: "Confirm delete", exact: true })
.click();
await expect(
page.getByText("No matching bookmarks", { exact: true }),
).toBeVisible();
// A refreshed deployment snapshot removes both the entry and direct-page access.
await page.route("**/api/plugins", async (route) => {
const response = await request.get(`${gatewayURL}/api/plugins`);
const entries = await response.json();
await route.fulfill({
json: entries.map((entry: { settings: Record<string, unknown> }) => ({
...entry,
settings: { ...entry.settings, enabled: false },
})),
});
});
await page.reload();
await expect(libraryLink).toHaveCount(0);
await expect(
page.getByRole("heading", { name: "Extension page unavailable" }),
).toBeVisible();
await expect(
page.getByRole("searchbox", { name: "Search bookmarks" }),
).toHaveCount(0);
});
}
for (const brokenFactory of [
"throw new Error('broken plugin');",
"return { actions: undefined };",
"return { label: 'Broken', icon: 'bookmark', actions: [{ id: 'broken', label: 'Broken', icon: 'bookmark', available: async () => { throw new Error('rejected availability'); }, execute: async () => {} }] };",
]) {
test(`a broken plugin action factory is isolated: ${brokenFactory}`, async ({
page,
baseURL,
}) => {
const errors: string[] = [];
page.on("pageerror", (error) => errors.push(error.message));
mockLangGraphAPI(page, {
threads: [
{
thread_id: MOCK_THREAD_ID,
title: "Healthy conversation",
messages: [
{
id: "answer",
type: "ai",
content: "The conversation is still usable.",
},
],
},
],
});
const code = {
broken: `export default { apiVersion: 1, module: 'broken', conversationActions() { ${brokenFactory} } };`,
healthy: `export default { apiVersion: 1, module: 'healthy', conversationActions() { return { label: 'Healthy actions', icon: 'bookmark', actions: [{ id: 'test', label: 'Run healthy action', icon: 'bookmark', available: () => true, execute: async (_context, services) => services.showMessage('Healthy action completed.') }] }; } };`,
};
const entries = Object.entries(code).map(([module, source]) => ({
namespace: `test.${module}`,
module,
title: module,
description: "",
settings: { enabled: true },
entry: `/api/plugins/modules/${module}/${createHash("sha256").update(source).digest("hex")}.mjs`,
}));
await page.route("**/api/plugins**", async (route) => {
const url = new URL(route.request().url());
const headers = {
"access-control-allow-origin": new URL(baseURL!).origin,
"access-control-allow-credentials": "true",
};
if (url.pathname.endsWith("/api/plugins"))
return route.fulfill({ json: entries, headers });
const entry = entries.find((item) => url.pathname.endsWith(item.entry));
if (!entry) return route.fulfill({ status: 404, headers });
return route.fulfill({
body: code[entry.module as keyof typeof code],
contentType: "text/javascript",
headers,
});
});
await page.goto(`/workspace/chats/${MOCK_THREAD_ID}`);
await expect(
page.getByText("The conversation is still usable.", { exact: true }),
).toBeVisible();
await page
.getByRole("button", { name: "Healthy actions", exact: true })
.click();
await page
.getByRole("menuitem", { name: "Run healthy action", exact: true })
.click();
await expect(
page.getByText("Healthy action completed.", { exact: true }),
).toBeVisible();
expect(errors).toEqual([]);
});
}
for (const locale of ["en-US", "zh-CN"]) {
test(`extension host uses ${locale} copy and its own search label`, async ({
page,
baseURL,
}) => {
const zh = locale === "zh-CN";
mockLangGraphAPI(page);
await page
.context()
.addCookies([{ name: "locale", value: locale, url: baseURL! }]);
await page.goto("/workspace/capabilities?tab=extensions");
await expect(
page.getByRole("tab", {
name: zh ? "扩展插件" : "Extensions",
exact: true,
}),
).toBeVisible();
await expect(
page.getByPlaceholder(
zh ? "按名称或用途搜索扩展" : "Search extensions by name or purpose",
),
).toBeVisible();
await expect(
page.getByRole("button", {
name: zh
? "重新加载扩展(刷新页面)"
: "Reload extensions (refresh page)",
exact: true,
}),
).toBeVisible();
await expect(
page.getByText(
zh ? "没有匹配的已安装扩展。" : "No matching installed extensions.",
{ exact: true },
),
).toBeVisible();
await page.goto("/workspace/extensions/missing.plugin/library");
await expect(
page.getByRole("heading", {
name: zh ? "扩展页面不可用" : "Extension page unavailable",
}),
).toBeVisible();
await expect(
page.getByRole("link", { name: zh ? "查看扩展" : "View extensions" }),
).toBeVisible();
});
}
test("unregistered plugin pages stay unavailable", async ({ page }) => {
mockLangGraphAPI(page);
await page.goto("/workspace/extensions/missing.plugin/library");
await expect(
page.getByRole("heading", { name: "Extension page unavailable" }),
).toBeVisible();
await expect(
page.getByRole("searchbox", { name: "Search bookmarks" }),
).toHaveCount(0);
});

View File

@ -285,7 +285,9 @@ test("plugin filters remain usable after an MCP refetch fails", async ({
await page await page
.getByRole("switch", { name: "Enabled GitHub", exact: true }) .getByRole("switch", { name: "Enabled GitHub", exact: true })
.click(); .click();
await expect(page.getByRole("alert")).toBeVisible(); await expect(
page.getByRole("alert").filter({ hasText: "Admin privileges" }),
).toBeVisible();
await expect(installed).toHaveAttribute("aria-selected", "true"); await expect(installed).toHaveAttribute("aria-selected", "true");
await expect( await expect(
page.getByRole("button", { name: "Add MCP plugin" }), page.getByRole("button", { name: "Add MCP plugin" }),

View File

@ -305,6 +305,7 @@ function runStreamThreadId(route: Route) {
* for a real backend. * for a real backend.
*/ */
export function mockLangGraphAPI(page: Page, options?: MockAPIOptions) { export function mockLangGraphAPI(page: Page, options?: MockAPIOptions) {
void page.route("**/api/plugins", (route) => route.fulfill({ json: [] }));
let threads = [...(options?.threads ?? [])]; let threads = [...(options?.threads ?? [])];
const projectsList = (options?.projects ?? []).map((project) => ({ const projectsList = (options?.projects ?? []).map((project) => ({
instructions: "", instructions: "",

View File

@ -0,0 +1,174 @@
import { afterEach, beforeEach, expect, rs, test } from "@rstest/core";
import { cleanup, render, screen } from "@testing-library/react";
import { TooltipProvider } from "@/components/ui/tooltip";
import { ConversationExtensionActions } from "@/components/workspace/conversation-extension-actions";
import type { ConversationActionGroup } from "@/core/extensions/contracts";
import type { LoadedContribution } from "@/core/extensions/registry";
import { enUS } from "@/core/i18n";
import type { AgentThread } from "@/core/threads/types";
const state = rs.hoisted(() => ({ entries: [] as LoadedContribution[] }));
rs.mock("@/core/extensions/hooks", () => ({
useFrontendExtensions: () => ({ data: state.entries }),
useFrontendServices: () => ({}),
}));
rs.mock("@/core/i18n/hooks", () => ({
useI18n: () => ({ t: enUS, locale: "en-US" }),
}));
rs.mock("@/core/config", () => ({ getBackendBaseURL: () => "" }));
rs.mock("@/core/api/fetcher", () => ({ fetch: rs.fn() }));
const action = {
id: "save",
label: "Save",
icon: "bookmark",
available: () => true,
execute: async () => undefined,
};
const group = { label: "Healthy actions", icon: "bookmark", actions: [action] };
function entry(namespace: string, factory: () => unknown): LoadedContribution {
return {
namespace,
title: namespace,
description: "",
module: namespace,
entry: null,
settings: { enabled: true },
extension: {
apiVersion: 1,
module: namespace,
conversationActions: factory as () => ConversationActionGroup,
},
};
}
beforeEach(() => {
rs.spyOn(console, "warn").mockImplementation(() => undefined);
});
afterEach(() => {
cleanup();
rs.restoreAllMocks();
});
const invalid: [string, () => unknown][] = [
[
"factory throws",
() => {
throw new Error("plugin error");
},
],
["missing actions", () => ({ label: "Broken", icon: "bookmark" })],
["actions is not an array", () => ({ ...group, actions: {} })],
["invalid group label", () => ({ ...group, label: {} })],
["invalid group icon", () => ({ ...group, icon: {} })],
[
"invalid action label",
() => ({ ...group, actions: [{ ...action, label: {} }] }),
],
["duplicate action ids", () => ({ ...group, actions: [action, action] })],
[
"missing executor",
() => ({ ...group, actions: [{ ...action, execute: null }] }),
],
[
"missing availability",
() => ({ ...group, actions: [{ ...action, available: null }] }),
],
[
"availability throws",
() => ({
...group,
actions: [
{
...action,
available: () => {
throw new Error("policy failed");
},
},
],
}),
],
[
"availability is asynchronous",
() => ({ ...group, actions: [{ ...action, available: async () => true }] }),
],
];
for (const [name, factory] of invalid) {
test(`contains ${name} without losing the other plugin or conversation`, () => {
state.entries = [entry("broken", factory), entry("healthy", () => group)];
render(
<TooltipProvider>
<p>Conversation remains usable</p>
<ConversationExtensionActions
context={{ thread: { thread_id: "test" } as AgentThread }}
/>
</TooltipProvider>,
);
expect(screen.getByText("Conversation remains usable")).toBeDefined();
expect(screen.getAllByRole("button")).toHaveLength(1);
expect(
screen.getByRole("button", { name: "Healthy actions" }),
).toBeDefined();
});
}
test("empty, hidden and absent action groups leave healthy actions available", () => {
state.entries = [
entry("absent", () => undefined),
entry("empty", () => ({ ...group, actions: [] })),
entry("hidden", () => ({
...group,
actions: [{ ...action, available: () => false }],
})),
entry("healthy", () => group),
];
render(
<TooltipProvider>
<ConversationExtensionActions
context={{ thread: { thread_id: "test" } as AgentThread }}
/>
</TooltipProvider>,
);
expect(screen.getAllByRole("button")).toHaveLength(1);
expect(console.warn).not.toHaveBeenCalled();
});
test("render uses validated label snapshots instead of rereading plugin getters", () => {
let reads = 0;
state.entries = [
entry("healthy", () => ({
...group,
get label() {
if (++reads > 1) throw new Error("getter read twice");
return "Healthy actions";
},
})),
];
render(
<TooltipProvider>
<ConversationExtensionActions
context={{ thread: { thread_id: "test" } as AgentThread }}
/>
</TooltipProvider>,
);
expect(screen.getByRole("button", { name: "Healthy actions" })).toBeDefined();
expect(reads).toBe(1);
});
test("unknown icon names including prototype properties use the fallback icon", () => {
state.entries = [
entry("healthy", () => ({
...group,
icon: "__proto__",
actions: [{ ...action, icon: "constructor" }],
})),
];
render(
<TooltipProvider>
<ConversationExtensionActions
context={{ thread: { thread_id: "test" } as AgentThread }}
/>
</TooltipProvider>,
);
expect(screen.getByRole("button", { name: "Healthy actions" })).toBeDefined();
});

View File

@ -0,0 +1,62 @@
import { expect, rs, test } from "@rstest/core";
import { resolveConversationActions } from "@/core/extensions/actions";
import type { FrontendExtension } from "@/core/extensions/contracts";
import { enUS } from "@/core/i18n";
for (const callback of ["factory", "availability", "thenable"]) {
test(`contains rejected asynchronous ${callback} without an unhandled rejection`, async () => {
const warning = rs
.spyOn(console, "warn")
.mockImplementation(() => undefined);
try {
const rejected = () => Promise.reject(new Error("invalid async plugin"));
const extension = {
conversationActions:
callback === "factory"
? rejected
: () => ({
label: "Broken",
icon: "bookmark",
actions: [
{
id: "save",
label: "Save",
icon: "bookmark",
execute: async () => undefined,
available:
callback === "thenable"
? () => ({
then: (
_resolve: unknown,
reject: (error: Error) => void,
) => reject(new Error("invalid thenable")),
})
: rejected,
},
],
}),
} as unknown as FrontendExtension;
expect(
resolveConversationActions(
extension,
{
namespace: "broken",
module: null,
entry: null,
title: "Broken",
description: "",
settings: {},
},
enUS,
"en-US",
),
).toBeUndefined();
// Give rejected promises a full event-loop turn. Rstest fails on unhandled rejections.
await new Promise((resolve) => setTimeout(resolve, 0));
expect(warning).toHaveBeenCalledTimes(1);
} finally {
warning.mockRestore();
}
});
}

View File

@ -0,0 +1,102 @@
import { afterEach, describe, expect, rs, test } from "@rstest/core";
import type {
FrontendExtension,
PluginSurface,
} from "@/core/extensions/contracts";
import {
pluginPages,
pluginPageTitle,
pluginPagePath,
} from "@/core/extensions/pages";
import {
loadFrontendExtensions,
type LoadedContribution,
} from "@/core/extensions/registry";
rs.mock("@/core/config", () => ({ getBackendBaseURL: () => "" }));
rs.mock("@/core/api/fetcher", () => ({
fetch: async () => new Response("export default {}"),
}));
afterEach(() => {
rs.restoreAllMocks();
});
const library: PluginSurface = {
id: "library",
title: "Library",
slot: "page",
navigation: { label: "My bookmarks", labelZh: "我的书签" },
mount: () => ({ dispose: () => undefined }),
};
const entry: LoadedContribution = {
namespace: "community.bookmarks",
module: "bookmarks.v1",
entry: `/api/plugins/modules/bookmarks.v1/${"a".repeat(64)}.mjs`,
title: "Bookmarks",
description: "",
settings: { enabled: true },
extension: { apiVersion: 1, module: "bookmarks.v1", surfaces: [library] },
};
describe("plugin-owned pages", () => {
test("only loaded enabled page surfaces become namespaced routes", () => {
const entries = [
entry,
{ ...entry, namespace: "disabled", settings: { enabled: false } },
{ ...entry, namespace: "unloaded", extension: undefined },
{
...entry,
namespace: "another.plugin",
extension: {
...entry.extension!,
surfaces: [library],
},
},
];
const pages = pluginPages(entries);
expect(pages.map(({ href }) => href)).toEqual([
"/workspace/extensions/community.bookmarks/library",
"/workspace/extensions/another.plugin/library",
]);
expect(pluginPageTitle(library, "zh-CN")).toBe("我的书签");
expect(pluginPageTitle(library, "en-US")).toBe("My bookmarks");
expect(pluginPagePath("a/b?c", "page#one")).toBe(
"/workspace/extensions/a%2Fb%3Fc/page%23one",
);
});
test("page URLs do not require a sidebar entry", () => {
const pages = pluginPages([
{
...entry,
extension: {
...entry.extension!,
surfaces: [{ ...library, navigation: undefined }],
},
},
]);
expect(pages).toHaveLength(1);
expect(pluginPageTitle(pages[0]!.surface, "zh-CN")).toBe("Library");
});
test("invalid navigation is contained to its module", async () => {
for (const surface of [
{ ...library, slot: "composer" },
{ ...library, navigation: { label: "" } },
{ ...library, navigation: { label: "ok", labelZh: 42 } },
{ ...library, id: "../chats" },
]) {
const importer = rs.fn(async () => ({
default: {
...entry.extension,
surfaces: [surface],
} as FrontendExtension,
}));
const result = await loadFrontendExtensions([entry], importer);
expect(importer).toHaveBeenCalledTimes(1);
expect(result[0]?.error).toBeTruthy();
expect(pluginPages(result)).toEqual([]);
}
});
});

View File

@ -0,0 +1,105 @@
import { afterEach, beforeEach, expect, rs, test } from "@rstest/core";
import { loadFrontendExtensions } from "@/core/extensions/registry";
const config = rs.hoisted(() => ({ backend: "" }));
rs.mock("@/core/config", () => ({ getBackendBaseURL: () => config.backend }));
rs.mock("@/core/static-mode", () => ({ isStaticWebsiteOnly: () => false }));
rs.mock("@/core/api/static-response", () => ({ staticApiResponse: rs.fn() }));
const entry = {
namespace: "community.bookmarks",
module: "bookmarks.v1",
entry: `/api/plugins/modules/bookmarks.v1/${"a".repeat(64)}.mjs`,
title: "Bookmarks",
description: "",
settings: { enabled: true },
};
const extension = { apiVersion: 1, module: entry.module };
const code = "export default {apiVersion: 1, module: 'bookmarks.v1'};";
beforeEach(() => {
config.backend = "";
rs.spyOn(console, "warn").mockImplementation(() => undefined);
});
afterEach(() => {
rs.restoreAllMocks();
});
for (const backend of [
"",
"https://backend.example",
"https://backend.example/deerflow",
"/gateway",
]) {
test(`loads authenticated code with backend base ${backend || "same origin"}`, async () => {
config.backend = backend;
const request = rs
.spyOn(globalThis, "fetch")
.mockResolvedValue(new Response(code));
const revoke = rs.spyOn(URL, "revokeObjectURL");
const importer = rs.fn(async (url: string) => {
expect(url.startsWith("blob:")).toBe(true);
// Read the actual blob without using the spied HTTP fetch.
const { resolveObjectURL } = await import("node:buffer");
expect(await resolveObjectURL(url)?.text()).toBe(code);
expect(revoke).not.toHaveBeenCalled();
return { default: extension };
});
const result = await loadFrontendExtensions([entry], importer);
expect(result[0]?.extension).toEqual(extension);
expect(request).toHaveBeenCalledWith(
`${backend}${entry.entry}`,
expect.objectContaining({ credentials: "include", cache: "no-store" }),
);
expect(importer).toHaveBeenCalledTimes(1);
expect(revoke).toHaveBeenCalledWith(importer.mock.calls[0]![0]);
});
}
test("rejected HTTP responses never execute as modules", async () => {
const request = rs.spyOn(globalThis, "fetch");
const create = rs.spyOn(URL, "createObjectURL");
const importer = rs.fn();
for (const status of [403, 404, 500]) {
request.mockResolvedValue(new Response("not javascript", { status }));
const result = await loadFrontendExtensions([entry], importer);
expect(result[0]?.error).toBeTruthy();
expect(result[0]?.extension).toBeUndefined();
}
expect(create).not.toHaveBeenCalled();
expect(importer).not.toHaveBeenCalled();
});
test("failed import releases its blob and does not prevent another plugin loading", async () => {
rs.spyOn(globalThis, "fetch").mockImplementation(
async () => new Response(code),
);
const revoke = rs.spyOn(URL, "revokeObjectURL");
const importer = rs
.fn()
.mockRejectedValueOnce(new Error("module failed"))
.mockResolvedValueOnce({ default: extension });
const result = await loadFrontendExtensions([entry, entry], importer);
expect(result[0]?.error).toBeTruthy();
expect(result[1]?.extension).toEqual(extension);
expect(revoke).toHaveBeenCalledTimes(2);
for (const [url] of importer.mock.calls)
expect(revoke).toHaveBeenCalledWith(url);
});
test("disabled, backend-only and invalid entries never fetch or import", async () => {
const request = rs.spyOn(globalThis, "fetch");
const importer = rs.fn();
await loadFrontendExtensions(
[
{ ...entry, settings: { enabled: false } },
{ ...entry, module: null, entry: null },
{ ...entry, entry: "https://untrusted.example/code.mjs" },
{ ...entry, entry: `${entry.entry}?redirect=elsewhere` },
],
importer,
);
expect(request).not.toHaveBeenCalled();
expect(importer).not.toHaveBeenCalled();
});

View File

@ -0,0 +1,165 @@
import { beforeEach, expect, rs, test } from "@rstest/core";
import { loadFrontendExtensions } from "@/core/extensions/registry";
import {
bindFrontendServices,
conversationText,
latestVisibleAnswer,
openConversation,
} from "@/core/extensions/services";
import type { AgentThread } from "@/core/threads/types";
const { request, getState, getThread } = rs.hoisted(() => ({
request: rs.fn(),
getState: rs.fn(),
getThread: rs.fn(),
}));
rs.mock("@/core/api/fetcher", () => ({ fetch: request }));
rs.mock("@/core/config", () => ({ getBackendBaseURL: () => "" }));
rs.mock("@/core/api", () => ({
getAPIClient: () => ({ threads: { getState, get: getThread } }),
}));
beforeEach(() => {
rs.clearAllMocks();
});
test("bookmark receives only the last visible assistant answer", async () => {
const thread = {
thread_id: "t",
values: { title: "Example" },
} as AgentThread;
const answer = await latestVisibleAnswer({
thread,
messages: [
{ id: "a", type: "ai", content: "<think>SECRET</think>Visible answer" },
{
id: "h",
type: "ai",
content: "HIDDEN",
additional_kwargs: { hide_from_ui: true },
},
{ id: "u", type: "human", content: "Later question" },
],
});
expect(answer).toEqual({ id: "a", text: "Visible answer" });
});
const entry = {
namespace: "community.stats",
module: null,
entry: null,
title: "Stats",
description: "",
settings: { enabled: true },
backend_actions: ["stats"],
};
test("backend-only entry stays visible without attempting a browser import", async () => {
const importer = rs.fn();
expect(await loadFrontendExtensions([entry], importer)).toEqual([entry]);
expect(importer).not.toHaveBeenCalled();
});
test("backend bridge binds installed namespace and propagates administrator disable", async () => {
const services = bindFrontendServices(
{
conversationText,
showMessage: rs.fn(),
},
entry,
);
request.mockResolvedValue({
ok: true,
json: async () => ({ characters: 5 }),
});
expect(await services.callBackend("stats", { text: "hello" })).toEqual({
characters: 5,
});
expect(request).toHaveBeenCalledWith(
"/api/plugins/community.stats/actions/stats",
expect.objectContaining({ method: "POST", body: '{"text":"hello"}' }),
);
await expect(services.callBackend("undeclared", {})).rejects.toThrow(
"not declared",
);
expect(request).toHaveBeenCalledTimes(1);
request.mockResolvedValue({ ok: false, status: 403 });
await expect(services.callBackend("stats", {})).rejects.toThrow("403");
});
test("plugin transcript service shares visible-only sanitizer and authenticated sidebar read", async () => {
const thread = {
thread_id: "test",
values: { title: "Synthetic" },
} as AgentThread;
const messages = [
{ id: "u", type: "human" as const, content: "hello" },
{
id: "a",
type: "ai" as const,
content: "<think>PRIVATE</think>world",
additional_kwargs: { reasoning_content: "PRIVATE" },
},
{
id: "hidden",
type: "ai" as const,
content: "HIDDEN",
additional_kwargs: { hide_from_ui: true },
},
];
const text = await conversationText({ thread, messages });
expect(text).toBe("hello\n\nworld");
expect(getState).not.toHaveBeenCalled();
getState.mockResolvedValue({ values: { messages } });
expect(await conversationText({ thread })).toBe(text);
expect(getState).toHaveBeenCalledWith("test");
getState.mockRejectedValue(new Error("403"));
await expect(conversationText({ thread })).rejects.toThrow("403");
});
for (const agent of [undefined, "researcher", "研究 / agent?#"]) {
test(`plugin navigation resolves the current owner for ${agent ?? "default"} conversations`, async () => {
getThread.mockResolvedValue({
thread_id: "thread / 1",
metadata: agent ? { agent_name: agent } : {},
});
const navigate = rs.fn();
const signal = new AbortController().signal;
await openConversation("thread / 1", navigate, signal);
expect(getThread).toHaveBeenCalledWith("thread / 1", { signal });
expect(navigate).toHaveBeenCalledWith(
agent
? `/workspace/agents/${encodeURIComponent(agent)}/chats/thread%20%2F%201`
: "/workspace/chats/thread%20%2F%201",
);
});
}
test("inaccessible conversations never fall back to the default agent route", async () => {
const navigate = rs.fn();
getThread.mockRejectedValue(new Error("403"));
await expect(openConversation("thread", navigate)).rejects.toThrow("403");
expect(navigate).not.toHaveBeenCalled();
});
test("unmounted or switched-account plugin pages cannot navigate after a late metadata read", async () => {
const navigate = rs.fn();
const abort = new AbortController();
let finish!: (value: unknown) => void;
getThread.mockReturnValue(
new Promise((resolve) => {
finish = resolve;
}),
);
const pending = openConversation("thread", navigate, abort.signal);
abort.abort();
finish({ thread_id: "thread", metadata: { agent_name: "researcher" } });
await expect(pending).rejects.toThrow();
expect(navigate).not.toHaveBeenCalled();
getThread.mockClear();
await expect(
openConversation("thread", navigate, abort.signal),
).rejects.toThrow();
expect(getThread).not.toHaveBeenCalled();
});

View File

@ -0,0 +1,115 @@
import { expect, rs, test } from "@rstest/core";
import { mountSurface } from "@/core/extensions/surfaces";
test("surface cleanup aborts outstanding work and rejects late backend calls", async () => {
const container = document.createElement("div");
const dispose = rs.fn();
const backend = rs.fn(async () => ({}));
let callLater: (
name: string,
payload: Record<string, unknown>,
) => Promise<unknown> = rs.fn();
let signal: AbortSignal | undefined;
const cleanup = mountSurface(
container,
{
id: "picker",
slot: "page",
title: "Picker",
mount(root, context) {
signal = context.signal;
callLater = context.callBackend;
root.textContent = "PLUGIN UI";
return { dispose };
},
},
{
namespace: "community.example",
locale: "en",
settings: {},
threadId: "a",
callBackend: backend,
},
rs.fn(),
);
expect(container.shadowRoot?.textContent).toBe("PLUGIN UI");
await callLater("search", {});
cleanup();
cleanup();
await expect(callLater("search", {})).rejects.toThrow();
expect(backend).toHaveBeenCalledTimes(1);
expect(signal?.aborted).toBe(true);
expect(dispose).toHaveBeenCalledTimes(1);
expect(container.shadowRoot?.textContent).toBe("");
});
test("a failed mount is isolated and cannot leave its partial UI behind", () => {
const error = rs.fn();
const container = document.createElement("div");
mountSurface(
container,
{
id: "bad",
slot: "page",
title: "Broken",
mount(root) {
root.textContent = "partial";
throw new Error("broken");
},
},
{
namespace: "community.example",
locale: "en",
settings: {},
callBackend: rs.fn(),
},
error,
);
expect(error).toHaveBeenCalledTimes(1);
expect(container.shadowRoot?.textContent).toBe("");
});
for (const failMount of [false, true]) {
test(`surface ${failMount ? "mount failure" : "cleanup"} aborts navigation and rejects stale callbacks`, async () => {
let openLater!: (id: string) => Promise<void>;
let pending!: Promise<void>;
let finish!: () => void;
const navigate = rs.fn();
const open = rs.fn(async (_id: string, signal: AbortSignal) => {
await new Promise<void>((resolve) => {
finish = resolve;
});
signal.throwIfAborted();
navigate();
});
const cleanup = mountSurface(
document.createElement("div"),
{
id: "library",
slot: "page",
title: "Library",
mount(_root, context) {
openLater = context.openConversation!;
pending = openLater("thread");
if (failMount) throw new Error("mount failed");
return { dispose: rs.fn() };
},
},
{
namespace: "bookmarks",
locale: "en",
settings: {},
callBackend: rs.fn(),
openConversation: open,
},
rs.fn(),
);
if (!failMount) cleanup();
finish();
await expect(pending).rejects.toThrow();
await expect(openLater("thread")).rejects.toThrow();
expect(open).toHaveBeenCalledTimes(1);
expect(navigate).not.toHaveBeenCalled();
});
}