Wenchao An 2a9beb34b9
feat(plugins): support manifests and static asset directories (#5685)
* 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>
2026-09-23 15:40:22 +08:00

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