mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-09-25 22:16:19 +00:00
* 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
108 lines
4.7 KiB
Python
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
|