diff --git a/.github/workflows/e2e-tests.yml b/.github/workflows/e2e-tests.yml index 9c30c4648..b27bfcec3 100644 --- a/.github/workflows/e2e-tests.yml +++ b/.github/workflows/e2e-tests.yml @@ -5,11 +5,27 @@ on: branches: [ 'main', '2.0.x-dev' ] paths: - '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' pull_request: types: [opened, synchronize, reopened, ready_for_review] paths: - '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' concurrency: @@ -23,12 +39,28 @@ jobs: e2e-tests: if: ${{ github.event_name != 'pull_request' || github.event.pull_request.draft == false }} runs-on: ubuntu-latest - timeout-minutes: 15 + timeout-minutes: 25 steps: - name: Checkout 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 uses: actions/setup-node@v4 with: diff --git a/README.md b/README.md index 1803e2a49..890176640 100644 --- a/README.md +++ b/README.md @@ -1280,7 +1280,7 @@ A managed package declares exactly one standard PEP 621 entry point: 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, 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), @@ -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 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 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 @@ -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. +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. 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: diff --git a/backend/app/gateway/AGENTS.md b/backend/app/gateway/AGENTS.md index 60ba34e07..4d50d5582 100644 --- a/backend/app/gateway/AGENTS.md +++ b/backend/app/gateway/AGENTS.md @@ -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/`. 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. diff --git a/backend/app/gateway/app.py b/backend/app/gateway/app.py index f1097e588..e7c2e4a5a 100644 --- a/backend/app/gateway/app.py +++ b/backend/app/gateway/app.py @@ -34,6 +34,7 @@ from app.gateway.routers import ( mcp_tasks, memory, models, + plugins, project_documents, project_thread_files, projects, @@ -553,6 +554,7 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]: repository=batch_repo, config=subagent_batches_config, runtime_config=subagent_runtime_config, + extensions=getattr(app.state, "extensions", None), ) app.state.subagent_batch_service = batch_service 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) app.include_router(assistants_compat.router) + app.include_router(plugins.router) + # Auth API is mounted at /api/v1/auth app.include_router(auth.router) app.include_router(user_preferences.router) diff --git a/backend/app/gateway/routers/plugins.py b/backend/app/gateway/routers/plugins.py new file mode 100644 index 000000000..ac65a99b5 --- /dev/null +++ b/backend/app/gateway/routers/plugins.py @@ -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 diff --git a/backend/extension_test_fixtures/bookmark_plugin_gateway.py b/backend/extension_test_fixtures/bookmark_plugin_gateway.py new file mode 100644 index 000000000..875007a25 --- /dev/null +++ b/backend/extension_test_fixtures/bookmark_plugin_gateway.py @@ -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") diff --git a/backend/packages/extension-api/deerflow_extension_api/__init__.py b/backend/packages/extension-api/deerflow_extension_api/__init__.py index 266ec3678..1a40ea69f 100644 --- a/backend/packages/extension-api/deerflow_extension_api/__init__.py +++ b/backend/packages/extension-api/deerflow_extension_api/__init__.py @@ -45,6 +45,7 @@ from deerflow_extension_api.placement import ( MiddlewarePlacement, Placement, ) +from deerflow_extension_api.plugins import ActionContext, BackendAction, BrowserModule, ModelTool, PluginContribution, ToolContext from deerflow_extension_api.provenance import ( MESSAGE_CONTENT_KIND_KEY, MESSAGE_PRODUCER_ENTITY_ID_KEY, @@ -73,13 +74,21 @@ from deerflow_extension_api.runtime_bridge import ( EXTENSION_TASK_STORE_KEY, task_store_from_runtime, ) +from deerflow_extension_api.settings import SettingsField from deerflow_extension_api.state import ExtensionData #: Contract version. Before 1.0, minors may break and patches are additive. #: From 1.0 on, bump the major for breaking changes. -API_VERSION = "0.2.1" +API_VERSION = "0.2.2" __all__ = [ + "ActionContext", + "BackendAction", + "BrowserModule", + "ModelTool", + "PluginContribution", + "ToolContext", + "SettingsField", "API_VERSION", "EXTENSION_PRINCIPAL_RESOLVER_KEY", "EXTENSION_TASK_STORE_KEY", diff --git a/backend/packages/extension-api/deerflow_extension_api/contracts.py b/backend/packages/extension-api/deerflow_extension_api/contracts.py index 7fe5e62f0..54a30156d 100644 --- a/backend/packages/extension-api/deerflow_extension_api/contracts.py +++ b/backend/packages/extension-api/deerflow_extension_api/contracts.py @@ -14,6 +14,7 @@ from dataclasses import dataclass, field from enum import StrEnum 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 if TYPE_CHECKING: # pragma: no cover - typing only @@ -189,6 +190,10 @@ class ExtensionRegistry(Protocol): (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: return None diff --git a/backend/packages/extension-api/deerflow_extension_api/plugins.py b/backend/packages/extension-api/deerflow_extension_api/plugins.py new file mode 100644 index 000000000..ec31370ce --- /dev/null +++ b/backend/packages/extension-api/deerflow_extension_api/plugins.py @@ -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, + ) diff --git a/backend/packages/extension-api/deerflow_extension_api/settings.py b/backend/packages/extension-api/deerflow_extension_api/settings.py new file mode 100644 index 000000000..b4bb7565d --- /dev/null +++ b/backend/packages/extension-api/deerflow_extension_api/settings.py @@ -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)) diff --git a/backend/packages/extension-api/pyproject.toml b/backend/packages/extension-api/pyproject.toml index 8bf178e25..fdcb0ea23 100644 --- a/backend/packages/extension-api/pyproject.toml +++ b/backend/packages/extension-api/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "deerflow-extension-api" -version = "0.2.1" +version = "0.2.2" description = "Public contracts for DeerFlow extensions" requires-python = ">=3.12" # Keep the contract package import-light and independent from the host. Public diff --git a/backend/packages/harness/deerflow/config/plugin_settings.py b/backend/packages/harness/deerflow/config/plugin_settings.py new file mode 100644 index 000000000..4e7bc0b51 --- /dev/null +++ b/backend/packages/harness/deerflow/config/plugin_settings.py @@ -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} diff --git a/backend/packages/harness/deerflow/extensions/AGENTS.md b/backend/packages/harness/deerflow/extensions/AGENTS.md index 3b9f4ea81..bd5423df1 100644 --- a/backend/packages/harness/deerflow/extensions/AGENTS.md +++ b/backend/packages/harness/deerflow/extensions/AGENTS.md @@ -377,3 +377,14 @@ that the current host silently ignores. `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. 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. diff --git a/backend/packages/harness/deerflow/extensions/plugin_tools.py b/backend/packages/harness/deerflow/extensions/plugin_tools.py new file mode 100644 index 000000000..9b814f9b7 --- /dev/null +++ b/backend/packages/harness/deerflow/extensions/plugin_tools.py @@ -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 diff --git a/backend/packages/harness/deerflow/extensions/registry.py b/backend/packages/harness/deerflow/extensions/registry.py index 900053110..898e54b6d 100644 --- a/backend/packages/harness/deerflow/extensions/registry.py +++ b/backend/packages/harness/deerflow/extensions/registry.py @@ -7,6 +7,8 @@ runtime projection. from __future__ import annotations +import inspect +import re from collections.abc import Iterator, Sequence from contextlib import contextmanager from dataclasses import dataclass @@ -22,6 +24,7 @@ from deerflow_extension_api import ( TaskLifecycleContributor, ) from deerflow_extension_api import ExtensionRegistry as ExtensionRegistryContract +from deerflow_extension_api.plugins import PluginContribution _Entry = tuple[str, Any] @@ -50,6 +53,7 @@ class LoadedExtensions: context_compaction_observers: tuple[tuple[str, ContextCompactionObserver], ...] = () services: tuple[tuple[str, ExtensionService], ...] = () routers: tuple[tuple[str, Any], ...] = () + plugins: tuple[tuple[str, PluginContribution], ...] = () # Precomputed attributes, not methods: hook sites read one attribute to # short-circuit, so the zero-extension path constructs nothing. @@ -77,6 +81,7 @@ class ExtensionRegistry(ExtensionRegistryContract): self._context_compaction_observers: list[_Entry] = [] self._services: list[_Entry] = [] self._routers: list[_Entry] = [] + self._plugins: list[_Entry] = [] self._current_source: str | None = None @contextmanager @@ -94,6 +99,38 @@ class ExtensionRegistry(ExtensionRegistryContract): raise RuntimeError("registration must happen inside ExtensionRegistry.attributed_to(...)") 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: self._middlewares.append((self._source(), contributor)) @@ -137,10 +174,11 @@ class ExtensionRegistry(ExtensionRegistryContract): self._context_compaction_observers, self._services, self._routers, + self._plugins, ): 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.""" return ( len(self._middlewares), @@ -150,9 +188,10 @@ class ExtensionRegistry(ExtensionRegistryContract): len(self._context_compaction_observers), len(self._services), 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``. Positional rather than source-keyed: two specs may legitimately share @@ -168,6 +207,7 @@ class ExtensionRegistry(ExtensionRegistryContract): self._context_compaction_observers, self._services, self._routers, + self._plugins, ), mark, strict=True, @@ -184,6 +224,7 @@ class ExtensionRegistry(ExtensionRegistryContract): context_compaction_observers=tuple(self._context_compaction_observers), services=tuple(self._services), routers=tuple(self._routers), + plugins=tuple(self._plugins), has_middleware_contributors=bool(self._middlewares), has_task_lifecycle=bool(self._task_lifecycle), has_system_model_observers=bool(self._system_model_observers), diff --git a/backend/packages/harness/deerflow/subagents/batch_service.py b/backend/packages/harness/deerflow/subagents/batch_service.py index 659659e7c..e15666ad6 100644 --- a/backend/packages/harness/deerflow/subagents/batch_service.py +++ b/backend/packages/harness/deerflow/subagents/batch_service.py @@ -10,6 +10,7 @@ from typing import Any from deerflow.config.app_config import AppConfig, get_app_config from deerflow.config.subagent_batches_config import SubagentBatchesConfig 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_runtime import BatchSubmitRequest from deerflow.subagents.capacity import SubagentExecutionCapacity @@ -47,12 +48,16 @@ class SubagentBatchService: runtime_config: SubagentRuntimeConfig, app_config: AppConfig | None = None, execution_capacity: SubagentExecutionCapacity | None = None, + extensions: LoadedExtensions | None = None, ) -> None: self._repository = repository self._config = config self._runtime_config = runtime_config self._app_config = app_config 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._stop = asyncio.Event() self._poller: asyncio.Task[None] | None = None @@ -206,6 +211,7 @@ class SubagentBatchService: subagent_enabled=False, include_upload_tool=False, app_config=app_config, + extensions=self._extensions, ) # Revalidate durable state before launching: cancel_batch may have # terminalized this item (or its lease may have been lost) while @@ -240,6 +246,7 @@ class SubagentBatchService: authz_attributes=spec.get("authz_attributes"), knowledge_scope=spec.get("knowledge_scope"), execution_capacity=self._execution_capacity, + extensions=self._extensions, 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']}" diff --git a/backend/packages/harness/deerflow/tools/builtins/task_tool.py b/backend/packages/harness/deerflow/tools/builtins/task_tool.py index 844a68080..b8f3d2b65 100644 --- a/backend/packages/harness/deerflow/tools/builtins/task_tool.py +++ b/backend/packages/harness/deerflow/tools/builtins/task_tool.py @@ -910,6 +910,8 @@ async def task_tool( available_tools_kwargs["app_config"] = resolved_app_config # Assemble off-loop: tool assembly may block on MCP cache initialization, # 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) # Create executor diff --git a/backend/packages/harness/deerflow/tools/tools.py b/backend/packages/harness/deerflow/tools/tools.py index e651310fa..551171f51 100644 --- a/backend/packages/harness/deerflow/tools/tools.py +++ b/backend/packages/harness/deerflow/tools/tools.py @@ -111,6 +111,7 @@ def get_available_tools( include_upload_tool: bool = True, include_conversation_reader: bool = False, app_config: AppConfig | None = None, + extensions=None, chat_model: BaseChatModel | None = None, ) -> list[BaseTool]: """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 # built-ins, MCP tools, and ACP tools. Duplicate names cause the LLM to # 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() unique_tools: list[BaseTool] = [] for t in all_tools: diff --git a/backend/packages/harness/pyproject.toml b/backend/packages/harness/pyproject.toml index 099a1e0d4..b88735834 100644 --- a/backend/packages/harness/pyproject.toml +++ b/backend/packages/harness/pyproject.toml @@ -13,10 +13,11 @@ dependencies = [ # the contract version it implements, extensions declare ranges. A range # here would let pip resolve a newer contract package than this harness # implements, making newer extensions look supported at runtime. - "deerflow-extension-api==0.2.1", + "deerflow-extension-api==0.2.2", "dotenv>=0.9.9", "exa-py>=1.0.0", "httpx>=0.28.0", + "jsonschema>=4.26.0", "kubernetes>=30.0.0", # Lower bound reflects what the lockfile resolves and tests run against # (langgraph 1.2.9 pulls langchain >=1.3 transitively). diff --git a/backend/tests/test_batch_extension_snapshot.py b/backend/tests/test_batch_extension_snapshot.py new file mode 100644 index 000000000..37cac51da --- /dev/null +++ b/backend/tests/test_batch_extension_snapshot.py @@ -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) diff --git a/backend/tests/test_bookmark_plugin.py b/backend/tests/test_bookmark_plugin.py new file mode 100644 index 000000000..9891085d3 --- /dev/null +++ b/backend/tests/test_bookmark_plugin.py @@ -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 diff --git a/backend/tests/test_extension_api_contracts.py b/backend/tests/test_extension_api_contracts.py index c489702b9..95d023539 100644 --- a/backend/tests/test_extension_api_contracts.py +++ b/backend/tests/test_extension_api_contracts.py @@ -342,7 +342,7 @@ def test_runtime_api_version_matches_the_installed_contract_package(): """Every additive contract slice bumps both gates together.""" from importlib.metadata import version - assert API_VERSION == "0.2.1" + assert API_VERSION == "0.2.2" assert API_VERSION == version("deerflow-extension-api") diff --git a/backend/tests/test_extension_api_surface.py b/backend/tests/test_extension_api_surface.py index 0de722a72..8a7f57a83 100644 --- a/backend/tests/test_extension_api_surface.py +++ b/backend/tests/test_extension_api_surface.py @@ -36,6 +36,13 @@ def test_every_contract_kind_added_for_the_0_2_series_is_exported(): import deerflow_extension_api as api for name in ( + "ActionContext", + "BackendAction", + "BrowserModule", + "ModelTool", + "PluginContribution", + "SettingsField", + "ToolContext", "AgentAssemblyDescriptor", "AgentAssemblyObserver", "CompactionEvent", @@ -68,5 +75,6 @@ def test_registry_protocol_declares_every_registration_method(): "context_compaction_observer", "service", "routers", + "plugin", ): assert hasattr(ExtensionRegistry, method), f"ExtensionRegistry must declare {method}()" diff --git a/backend/tests/test_extension_task_store_runtime.py b/backend/tests/test_extension_task_store_runtime.py index a022b912b..b23913aa6 100644 --- a/backend/tests/test_extension_task_store_runtime.py +++ b/backend/tests/test_extension_task_store_runtime.py @@ -142,6 +142,12 @@ _MOCKED_SUBAGENT_MODULES = ( @pytest.fixture def _subagent_env(): """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_executor = sys.modules.get("deerflow.subagents.executor") missing = object() diff --git a/backend/tests/test_gateway_lifespan_shutdown.py b/backend/tests/test_gateway_lifespan_shutdown.py index 258024706..f2a29dad4 100644 --- a/backend/tests/test_gateway_lifespan_shutdown.py +++ b/backend/tests/test_gateway_lifespan_shutdown.py @@ -525,3 +525,46 @@ def test_lifespan_preserves_flush_budget_when_retrieval_warm_is_still_running() assert shutdown_elapsed < 1.0 manager.shutdown_flush.assert_called_once_with(5.0) 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 diff --git a/backend/tests/test_plugin_contributions.py b/backend/tests/test_plugin_contributions.py new file mode 100644 index 000000000..7f3ee0d31 --- /dev/null +++ b/backend/tests/test_plugin_contributions.py @@ -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 diff --git a/backend/tests/test_plugin_tools.py b/backend/tests/test_plugin_tools.py new file mode 100644 index 000000000..0007bc8f8 --- /dev/null +++ b/backend/tests/test_plugin_tools.py @@ -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) diff --git a/backend/tests/test_task_tool_core_logic.py b/backend/tests/test_task_tool_core_logic.py index 29f0ab03c..681d0e494 100644 --- a/backend/tests/test_task_tool_core_logic.py +++ b/backend/tests/test_task_tool_core_logic.py @@ -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.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") 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): 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.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") 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): runtime = _make_runtime() diff --git a/backend/uv.lock b/backend/uv.lock index 7fe80fa97..2b07fabbc 100644 --- a/backend/uv.lock +++ b/backend/uv.lock @@ -918,7 +918,7 @@ extensions = [] [[package]] name = "deerflow-extension-api" -version = "0.2.1" +version = "0.2.2" source = { editable = "packages/extension-api" } [[package]] @@ -942,6 +942,7 @@ dependencies = [ { name = "firecrawl-py" }, { name = "html5lib" }, { name = "httpx" }, + { name = "jsonschema" }, { name = "kubernetes" }, { name = "langchain" }, { name = "langchain-anthropic" }, @@ -1028,6 +1029,7 @@ requires-dist = [ { name = "html5lib", specifier = ">=1.1" }, { name = "httpx", specifier = ">=0.28.0" }, { name = "jieba", marker = "extra == 'memory-zh'", specifier = ">=0.42.1" }, + { name = "jsonschema", specifier = ">=4.26.0" }, { name = "kubernetes", specifier = ">=30.0.0" }, { name = "langchain", specifier = ">=1.3" }, { name = "langchain-anthropic", specifier = ">=1.4.1" }, diff --git a/docs/full-stack-plugins.md b/docs/full-stack-plugins.md new file mode 100644 index 000000000..c189a2067 --- /dev/null +++ b/docs/full-stack-plugins.md @@ -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. diff --git a/docs/images/plugins/bookmarks-detail.png b/docs/images/plugins/bookmarks-detail.png new file mode 100644 index 000000000..b414391d7 Binary files /dev/null and b/docs/images/plugins/bookmarks-detail.png differ diff --git a/docs/images/plugins/bookmarks-directory.png b/docs/images/plugins/bookmarks-directory.png new file mode 100644 index 000000000..0664bb47b Binary files /dev/null and b/docs/images/plugins/bookmarks-directory.png differ diff --git a/examples/deerflow-extension-bookmarks/README.md b/examples/deerflow-extension-bookmarks/README.md new file mode 100644 index 000000000..fa935284d --- /dev/null +++ b/examples/deerflow-extension-bookmarks/README.md @@ -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. diff --git a/examples/deerflow-extension-bookmarks/THIRD_PARTY_NOTICES.md b/examples/deerflow-extension-bookmarks/THIRD_PARTY_NOTICES.md new file mode 100644 index 000000000..1ea845ffd --- /dev/null +++ b/examples/deerflow-extension-bookmarks/THIRD_PARTY_NOTICES.md @@ -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. diff --git a/examples/deerflow-extension-bookmarks/deerflow_extension_bookmarks/__init__.py b/examples/deerflow-extension-bookmarks/deerflow_extension_bookmarks/__init__.py new file mode 100644 index 000000000..34a53fdca --- /dev/null +++ b/examples/deerflow-extension-bookmarks/deerflow_extension_bookmarks/__init__.py @@ -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") diff --git a/examples/deerflow-extension-bookmarks/deerflow_extension_bookmarks/client.mjs b/examples/deerflow-extension-bookmarks/deerflow_extension_bookmarks/client.mjs new file mode 100644 index 000000000..fb48b9c98 --- /dev/null +++ b/examples/deerflow-extension-bookmarks/deerflow_extension_bookmarks/client.mjs @@ -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.", + ); + }, + }, + ], + }; + }, +}; diff --git a/examples/deerflow-extension-bookmarks/pyproject.toml b/examples/deerflow-extension-bookmarks/pyproject.toml new file mode 100644 index 000000000..a7fbb1213 --- /dev/null +++ b/examples/deerflow-extension-bookmarks/pyproject.toml @@ -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"] diff --git a/frontend/AGENTS.md b/frontend/AGENTS.md index 8cd922271..c30be7f58 100644 --- a/frontend/AGENTS.md +++ b/frontend/AGENTS.md @@ -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 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. + +## 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. diff --git a/frontend/src/app/workspace/agents/[agent_name]/chats/[thread_id]/page.tsx b/frontend/src/app/workspace/agents/[agent_name]/chats/[thread_id]/page.tsx index 3a1d5f772..d0a2f05b7 100644 --- a/frontend/src/app/workspace/agents/[agent_name]/chats/[thread_id]/page.tsx +++ b/frontend/src/app/workspace/agents/[agent_name]/chats/[thread_id]/page.tsx @@ -30,6 +30,7 @@ import { } from "@/components/workspace/sidecar"; import { ThreadArchiveStatus } from "@/components/workspace/thread-archive-status"; 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 { ThreadTitle } from "@/components/workspace/thread-title"; import { TodoList } from "@/components/workspace/todo-list"; @@ -417,6 +418,7 @@ export default function AgentChatPage() { {browserEnabled && } + diff --git a/frontend/src/app/workspace/extensions/[namespace]/[surface_id]/page.tsx b/frontend/src/app/workspace/extensions/[namespace]/[surface_id]/page.tsx new file mode 100644 index 000000000..88fcb9483 --- /dev/null +++ b/frontend/src/app/workspace/extensions/[namespace]/[surface_id]/page.tsx @@ -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 ; +} diff --git a/frontend/src/app/workspace/workspace-content.tsx b/frontend/src/app/workspace/workspace-content.tsx index 015e5c801..b60f9202e 100644 --- a/frontend/src/app/workspace/workspace-content.tsx +++ b/frontend/src/app/workspace/workspace-content.tsx @@ -9,6 +9,7 @@ import { ModelLoadErrorBanner } from "@/components/workspace/model-load-error-ba import { SettingsDialogHost } from "@/components/workspace/settings"; import { WorkspaceSettingsDeepLink } from "@/components/workspace/workspace-settings-deep-link"; import { WorkspaceSidebar } from "@/components/workspace/workspace-sidebar"; +import { ExtensionPageBootstrap } from "@/core/extensions/hooks"; import { UserPreferencesBoundary } from "@/core/settings/user-preferences-boundary"; function parseSidebarOpenCookie( @@ -34,6 +35,7 @@ export async function WorkspaceContent({ return ( + diff --git a/frontend/src/components/workspace/capabilities/capability-center.tsx b/frontend/src/components/workspace/capabilities/capability-center.tsx index 63757f159..108be3705 100644 --- a/frontend/src/components/workspace/capabilities/capability-center.tsx +++ b/frontend/src/components/workspace/capabilities/capability-center.tsx @@ -13,6 +13,9 @@ import { useI18n } from "@/core/i18n/hooks"; const PluginGallery = dynamic(() => import("./plugin-gallery").then((module) => module.PluginGallery), ); +const ExtensionGallery = dynamic(() => + import("./extension-gallery").then((module) => module.ExtensionGallery), +); const SkillGallery = dynamic(() => import("./skill-gallery").then((module) => module.SkillGallery), ); @@ -33,7 +36,13 @@ export function CapabilityCenter() { const params = useSearchParams(); const router = useRouter(); 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(""); function changeTab(value: string) { setQuery(""); @@ -64,14 +73,18 @@ export function CapabilityCenter() { disabled={!hydrated} className="bg-muted/30 h-10 rounded-xl pl-9 shadow-none" aria-label={ - tab === "plugins" - ? t.capabilities.searchPlugins - : t.capabilities.searchSkills + tab === "extensions" + ? t.extensions.search + : tab === "skills" + ? t.capabilities.searchSkills + : t.capabilities.searchPlugins } placeholder={ - tab === "plugins" - ? t.capabilities.searchPlugins - : t.capabilities.searchSkills + tab === "extensions" + ? t.extensions.search + : tab === "skills" + ? t.capabilities.searchSkills + : t.capabilities.searchPlugins } value={query} onChange={(event) => setQuery(event.target.value)} @@ -82,15 +95,23 @@ export function CapabilityCenter() { - {t.capabilities.plugins} + {t.capabilities.toolsAndIntegrations} {t.capabilities.skills} + + {t.extensions.title} + - {tab === "plugins" ? ( + {tab === "extensions" ? ( + + ) : tab !== "skills" ? ( ) : ( diff --git a/frontend/src/components/workspace/capabilities/extension-gallery.tsx b/frontend/src/components/workspace/capabilities/extension-gallery.tsx new file mode 100644 index 000000000..86ec177d1 --- /dev/null +++ b/frontend/src/components/workspace/capabilities/extension-gallery.tsx @@ -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

