mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-09-25 22:16:19 +00:00
* feat(plugins): support manifest-backed browser assets * fix(plugins): validate asset roots and clarify module timeouts * fix(plugins): sandbox asset documents and support Turbo imports * docs(gateway): keep guidance within the merged size budget --------- Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
140 lines
6.4 KiB
Python
140 lines
6.4 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.browser_assets import LoadedBrowserAssets, valid_asset_path
|
|
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
|
|
if isinstance(module, LoadedBrowserAssets):
|
|
revision = module.revision
|
|
entry = f"/api/plugins/{plugin.namespace}/assets/{revision}/{module.entry}"
|
|
transport = "assets-v1"
|
|
else:
|
|
revision = hashlib.sha256(module.code.encode()).hexdigest() if module else None
|
|
entry = f"/api/plugins/modules/{module.module}/{revision}.mjs" if module else None
|
|
transport = "inline-v1" 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": entry,
|
|
"transport": transport,
|
|
"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 not isinstance(plugin.frontend, LoadedBrowserAssets) 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.get("/{namespace}/assets/{revision}/{path:path}")
|
|
async def plugin_asset(request: Request, namespace: str, revision: str, path: str):
|
|
_principal(request)
|
|
if valid_asset_path(path):
|
|
for _, plugin in request.app.state.extensions.plugins:
|
|
module = plugin.frontend
|
|
if plugin.namespace == namespace and isinstance(module, LoadedBrowserAssets) and module.revision == revision:
|
|
asset = module.files.get(path)
|
|
if asset is not None:
|
|
return Response(
|
|
asset.content,
|
|
media_type=asset.media_type,
|
|
headers={
|
|
"Cache-Control": "private, max-age=31536000, immutable",
|
|
"Vary": "Cookie, Authorization",
|
|
"X-Content-Type-Options": "nosniff",
|
|
# Assets can also be opened as documents (notably SVG).
|
|
"Content-Security-Policy": "sandbox",
|
|
},
|
|
)
|
|
raise HTTPException(404, "Plugin asset unavailable; reload the page.", headers={"Cache-Control": "private, no-store"})
|
|
|
|
|
|
@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
|