Wenchao An e2f19d8335
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
2026-09-22 11:18:57 +08:00

108 lines
4.7 KiB
Python

"""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