{t.extensions.loading}

; + if (source.isError) + return ( +
+

{t.extensions.unavailable}

+ +
+ ); + const reload = ( + + ); + if (selected) { + const entry = entries.find((item) => item.namespace === selected); + return ( +
+ {reload} + + {!entry ? ( +

{t.extensions.notInstalled}

+ ) : ( +
+

{entry.title}

+

{entry.description}

+

+ {entry.settings.enabled === true + ? t.extensions.enabledManaged + : t.extensions.disabledManaged} +

+
+ )} +
+ ); + } + const visible = entries.filter((entry) => + `${entry.title} ${entry.description}` + .toLowerCase() + .includes(query.trim().toLowerCase()), + ); + return ( +
+ {reload} +

+ {t.extensions.deploymentHint} +

+
+ {visible.map((entry) => { + const loaded = publicQuery.data?.find( + (item) => item.namespace === entry.namespace, + ); + const Icon = extensionIcon(loaded?.extension?.icon); + return ( + + +
+ } + 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)} + > + + + ); + })} +
+ {!visible.length && ( +

+ {t.extensions.noResults} +

+ )} + + ); +} diff --git a/frontend/src/components/workspace/chats/chat-page.tsx b/frontend/src/components/workspace/chats/chat-page.tsx index e05496279..1a2875162 100644 --- a/frontend/src/components/workspace/chats/chat-page.tsx +++ b/frontend/src/components/workspace/chats/chat-page.tsx @@ -30,6 +30,7 @@ import { } from "@/components/workspace/sidecar"; import { ThreadArchiveStatus } from "@/components/workspace/thread-archive-status"; 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 { ThreadSubagentBatches } from "@/components/workspace/thread-subagent-batches"; import { ThreadTitle } from "@/components/workspace/thread-title"; @@ -494,6 +495,7 @@ export default function ChatPage() { {browserEnabled && } + diff --git a/frontend/src/components/workspace/conversation-extension-actions.tsx b/frontend/src/components/workspace/conversation-extension-actions.tsx new file mode 100644 index 000000000..61e990780 --- /dev/null +++ b/frontend/src/components/workspace/conversation-extension-actions.tsx @@ -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 ( + void execute(action, contribution)} + > + + {action.label} + + ); + }); + if (placement === "menu") + return ( + + + + {group.label} + + {items} + + ); + return ( + + + + + + + {items} + + ); + }, + ); +} diff --git a/frontend/src/components/workspace/plugin-navigation.tsx b/frontend/src/components/workspace/plugin-navigation.tsx new file mode 100644 index 000000000..92efbe8c4 --- /dev/null +++ b/frontend/src/components/workspace/plugin-navigation.tsx @@ -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 ( + + {t.extensions.navigation} + + {pages.map(({ surface, href, icon }) => { + const Icon = extensionIcon(icon); + const title = pluginPageTitle(surface, locale); + return ( + + + + + {title} + + + + ); + })} + + + ); +} diff --git a/frontend/src/components/workspace/plugin-page.tsx b/frontend/src/components/workspace/plugin-page.tsx new file mode 100644 index 000000000..ddb804207 --- /dev/null +++ b/frontend/src/components/workspace/plugin-page.tsx @@ -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 ( +
+
+ + {t.breadcrumb.workspace} + / + {title} +
+
+
+ {query.isPending ? ( +

{t.extensions.pageLoading}

+ ) : ( + <> +

{title}

+ {page ? ( + + ) : ( +
+

{t.extensions.pageUnavailableHint}

+ + +
+ )} + + )} +
+
+
+ ); +} diff --git a/frontend/src/components/workspace/plugin-surfaces.tsx b/frontend/src/components/workspace/plugin-surfaces.tsx new file mode 100644 index 000000000..e485e4d27 --- /dev/null +++ b/frontend/src/components/workspace/plugin-surfaces.tsx @@ -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(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 ( +
+ {failed &&

{t.extensions.viewFailed}

} +
+
+ ); +} + +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) => ( + + )), + ); +} diff --git a/frontend/src/components/workspace/recent-chat-list.tsx b/frontend/src/components/workspace/recent-chat-list.tsx index 46132e65c..3f7820422 100644 --- a/frontend/src/components/workspace/recent-chat-list.tsx +++ b/frontend/src/components/workspace/recent-chat-list.tsx @@ -45,6 +45,7 @@ import { SidebarMenuButton, SidebarMenuItem, } from "@/components/ui/sidebar"; +import { ConversationExtensionActions } from "@/components/workspace/conversation-extension-actions"; import { getAPIClient } from "@/core/api"; import { useAuth } from "@/core/auth/AuthProvider"; import { hasPermission, PERMISSIONS } from "@/core/auth/permissions"; @@ -281,6 +282,10 @@ export function ThreadSidebarItem({ side={"right"} align={"start"} > + {pinned ? ( diff --git a/frontend/src/components/workspace/thread-extension-actions.tsx b/frontend/src/components/workspace/thread-extension-actions.tsx new file mode 100644 index 000000000..e39bde945 --- /dev/null +++ b/frontend/src/components/workspace/thread-extension-actions.tsx @@ -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 ( + + ); +} diff --git a/frontend/src/components/workspace/workspace-sidebar.tsx b/frontend/src/components/workspace/workspace-sidebar.tsx index 32135120c..254f0cae9 100644 --- a/frontend/src/components/workspace/workspace-sidebar.tsx +++ b/frontend/src/components/workspace/workspace-sidebar.tsx @@ -10,6 +10,7 @@ import { } from "@/components/ui/sidebar"; import { WorkspaceChannelsList } from "./channels/workspace-channels-list"; +import { PluginNavigation } from "./plugin-navigation"; import { ProjectsSection } from "./projects-section"; import { RecentChatList } from "./recent-chat-list"; import { ThreadDeleteDialogProvider } from "./thread-delete-dialog"; @@ -29,6 +30,7 @@ export function WorkspaceSidebar({ + {isSidebarOpen && ( <> diff --git a/frontend/src/core/extensions/actions.ts b/frontend/src/core/extensions/actions.ts new file mode 100644 index 000000000..a6234e1a6 --- /dev/null +++ b/frontend/src/core/extensions/actions.ts @@ -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(); + 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; + } +} diff --git a/frontend/src/core/extensions/api.ts b/frontend/src/core/extensions/api.ts new file mode 100644 index 000000000..51e4543a7 --- /dev/null +++ b/frontend/src/core/extensions/api.ts @@ -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()); +} diff --git a/frontend/src/core/extensions/contracts.ts b/frontend/src/core/extensions/contracts.ts new file mode 100644 index 000000000..591870e77 --- /dev/null +++ b/frontend/src/core/extensions/contracts.ts @@ -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 +>; +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; +}; +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, + ) => Promise; + conversationText: (context: ConversationActionContext) => Promise; + 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; +}; +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; +} diff --git a/frontend/src/core/extensions/hooks.ts b/frontend/src/core/extensions/hooks.ts new file mode 100644 index 000000000..72d273d7c --- /dev/null +++ b/frontend/src/core/extensions/hooks.ts @@ -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), + }; +} diff --git a/frontend/src/core/extensions/pages.ts b/frontend/src/core/extensions/pages.ts new file mode 100644 index 000000000..08eef5e62 --- /dev/null +++ b/frontend/src/core/extensions/pages.ts @@ -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, + })), + ); +} diff --git a/frontend/src/core/extensions/registry.ts b/frontend/src/core/extensions/registry.ts new file mode 100644 index 000000000..8d436f49a --- /dev/null +++ b/frontend/src/core/extensions/registry.ts @@ -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 = { + 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 { + 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(); + 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 }] + : [], + ); +} diff --git a/frontend/src/core/extensions/services.ts b/frontend/src/core/extensions/services.ts new file mode 100644 index 000000000..ee303694e --- /dev/null +++ b/frontend/src/core/extensions/services.ts @@ -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; + +/** 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; + }, + }; +} + +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( + 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)); +} diff --git a/frontend/src/core/extensions/surfaces.ts b/frontend/src/core/extensions/surfaces.ts new file mode 100644 index 000000000..3696f914a --- /dev/null +++ b/frontend/src/core/extensions/surfaces.ts @@ -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 & { + openConversation?: (threadId: string, signal: AbortSignal) => Promise; + }, + 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; +} diff --git a/frontend/src/core/i18n/locales/en-US.ts b/frontend/src/core/i18n/locales/en-US.ts index 62238663b..93cc4e056 100644 --- a/frontend/src/core/i18n/locales/en-US.ts +++ b/frontend/src/core/i18n/locales/en-US.ts @@ -17,7 +17,36 @@ export const enUS: Translations = { 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: { + toolsAndIntegrations: "Tools & integrations", icon: { title: "Plugin icon", upload: "Upload plugin icon", diff --git a/frontend/src/core/i18n/locales/types.ts b/frontend/src/core/i18n/locales/types.ts index 264b92222..b2f0960bb 100644 --- a/frontend/src/core/i18n/locales/types.ts +++ b/frontend/src/core/i18n/locales/types.ts @@ -6,7 +6,34 @@ export interface Translations { 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: { + toolsAndIntegrations: string; icon: { title: string; upload: string; diff --git a/frontend/src/core/i18n/locales/zh-CN.ts b/frontend/src/core/i18n/locales/zh-CN.ts index 82ec8fdb0..e3e964338 100644 --- a/frontend/src/core/i18n/locales/zh-CN.ts +++ b/frontend/src/core/i18n/locales/zh-CN.ts @@ -17,7 +17,35 @@ export const zhCN: Translations = { 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: { + toolsAndIntegrations: "工具与集成", icon: { title: "插件图标", upload: "上传插件图标", diff --git a/frontend/tests/e2e/bookmark-plugin.spec.ts b/frontend/tests/e2e/bookmark-plugin.spec.ts new file mode 100644 index 000000000..8cc96b672 --- /dev/null +++ b/frontend/tests/e2e/bookmark-plugin.spec.ts @@ -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((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((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((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 | 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 }) => ({ + ...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); +}); diff --git a/frontend/tests/e2e/capability-center.spec.ts b/frontend/tests/e2e/capability-center.spec.ts index 6dd94117c..9c0008f3d 100644 --- a/frontend/tests/e2e/capability-center.spec.ts +++ b/frontend/tests/e2e/capability-center.spec.ts @@ -285,7 +285,9 @@ test("plugin filters remain usable after an MCP refetch fails", async ({ await page .getByRole("switch", { name: "Enabled GitHub", exact: true }) .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( page.getByRole("button", { name: "Add MCP plugin" }), diff --git a/frontend/tests/e2e/utils/mock-api.ts b/frontend/tests/e2e/utils/mock-api.ts index 9fbdfda5d..358a03190 100644 --- a/frontend/tests/e2e/utils/mock-api.ts +++ b/frontend/tests/e2e/utils/mock-api.ts @@ -305,6 +305,7 @@ function runStreamThreadId(route: Route) { * for a real backend. */ export function mockLangGraphAPI(page: Page, options?: MockAPIOptions) { + void page.route("**/api/plugins", (route) => route.fulfill({ json: [] })); let threads = [...(options?.threads ?? [])]; const projectsList = (options?.projects ?? []).map((project) => ({ instructions: "", diff --git a/frontend/tests/unit/core/extensions/actions.dom.test.tsx b/frontend/tests/unit/core/extensions/actions.dom.test.tsx new file mode 100644 index 000000000..803be746c --- /dev/null +++ b/frontend/tests/unit/core/extensions/actions.dom.test.tsx @@ -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( + +

Conversation remains usable

+ +
, + ); + 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( + + + , + ); + 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( + + + , + ); + 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( + + + , + ); + expect(screen.getByRole("button", { name: "Healthy actions" })).toBeDefined(); +}); diff --git a/frontend/tests/unit/core/extensions/actions.test.ts b/frontend/tests/unit/core/extensions/actions.test.ts new file mode 100644 index 000000000..fb6250140 --- /dev/null +++ b/frontend/tests/unit/core/extensions/actions.test.ts @@ -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(); + } + }); +} diff --git a/frontend/tests/unit/core/extensions/pages.test.ts b/frontend/tests/unit/core/extensions/pages.test.ts new file mode 100644 index 000000000..9dcc5ad65 --- /dev/null +++ b/frontend/tests/unit/core/extensions/pages.test.ts @@ -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([]); + } + }); +}); diff --git a/frontend/tests/unit/core/extensions/registry.test.ts b/frontend/tests/unit/core/extensions/registry.test.ts new file mode 100644 index 000000000..b94a310b9 --- /dev/null +++ b/frontend/tests/unit/core/extensions/registry.test.ts @@ -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(); +}); diff --git a/frontend/tests/unit/core/extensions/services.test.ts b/frontend/tests/unit/core/extensions/services.test.ts new file mode 100644 index 000000000..ae03b9576 --- /dev/null +++ b/frontend/tests/unit/core/extensions/services.test.ts @@ -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: "SECRETVisible 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: "PRIVATEworld", + 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(); +}); diff --git a/frontend/tests/unit/core/extensions/surfaces.dom.test.ts b/frontend/tests/unit/core/extensions/surfaces.dom.test.ts new file mode 100644 index 000000000..a7590ae8c --- /dev/null +++ b/frontend/tests/unit/core/extensions/surfaces.dom.test.ts @@ -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, + ) => Promise = 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; + let pending!: Promise; + let finish!: () => void; + const navigate = rs.fn(); + const open = rs.fn(async (_id: string, signal: AbortSignal) => { + await new Promise((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(); + }); +}