From e2f19d83352131f0b8f5654b46cea6985b019797 Mon Sep 17 00:00:00 2001 From: Wenchao An Date: Tue, 22 Sep 2026 11:18:57 +0800 Subject: [PATCH] feat(plugins): full-stack plugin APIs and bookmarks (#5647) * feat(plugins): add full-stack contributions and bookmarks example * ci(plugins): provision bookmark gateway for browser tests * fix(plugins): authenticate module downloads through configured backend * fix(plugins): isolate contributions and localize extension UI * fix(plugins): preserve bookmark agent routing and contain async callbacks * fix(plugins): pin durable batch workers to app extension snapshots --- .github/workflows/e2e-tests.yml | 34 +- README.md | 16 +- backend/app/gateway/AGENTS.md | 2 + backend/app/gateway/app.py | 4 + backend/app/gateway/routers/plugins.py | 107 ++++ .../bookmark_plugin_gateway.py | 56 +++ .../deerflow_extension_api/__init__.py | 11 +- .../deerflow_extension_api/contracts.py | 5 + .../deerflow_extension_api/plugins.py | 98 ++++ .../deerflow_extension_api/settings.py | 53 ++ backend/packages/extension-api/pyproject.toml | 2 +- .../deerflow/config/plugin_settings.py | 50 ++ .../harness/deerflow/extensions/AGENTS.md | 11 + .../deerflow/extensions/plugin_tools.py | 113 +++++ .../harness/deerflow/extensions/registry.py | 45 +- .../deerflow/subagents/batch_service.py | 7 + .../deerflow/tools/builtins/task_tool.py | 2 + .../packages/harness/deerflow/tools/tools.py | 10 +- backend/packages/harness/pyproject.toml | 3 +- .../tests/test_batch_extension_snapshot.py | 118 +++++ backend/tests/test_bookmark_plugin.py | 84 ++++ backend/tests/test_extension_api_contracts.py | 2 +- backend/tests/test_extension_api_surface.py | 8 + .../test_extension_task_store_runtime.py | 6 + .../tests/test_gateway_lifespan_shutdown.py | 43 ++ backend/tests/test_plugin_contributions.py | 152 ++++++ backend/tests/test_plugin_tools.py | 125 +++++ backend/tests/test_task_tool_core_logic.py | 10 +- backend/uv.lock | 4 +- docs/full-stack-plugins.md | 159 ++++++ docs/images/plugins/bookmarks-detail.png | Bin 0 -> 115692 bytes docs/images/plugins/bookmarks-directory.png | Bin 0 -> 77763 bytes .../deerflow-extension-bookmarks/README.md | 102 ++++ .../THIRD_PARTY_NOTICES.md | 32 ++ .../deerflow_extension_bookmarks/__init__.py | 184 +++++++ .../deerflow_extension_bookmarks/client.mjs | 278 +++++++++++ .../pyproject.toml | 18 + frontend/AGENTS.md | 21 + .../[agent_name]/chats/[thread_id]/page.tsx | 2 + .../[namespace]/[surface_id]/page.tsx | 10 + .../src/app/workspace/workspace-content.tsx | 2 + .../capabilities/capability-center.tsx | 39 +- .../capabilities/extension-gallery.tsx | 125 +++++ .../components/workspace/chats/chat-page.tsx | 2 + .../conversation-extension-actions.tsx | 121 +++++ .../workspace/plugin-navigation.tsx | 51 ++ .../src/components/workspace/plugin-page.tsx | 72 +++ .../components/workspace/plugin-surfaces.tsx | 106 ++++ .../components/workspace/recent-chat-list.tsx | 5 + .../workspace/thread-extension-actions.tsx | 20 + .../workspace/workspace-sidebar.tsx | 2 + frontend/src/core/extensions/actions.ts | 72 +++ frontend/src/core/extensions/api.ts | 28 ++ frontend/src/core/extensions/contracts.ts | 83 ++++ frontend/src/core/extensions/hooks.ts | 37 ++ frontend/src/core/extensions/pages.ts | 29 ++ frontend/src/core/extensions/registry.ts | 129 +++++ frontend/src/core/extensions/services.ts | 86 ++++ frontend/src/core/extensions/surfaces.ts | 52 ++ frontend/src/core/i18n/locales/en-US.ts | 29 ++ frontend/src/core/i18n/locales/types.ts | 27 ++ frontend/src/core/i18n/locales/zh-CN.ts | 28 ++ frontend/tests/e2e/bookmark-plugin.spec.ts | 457 ++++++++++++++++++ frontend/tests/e2e/capability-center.spec.ts | 4 +- frontend/tests/e2e/utils/mock-api.ts | 1 + .../unit/core/extensions/actions.dom.test.tsx | 174 +++++++ .../unit/core/extensions/actions.test.ts | 62 +++ .../tests/unit/core/extensions/pages.test.ts | 102 ++++ .../unit/core/extensions/registry.test.ts | 105 ++++ .../unit/core/extensions/services.test.ts | 165 +++++++ .../unit/core/extensions/surfaces.dom.test.ts | 115 +++++ 71 files changed, 4295 insertions(+), 22 deletions(-) create mode 100644 backend/app/gateway/routers/plugins.py create mode 100644 backend/extension_test_fixtures/bookmark_plugin_gateway.py create mode 100644 backend/packages/extension-api/deerflow_extension_api/plugins.py create mode 100644 backend/packages/extension-api/deerflow_extension_api/settings.py create mode 100644 backend/packages/harness/deerflow/config/plugin_settings.py create mode 100644 backend/packages/harness/deerflow/extensions/plugin_tools.py create mode 100644 backend/tests/test_batch_extension_snapshot.py create mode 100644 backend/tests/test_bookmark_plugin.py create mode 100644 backend/tests/test_plugin_contributions.py create mode 100644 backend/tests/test_plugin_tools.py create mode 100644 docs/full-stack-plugins.md create mode 100644 docs/images/plugins/bookmarks-detail.png create mode 100644 docs/images/plugins/bookmarks-directory.png create mode 100644 examples/deerflow-extension-bookmarks/README.md create mode 100644 examples/deerflow-extension-bookmarks/THIRD_PARTY_NOTICES.md create mode 100644 examples/deerflow-extension-bookmarks/deerflow_extension_bookmarks/__init__.py create mode 100644 examples/deerflow-extension-bookmarks/deerflow_extension_bookmarks/client.mjs create mode 100644 examples/deerflow-extension-bookmarks/pyproject.toml create mode 100644 frontend/src/app/workspace/extensions/[namespace]/[surface_id]/page.tsx create mode 100644 frontend/src/components/workspace/capabilities/extension-gallery.tsx create mode 100644 frontend/src/components/workspace/conversation-extension-actions.tsx create mode 100644 frontend/src/components/workspace/plugin-navigation.tsx create mode 100644 frontend/src/components/workspace/plugin-page.tsx create mode 100644 frontend/src/components/workspace/plugin-surfaces.tsx create mode 100644 frontend/src/components/workspace/thread-extension-actions.tsx create mode 100644 frontend/src/core/extensions/actions.ts create mode 100644 frontend/src/core/extensions/api.ts create mode 100644 frontend/src/core/extensions/contracts.ts create mode 100644 frontend/src/core/extensions/hooks.ts create mode 100644 frontend/src/core/extensions/pages.ts create mode 100644 frontend/src/core/extensions/registry.ts create mode 100644 frontend/src/core/extensions/services.ts create mode 100644 frontend/src/core/extensions/surfaces.ts create mode 100644 frontend/tests/e2e/bookmark-plugin.spec.ts create mode 100644 frontend/tests/unit/core/extensions/actions.dom.test.tsx create mode 100644 frontend/tests/unit/core/extensions/actions.test.ts create mode 100644 frontend/tests/unit/core/extensions/pages.test.ts create mode 100644 frontend/tests/unit/core/extensions/registry.test.ts create mode 100644 frontend/tests/unit/core/extensions/services.test.ts create mode 100644 frontend/tests/unit/core/extensions/surfaces.dom.test.ts 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 0000000000000000000000000000000000000000..b414391d7b3ce28864f581f70ff341ea4f90dee9 GIT binary patch literal 115692 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 z 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�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

    Q!O@ciJu($fXEcY0LH@960e^ju`+4!0UT0@Wi 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 literal 0 HcmV?d00001 diff --git a/docs/images/plugins/bookmarks-directory.png b/docs/images/plugins/bookmarks-directory.png new file mode 100644 index 0000000000000000000000000000000000000000..0664bb47b59627fb12efd89dab9b111f406e1d56 GIT binary patch literal 77763 zcmc$`by!wi^ey_SASD8l(jg!q9g38+fOO|ecStuc-7O_4-QBIUAl=>F-MnY1-*mj4=t4krG8k#z+3=pMOxrJ_^bG^AEhtKmWktBR&Sdq0Q?^ z|K}g{f5e3N6`Ydy=HZ>NCh?yxdDZ=M9Et9OF6iC(`>KT(SAXqS^PwEa@%QEZ^s!f6 zB)a3ON2B1TrDG)-5^>Ar^x9V*E*lXChlkS$W2z2+6rshegakZWTU)Qd-`8u#UPg3` zKbLH3yJy;;Uk1KL)BP?j^yi8}U|ymo|8w>F;2mN9y?34e*;UZrdrwH8-u=B*$Qk{K z$KP8sxC0;Y{rgzrj{m2Rjm$Z!4hj!PLO}@$3ZhrY`nbqG5?W4Ihg8C3Vpb?1%Ww+}QYF?^mFFl&(8!R?G z`P=6-GzZdnERBs{yOD~^gH?Y6DZj}gf#MB8COW=9gVnBn|Iu!|WwKa;+Hkqg7r37; zxf>^bzc<|ok&q}Fnr8NJ>KayG_or3|2F_g_)UGHR_X6tOW7Lim7Vq`4c z+}iv#eOfqKBpOhA8L~k?o9?!>wEWn;Urt^=f{5!S5fT00SRRJDmw7IJ|Nc$kXY^Jb zY>DiFqM+63_#_`QphS}sm+-|%j=K@u^6)S-yV=_}D&M~Mo8g8NEwtKA_lohsOwG;N ztOkC~)*hCq){^m(JxU{YQT{ak^yyQcbC-+V($)Y!KZExsY?$A|mM*tj7h0U1SN+H3 zJyIU#(fB6vH7n)29pXzhgaayVt_Xu>$DN=TT>JrOBnR5I7lkdw0sf6C7)sjr`P5DHcX1QHA_OI7yUhI7wLnEGpKp=~~1)slkp(+<^1@NPL;nL{NfAg3>><(L~cOT6@ z&1GX_%Nmw?u%8+Hl3TJ^$2l5A?kVcDn5T2^VsEx;f%JCr!QEA_>f`F=?rhoVLIn7$8 z!u*5Ms9-IJ-!YDh)1cpd#pEk7Kipj1SS{8oUwJ!&)lEw1PqmtFkU@z~?u&1kcfB}2J?$1a7|Q3uC(M4q@4y-;SbI2!U17AEL!aHJ z#Bd-t)7G0VAD_CsT{ESb;YSdp0qEJgB zuK2^!{f&J`SW>MFum0BS^1Fk1*VpkYaK}G9I)kz4AuUBK#*;;AHDnjeeD= zH?Ev?jymzU+VHXU+$6w8@x-PVW=y&Qn zTWt{r6OBljsyCC4A0TqQ*z(uAl+PWUnVn^BhE?xQrCZ3!olID8Wn^UdGAd%272aK+ zwmQ)?26T?*4i+P;XK23R<<*KzK{*`}_9#cfe~j}+?Ra%?bYlQ=H+P#&$~Dik5f6cJ zJ6*j)KAeZX4x{JfeCZy%iKtQMWYBBHI^4cfozyW3$8>f4#VF^VnZPfprm2~Mii*l% z`-J^9{~(?aAsRmEM9HFeTmGn#m>>TSGF^5Y2CbJkaXtZ#9UWb!W@ct4CWg72_Xy89 zxv*jOC(>PA6Q5dnCCyek!bqTrx(2H% ztvaU?@uk5Oviq3i6qyYUhhYM*PMg>FJwau$%ne?W9&qi^ocG z#tHZ?!F#@?*yn$aK$vT(C**j>Bx?xb=XZZ{Dx^Q0lB}=ut9tismb8zLVC3eT<(**5 z&Qas$rc{c}uVG?H4Gj(Vw--wEj@H&VYu9f1Ya9KuSBJEWrK*|uJEx~>`JEwL=%YqIjH70DFth)&7kgZ_MhFeY4WKzQ=j zT={;2tYBLk1X54iry$zhk;>zkFIl+Z?qctLayGr3NiFNN*Rj2}h85bx->ilQCyDfW zuFT}NuiZh__BrmE8B5^8harc@tD<*MO#{> z*Ozm*esvI+tF7aH^;$aRf^RBDZVJt6irdApip%s#6JNi6C67LwX&(IM<&Ji+=zM!I zzYXn4eQ^U4)?F)Z7#^?wX&1ZeCD8}pLzSYfCeF&E;UDf?h02A|kBX40bFx(PI) zSw@GCKs-EZ8!q3vuI#RKE`j@veTKQ#sg}BzS!`Uk>TJ#vNn6Qaam8FupW#o`Iu4Rh zyG2AlJ>PxL$vFUXU9L8(NZ@<-DobyZcreWs)B_YN>W6nPkNMtP&ee?Y80gwo}Yb)P=1kk>x4SE;mQ^`7#&cLm%0s<#y5 zqm&fOTh@E)yM(9bW+6Ufh3b`|u413Eok#t3X9&X{>xrUO)97lpXuRaVFnA^`nd;iQ zH&aiPqt&ct5IXb$LNfkgrqyECFb-#9f3`-b(bLIK%0Wn26t~ym`{C-Uo_toIX?=FK z@Mlj)kPnBy%hpO+>=Aa@2nx2>R~p68ukzaW4qjC();X(drYROG2oSeuOtSK`OOeUW+%m1m!vu_{f@RmUSv{IUp7ka=e!$- zti;6I%Wp{9PWK}HQQC|eQ#M^Bqv+9&=P~I=nR=d(3|| zZZ>H)Zc(MPFCL1Cu!!k#rI^7F>c$oEkaPNqs;GZlJROXKxn$I;oYP6;M?XZMV`rlp0Lr?vHbFYO0RLKHSSWJPODprV4MW4gpG156aH>US zq+zef$d0E^ao2krLQah>6!FUM2j&rK4(9@%!V8`24l4Q4cRpZXhx~NQx^di_!NtX8 zlzJ>d!_EDe$d8`>p*Xe36WNGCp2iQ*&~P@_j7UUw~^Bq={m4fPIclsq%$01FNwoN=IZ? z)}IRRC1<@LxmLX&!6*B@t2Z(>grtgvO6vi{Xrdw_l&@Z4(dg30P8Q4e zZTeBO+#%X$eSYjti@K0yE!f0D#QMCY6v9)g;%zs#c>0j|FpEV3S>%NF%OG!16KRjUDgW z=NC1Bk)?}u`#ZEKqQ~?y+_mu2HmvfapSnj&r7x>@Yri%B!|B#!F_ES7MdW-qyv%)b z!5ty|Y;QpZxJ~Vp9 z#pRTjW6)mTTJa%qVqp>#rbj_UMY)phDa^I1U5M1SI+%!2(Z4`b7Ak~CzkNgh)io_I zTCOrtGFxXmNn8%C68c8M=7(5O`NXZbxEPIuGu0&R(IYQigbPk%l(K<0)TKfVuk#&> z@yW>2bEVQc*E%-cUl+)&=TiaqB!$h@MVmylxK!{e(2cQniv}T@GGKrh3IzA zJcLxt0~EomtgJre5{f&K6Nvq@z=e5@6oR)!wf46Mlcf}qwQ1}|{0a(eE*z}vQN()A zH+z0VLuG-1J$lpwfw-{T>}(xtok~8(hYBZlt{SNCjq3$*a9EfizFQXr+Um4zWMve> zE1g#FDRT#kE`WV)xzk^(6Sk)Iaxc?CjyI;j<2yF0x*>a$#%XrIhl0b!g)J==rbT%NH8llVZyx6zzbTLxsijhRowkfWefo9AXGoSIlB74m&iwXm ztH)}bQ=;eHOd7-fd%OLSdY_#Kov|Jrvk6O;W<#C=FVhIZ`;^>Gd3pI!wQAFEoy#NZ zg({8-Ap@GO`AKp4K|!QYVjUNk2G{Lq;}!mXaO?(?c_1aa=aZ$j7m<8__QrgrGvf^V z!!xB~Q|u<+(l3P}tQp3>_bci`4~$wjIQEe#wN~2NL$!{z7F-v}oURy^)5Q|5z7gQHKQ6Xdpu|*}4(lI2 zwBvD7QKxKL9Jwm@n<0)j6EAu#pz*Rx0)xLIXetQc*5zdA8K!B*HJ6)ku+Ux%hhq$GqSt$0DFbl0sHy$X90z1dR$zbUAvpC zfWYIM81nI=X6Mt5o3PoBs=K?JMH}9GJ3f>7`6hm_=50?zGa=CankGzDUVa}jppzoz z^R?;d3RYCyk$*u%q?so}MmAgatTSqF-tnkAjf$@UN*BV#4+jT_^G4;vcIeYLaG%(? z9K$0e{WX0bQG&ESfJf7;PvzyfxPQ^79nF z46foj88JHq-bPLFPbenq`lq#MLKOUA_nlo`LGimNX~@yDJ&_b<^lpS=Vq!>0NK{mu zb^4@^T1`$tIB=}2scL@+J!j-69ybo_<^NK0A2Aq7D1SeZoc#YccJKcxX(;KnMSz2Y zL-m&jS{HtHwVh;WY02>4&)nJnuTY#>G;4EugSo_)#Gt)27>-CywVY#4ZUa*e!2Z}wd>jV!~~9n0K|`8t-|!kbD^e2*f(Nhqqyd((~Pp-EjBq*=+iLpdK^Abw8if)>kU$LOZ{b# zgoGdwa~kcXuXuRe`y2YN*-y}3PEkW2lB-Fv-l!}sQw)w|&#~}eEX|y&Pg>3ehlVV4PoKu8y6}*^&tv30cU!48T1*GnI-Rf|9q>G+6IEUywFc`Qc`Vz4TJrt7 zwxFO7Z7khbiK^rqQ3*JI_oE?sb%{c?YH@qB$qzaL0xJ(bqG_(JJv~l(7K(Ldn-1{E zcp0^>mn1rO&|k^Qxk_P)>;?ZVFQn`aaM!j-+yT4Hq)^GRDAPT)dkKKF%NVoXfv70LYT(2xj5 zU5o5@{zJ+-1RNI95|U=q8P>80jbCyH=9}!5?|KIL0C8L3TON;k3OPA|P}t zoyuDXsa5iEsTLFvd}}r9*M_{_`>p;F>Pz~hx7Iea|Ar-oJLe{rmT^v@wFwd{k6QcOTW&3n6Ki z`1trBDu1(X5v5RBgj+TC0uB>}!Hy0*o~*2#Ct|N=k8$y!Z{~B>s0$1YM$D z5sa0FP1X>(p04*zX3MLSZB|uRH#%6>n(k9o5xsklgN-seI!Zy2c{EdJ5HM!+LAwm> zIB14IZB)aq`SkJA-cZXph*Q8SYc(BNJJ=}T+kW^E>ElC`;eKo}+J%}G@#f7hkQGn5 z5i-a~$;dF~5%&(A=0pXbEM%Yx{7aPIm^L5M+>^^=>S5|pkkv*Cm#Ss246o)wnvpRj z(2rJITT)Yl%FC|=EEj();P`DhU5{J>B#*OUDAj4OIdyw;eS3}Yo(o$UpI#&*3PLeT zLmMY?Nu*Nl(w^QMrtOuF2|61W($*R5ipcq^dRD z%d4c={@5I1z{-Vxp-9_ee&o(RW#(m{j_axeS4TmuLcL2je zJV~sMNo09!PM$u!5np|yTw|xplb`JYkBAI^IBv0xPns5^k`hPcwfn!Rb1NjCk&J^|^a;1yTD|*b3%PJn26?7>wRuKqX_|=@NDH8C z^Siv*=qJ$MMn!$5Ab7SHBK>h_bUe0Xd~kjZC_+_LRSUWJXW2nPK>$?Z{wr{05E@-| zqHI%;lOvq%nwT69`%uD7!9PRwyVU!Zqew3LWfWxOzxM{Vyx<%-@@a)?rwhP4lepf@ zFt`5|(wtQ+4@Rua)c+HFG8m`28zlbK$S~YR{=d}I{;yWt|55)Ub0X<}kfn=>{S^26 zyW0PT4f)NS4dxM}M$?I+ic=1Ws#-z>A+OB82RdVrw);_QL@7zey>(_lVgeqW`x#)b z7E$7FZ*FF1XOTHENYc`pJ{PuAza9{y>XeuI~ELWDbO>*K3Z z{C%JN{Gg(MD<`s9?ldPNE^8`sayd8ku4et+ILYejYQ5DJsnqwIDr01~k6`0wZ;6OP z)Dn`A{;kuCO@q#(Eus-=pq-p8C|e)mcZU-4<*3JBNH?dJYSpUt)W5Y_AmnuDot%XH z_XQe@Rg7jmQ)%z)OYCTBqbW5iw@~j@)Nv0+@-152u8(SblS1O+CO|=JZ_^6lxw^SN zoNDHyrJWgRYjT}@E+XP5gLgw~KG-5iMpg>4s)lCrOpPTm21d2+xr>_}h=0&PNhiMJ z;N^XE-vmyzfDv`KgN54G&dz@YaPe7K8Zvfw%pav6*y(4P^znE zaefsPA5jX)MqZEn2?3nCi-^doh&>?XEa#d+(vy=3hxe~L`kL*6Ocm+W)YSH-ss{)8 z{;~D|b&_t?>|ytcigLbT-;2VA4HLAC18r?{A0Uv(dHx|-b_B%drn;{#ey;iQTXRY= zC}ghz2Uk;BO`|x1Iv;o~KRN;jPB74fluFcUEpDZ8+1R=K~cuN@IBR^v!I zTfG{|7XMeyOV<#=0FaXWyV9ot!AFAambTm(R{wDUnlp3@hG=9B4a}sjR6U1XjCn?8 z@JsMX-Dt@Zo$ zzfsW8m~2gts1cFt5;lDWN0O_ke-p)lpBkDBU-8iRLXT#nybZMtMXiI6A+2~yl(Z~8SUDQMUn@UIn%(&pxKG5M__^O8wyH=d7LyPI#T zci3Evtma+LzMk}Dk+hJUA~BR+fpDajZtk%Bo9NC`Z3vIJ-c7~Q?H;K7`Z3;#gmlq- zixz>;{IMsNd_t*be6m2bTHEWf?$2|AS~Ma~W|t0admnD-!wY&qA){iDJlfj7XMsMz z_>4)}&-c&afePIjJj2DsNp4YR*39L+aGqXZ5OlY2BWrLU3ql}hO zqk)I}LEYob&B12CxP0Dj%iq(Wg&6>Sz7cwPGi{5Tk`mll_inu*4UH0P(9jY_FeTuJ zwpm>_s#qJCC5?a@?`k$TAgn#KY!dR$IKOk z_yw)^G;Qy0lVu;S?gm&tG=IJhRwNigE5Ln;ZD49hj_$7W;>8P*2qMO2VZ+xe&pHxo4Y@E51K0wQ92y!45AWxrk2#safq@@CeZs0hEj9$in#pusEj<3P*8MGg zUk<9)2e-y}1z;e)BXGc}TQ z`2jy$4L!a*F6uUC_#~#FfaU*UK~Rbs|78%7PRt*;wU}_(fSCYv1Hby08D^aVK(7KR zU5(A`Zs3RHr%>0+J)5PW@_ARZU`n7kyst9LpUJhOD)wo%cRGlz4{(4Q6T-ghmYM6=Z|0$Z2>pA{4m@@2ttx@tmv-4GK1 zrLRmLbCAl-5UYr^cjG1dvtciPECk1z0VdUH_k!q{&`BA06B)bra(lj6ZJc%lPbBR6 zVS;|z?--;@+`+Uahx@KIno*GQX*@tTl?}pp7Ld2u+Zoo6C|X@FQ$d`2b0dnTA^LZR z5|G`K0s$dtYN2-eEu$}+US)p6zM^z?9~xOQs3*ev&xRZs4i^rjTi zjQv((O!Axa&wE!tP1$$S^|m7U`{G)g%OKLyhaKfl#4p(iSuO9v2x>bH*86Z8;u*5p ziosdq#9@Vr%ZtT$@U9F> z!Z!Ue!lQ22+UhE>T=z0BN^p0)xYF*rx6+6)P-6VYj~1)LRFy2sbF_{CDf*dhBd&>0Sf1eg?Y>grNZw5A5*(75r~Srfc9crObo_HF1oY5l)lYv+$>ox$%5m{gx$9m6M$e|vjJokwVFh+U(7a^{gOL;w1`5J6dd2{zH^-*5x;*% zz)}Xj{p*DOGN0|{0EBej{P65&dn}z|3~#4a&qzbwR9M~p`l+b+K2`*=t5UI+vdivm z+i1tD(^JnJsaw74V0PWhL`8{VHqYm5rej5;GQkbl?B+6Pee!er+VIup_ZL!dt6kyT z&L_{*XCH(U%@m}@E^S{6?TAEcXjY*C zq3I1hlz9fY%FIVgY;(NclE(AeUEz(Zrot}C$Bjt_l6hKP2YHt@ZQjau8vo&wTKGvw zmy{K!Ms(N8ynGPO7?j4eNHc!D=0z^?O_Hbyz;tuJ&oU;VIlFgtguCGpRQFtg-vs69 zmz@eY&B7=fWF!x_RWu*nCD!F4Tb=tj@;|p-Svy*3I$z%%1BNwQAj0+X$rB$IBIk$$ zO~=Ez3bSrQyN`axE;58h;ZxJo3$3n1)v@9QO8LGon$Wu(jTc2|`E1VWRy=bDlfN{~ z)|zF$-xwNFmUHwwS@%uGd*|e9x55*54UA6|?N1n!xnyfX8&!Tw9owAV!GJ=JV|cjj z&1UWC((4pkPvYo{8w7d?Gh|{yNv{4~Zl&QT*TJBN_t(@#@u<5>f6((;m~*MwRi#Xs z_M;{^^4`!9qOo9Xy2O3{D8p82zmP&@1`KI~QibxiqL-uDCEZUZ0-2;qZo-=#*du5-F=OyV9457N$b7<0?|MkbC_-;6eWl)P*Ga5t z;H7~|vxCo$;Q4cssZ}bgX&TLftehOf0IB)T6W{_tIx%8fg8q06@9kSleIa3c!;8sN z2UY(0q8{o&`&&qNALkYKPYbNbxq2t&bh;51X#p4=mnCaKHyW)vOC6nXyOODt?fOM% ze{zWv_}wz9i$@(HLO1$+zID5+U8!UM9{!k z*x60S%37|5Pd68WsWCo`QiaW74XnzT`+x84y(tLLkggIwr3$_H?eT0eAfW?Itm$4x!}@rd(OS&XubJ}O3rwJxtd_yh>l&@3oh-F2iL%S}=%1+C1UtoPEk zjE{~A{$iZJBHhJF1MeT&*!Z`cGlATb7J!9@a-z+3`v?kppEh9$=vNlWo^)MdB88nJ zSTru9`_Uq>iY`6~c5HSTq=kf3nx|t z=%iOy$F96Y7k?r?AM7aYmU}c=S~+d>=~K~)x~3*kHZdR*e5HA1e{<_MIZR8`H{9TK zzUgcSiG$ooM^P{Chgh_hi50b}Z}~83~DAV~S;zx0QrXtikz5*Lh)iWOf92 zJ%#i(C~@i0N~Z)HCDv=wMt3m`GdhSL4LtO0K4kzo`1IgE~VCe;14vOs6`s-#fl5 zR|P3FJjd?CnV!PJSUi|%u3hv@-|2GOzv-{4V(Tni2XT|t*qizD17GE)u?IQ;=`jt` zDZH52*!0a7lQq>1>B%W6LvdvN9xyM#%ftEfL>si{&t=^zG(pd+MKiZw3T<7OZ*agF zyOk-g&GzfeO?&lr6Qi=)4`g++jV+zRPkE4&2A3gS!I=B|Ipo-gZ*Z zZFfm~v0RXg=9Bxw99M`jNe+yemE!`I>qarBS?lrTY@%~|dt{w%L>(?pt_rPNIvJ;e z&2?Zg*lP3Ft>_Mea!u$FF;=OSvP;9GZ#3!3gO<9NUOu8ysV?f$RV+Z{kL@(Rz1XI) zhcww4^o}jYx;Wk6oDVH$X4m4S1E*+jEDRd<9SGg5EG$?E(^by9-+xdX;j`)M11IT` z#|uo%&TwhYKhf1I0eso+#DMq?c5D+hZRTIaLBU}-u5$z--I?Cr4FF<`bSu9c8W8(x zO{ag!NWI~Af^MHEmJ|5c?aa{a(qssKgMHUgF4YvBasw5Wjrk)<`u;c2{#`gY$RWwy z=$-|R-Oo9rWPQOHySClfgW|loI6b{QKNsvSYTXbciKV;Mstu=;S%USGgI>(-oc2>b z!nW#@gdf$r9KrMR4R*aQRmQ_IO;HOOoqg{e_w85Kws9G?7Mko`&lAtb&Dh@oTgq5_ zTB|hnn%SeMRuyl9t5HVzY+G{Rl43LOnA+!ZSYBU0f8$8a4w`?7`H?Zci@yO`xHeen!&lxt>_1!S>=yf$+q4X*|I_ZGh&#?y={SWyNtKigUqbwYD-aY+!} zmO2j~$ljSOWHmfPyu+0+9WS!9w9Gk6er$ia8EXO16~=*VlaaD=KRO`z6jjD8 zWUO|@0tOH+aB3DzJ@M8tGNNAA)X+>R)q3yK74zoi$D6%{dU�g_V^RyR{+WPLHw} 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 WfAIC2*%|l>hlZ+_O5V*U|NI}fQa&C4 literal 0 HcmV?d00001 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(); + }); +}