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>
This commit is contained in:
Wenchao An 2026-09-23 15:40:22 +08:00 committed by GitHub
parent 736b5a4216
commit 2a9beb34b9
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
30 changed files with 1253 additions and 182 deletions

View File

@ -7,6 +7,7 @@ on:
- 'frontend/**' - 'frontend/**'
- 'backend/app/gateway/routers/plugins.py' - 'backend/app/gateway/routers/plugins.py'
- 'backend/extension_test_fixtures/bookmark_plugin_gateway.py' - 'backend/extension_test_fixtures/bookmark_plugin_gateway.py'
- 'backend/extension_test_fixtures/browser_asset_gateway.py'
- 'backend/packages/extension-api/**' - 'backend/packages/extension-api/**'
- 'backend/packages/harness/deerflow/extensions/**' - 'backend/packages/harness/deerflow/extensions/**'
- 'backend/packages/harness/deerflow/config/plugin_settings.py' - 'backend/packages/harness/deerflow/config/plugin_settings.py'
@ -20,6 +21,7 @@ on:
- 'frontend/**' - 'frontend/**'
- 'backend/app/gateway/routers/plugins.py' - 'backend/app/gateway/routers/plugins.py'
- 'backend/extension_test_fixtures/bookmark_plugin_gateway.py' - 'backend/extension_test_fixtures/bookmark_plugin_gateway.py'
- 'backend/extension_test_fixtures/browser_asset_gateway.py'
- 'backend/packages/extension-api/**' - 'backend/packages/extension-api/**'
- 'backend/packages/harness/deerflow/extensions/**' - 'backend/packages/harness/deerflow/extensions/**'
- 'backend/packages/harness/deerflow/config/plugin_settings.py' - 'backend/packages/harness/deerflow/config/plugin_settings.py'

View File

@ -2,12 +2,12 @@
Studio retains sanitized creation metadata. Studio retains sanitized creation metadata.
Capability Center's `business` adapter validates only the bundled provider's Capability Center's `business` adapter validates bundled-provider credentials
credential fields and creates a normal MCP connection. The MCP API accepts the and creates an MCP connection. The MCP API accepts only the exact isolated
exact isolated interpreter/module/provider launcher generated by interpreter/module/provider launcher and credential environment keys generated
`deerflow.capabilities.business`, including the exact credential environment key by `deerflow.capabilities.business`. Reject arbitrary Python commands; manifest
set. Do not allow arbitrary Python commands, trust manifest metadata to bypass metadata cannot bypass execution policy, and credentials stay out of tool
execution policy, or put credentials into tool schemas. Keep admin checks, masked edits, atomic configuration writes and MCP cache reloads. schemas. Preserve admin checks, masked edits, atomic writes and MCP cache reloads.
Memory shutdown resolves hot-reloaded config and the backend, flushes, then Memory shutdown resolves hot-reloaded config and the backend, flushes, then
closes as one `await_drained` operation. Keep config resolution inside the closes as one `await_drained` operation. Keep config resolution inside the

View File

@ -9,6 +9,7 @@ from types import MappingProxyType
from deerflow_extension_api.auth import resolve_principal from deerflow_extension_api.auth import resolve_principal
from fastapi import APIRouter, HTTPException, Request, Response 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 from deerflow.extensions.plugin_tools import plugin_settings
router = APIRouter(prefix="/api/plugins", tags=["plugins"]) router = APIRouter(prefix="/api/plugins", tags=["plugins"])
@ -30,7 +31,14 @@ async def list_plugins(request: Request, response: Response):
for source, plugin in request.app.state.extensions.plugins: for source, plugin in request.app.state.extensions.plugins:
settings = plugin_settings(source, plugin) settings = plugin_settings(source, plugin)
module = plugin.frontend 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 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",) public = ("enabled", *module.public_fields) if module else ("enabled",)
entries.append( entries.append(
{ {
@ -39,7 +47,8 @@ async def list_plugins(request: Request, response: Response):
"description": plugin.description, "description": plugin.description,
"viewer_id": principal.user_id, "viewer_id": principal.user_id,
"module": module.module if module else None, "module": module.module if module else None,
"entry": f"/api/plugins/modules/{module.module}/{revision}.mjs" if module else None, "entry": entry,
"transport": transport,
"settings": {key: settings[key] for key in public}, "settings": {key: settings[key] for key in public},
"backend_actions": [action.name for action in plugin.backend], "backend_actions": [action.name for action in plugin.backend],
} }
@ -51,13 +60,36 @@ async def list_plugins(request: Request, response: Response):
async def plugin_module(request: Request, module: str, revision: str): async def plugin_module(request: Request, module: str, revision: str):
_principal(request) _principal(request)
for _, plugin in request.app.state.extensions.plugins: for _, plugin in request.app.state.extensions.plugins:
if plugin.frontend and plugin.frontend.module == module: if plugin.frontend and not isinstance(plugin.frontend, LoadedBrowserAssets) and plugin.frontend.module == module:
code = plugin.frontend.code.encode() code = plugin.frontend.code.encode()
if hashlib.sha256(code).hexdigest() == revision: if hashlib.sha256(code).hexdigest() == revision:
return Response(code, media_type="text/javascript", headers={"Cache-Control": "private, no-store", "X-Content-Type-Options": "nosniff"}) 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.") 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}") @router.post("/{namespace}/actions/{action_name}")
async def invoke_plugin_action(request: Request, namespace: str, action_name: str): async def invoke_plugin_action(request: Request, namespace: str, action_name: str):
"""Invoke an installed action with the authenticated viewer and deployment settings.""" """Invoke an installed action with the authenticated viewer and deployment settings."""

View File

@ -0,0 +1,34 @@
"""Loopback-only browser probe: real asset registration/router, synthetic identity."""
import json
import sys
from pathlib import Path
from tempfile import TemporaryDirectory
import uvicorn
from deerflow_extension_api import BrowserAssets, PluginContribution
from deerflow_extension_api.auth import EXTENSION_PRINCIPAL_RESOLVER_KEY, ExtensionPrincipal
from fastapi import FastAPI
from app.gateway.routers.plugins import router
from deerflow.extensions.registry import ExtensionRegistry
def create_app(directory):
root = Path(directory)
(root / "index.mjs").write_text("export default {};")
(root / "active.svg").write_text('<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20"><script>document.documentElement.setAttribute("data-executed", "yes")</script><rect width="20" height="20" fill="green"/></svg>')
(root / "ui_manifest.json").write_text(json.dumps({"schema_version": 1, "entry": "index.mjs", "files": ["index.mjs", "active.svg"]}))
registry = ExtensionRegistry()
with registry.attributed_to("browser-probe"):
registry.plugin(PluginContribution(namespace="test.assets", title="Browser probe", frontend=BrowserAssets("probe.v1", root)))
app = FastAPI()
app.state.extensions = registry.build()
setattr(app.state, EXTENSION_PRINCIPAL_RESOLVER_KEY, lambda request: ExtensionPrincipal("alice") if request.cookies.get("plugin_session") == "synthetic" else None)
app.include_router(router)
return app
if __name__ == "__main__":
with TemporaryDirectory(prefix="deerflow-asset-probe-") as directory:
uvicorn.run(create_app(directory), host="127.0.0.1", port=int(sys.argv[1]), log_level="warning")

View File

@ -45,7 +45,7 @@ from deerflow_extension_api.placement import (
MiddlewarePlacement, MiddlewarePlacement,
Placement, Placement,
) )
from deerflow_extension_api.plugins import ActionContext, BackendAction, BrowserModule, ModelTool, PluginContribution, ToolContext from deerflow_extension_api.plugins import ActionContext, BackendAction, BrowserAssets, BrowserModule, ModelTool, PluginContribution, ToolContext
from deerflow_extension_api.provenance import ( from deerflow_extension_api.provenance import (
MESSAGE_CONTENT_KIND_KEY, MESSAGE_CONTENT_KIND_KEY,
MESSAGE_PRODUCER_ENTITY_ID_KEY, MESSAGE_PRODUCER_ENTITY_ID_KEY,
@ -82,11 +82,12 @@ from deerflow_extension_api.state import ExtensionData
#: Contract version. Before 1.0, minors may break and patches are additive. #: Contract version. Before 1.0, minors may break and patches are additive.
#: From 1.0 on, bump the major for breaking changes. #: From 1.0 on, bump the major for breaking changes.
API_VERSION = "0.2.2" API_VERSION = "0.2.3"
__all__ = [ __all__ = [
"ActionContext", "ActionContext",
"BackendAction", "BackendAction",
"BrowserAssets",
"BrowserModule", "BrowserModule",
"ModelTool", "ModelTool",
"PluginContribution", "PluginContribution",

View File

@ -8,6 +8,7 @@ from __future__ import annotations
from collections.abc import Awaitable, Callable, Mapping from collections.abc import Awaitable, Callable, Mapping
from dataclasses import dataclass from dataclasses import dataclass
from pathlib import Path
from typing import Any from typing import Any
from deerflow_extension_api.auth import ExtensionPrincipal from deerflow_extension_api.auth import ExtensionPrincipal
@ -48,11 +49,7 @@ class ModelTool:
@dataclass(frozen=True) @dataclass(frozen=True)
class BrowserModule: class BrowserModule:
"""Experimental single-file browser transport, not the final asset package API. """Self-contained browser module; use BrowserAssets for relative resources."""
A future versioned packaged-asset transport will coexist with this inline
form; see docs/full-stack-plugins.md for the compatibility direction.
"""
module: str module: str
code: str code: str
@ -62,12 +59,29 @@ class BrowserModule:
object.__setattr__(self, "public_fields", tuple(self.public_fields)) object.__setattr__(self, "public_fields", tuple(self.public_fields))
@dataclass(frozen=True)
class BrowserAssets:
"""Versioned manifest and static files inside a trusted installed package.
The host validates and snapshots the allowlisted files during registration.
Relative paths in the manifest are resolved against root, never a request.
"""
module: str
root: str | Path
manifest: str = "ui_manifest.json"
public_fields: tuple[str, ...] = ()
def __post_init__(self) -> None:
object.__setattr__(self, "public_fields", tuple(self.public_fields))
@dataclass(frozen=True) @dataclass(frozen=True)
class PluginContribution: class PluginContribution:
"""One identity, one enabled switch, optional settings and implementations. """One identity, one enabled switch, optional settings and implementations.
The host owns the boolean ``enabled`` field. Other fields are non-secret The host owns the boolean ``enabled`` field. Other fields are non-secret
settings, private to the backend unless explicitly projected by BrowserModule. settings, private to the backend unless explicitly projected by its browser declaration.
Supply at least one browser module, backend action, or model tool. Backend implementations Supply at least one browser module, backend action, or model tool. Backend implementations
are installed through the existing operator-controlled Python loader. are installed through the existing operator-controlled Python loader.
""" """
@ -77,7 +91,7 @@ class PluginContribution:
description: str = "" description: str = ""
enabled: bool = False enabled: bool = False
fields: tuple[SettingsField, ...] = () fields: tuple[SettingsField, ...] = ()
frontend: BrowserModule | None = None frontend: BrowserModule | BrowserAssets | None = None
backend: tuple[BackendAction, ...] = () backend: tuple[BackendAction, ...] = ()
api_version: int = 1 api_version: int = 1
tools: tuple[ModelTool, ...] = () tools: tuple[ModelTool, ...] = ()

View File

@ -1,6 +1,6 @@
[project] [project]
name = "deerflow-extension-api" name = "deerflow-extension-api"
version = "0.2.2" version = "0.2.3"
description = "Public contracts for DeerFlow extensions" description = "Public contracts for DeerFlow extensions"
requires-python = ">=3.12" requires-python = ">=3.12"
# Keep the contract package import-light and independent from the host. Public # Keep the contract package import-light and independent from the host. Public

View File

@ -0,0 +1,112 @@
"""Bounded, immutable startup snapshots for manifest-listed browser resources."""
from __future__ import annotations
import hashlib
import json
import re
from collections.abc import Mapping
from dataclasses import dataclass
from pathlib import Path
from types import MappingProxyType
from deerflow_extension_api.plugins import BrowserAssets
MAX_FILE_BYTES = 4 * 1024 * 1024
MAX_PACKAGE_BYTES = 16 * 1024 * 1024
MAX_FILES = 256
MIME_TYPES = {
".js": "text/javascript",
".mjs": "text/javascript",
".css": "text/css",
".json": "application/json",
".map": "application/json",
".wasm": "application/wasm",
".png": "image/png",
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".gif": "image/gif",
".webp": "image/webp",
".svg": "image/svg+xml",
".ico": "image/x-icon",
".woff": "font/woff",
".woff2": "font/woff2",
".ttf": "font/ttf",
".otf": "font/otf",
}
def valid_asset_path(path: object) -> bool:
return isinstance(path, str) and len(path) <= 512 and all(re.fullmatch(r"[A-Za-z0-9_-][A-Za-z0-9_.-]*", part) is not None for part in path.split("/"))
@dataclass(frozen=True)
class Asset:
content: bytes
media_type: str
@dataclass(frozen=True, kw_only=True)
class LoadedBrowserAssets(BrowserAssets):
entry: str
revision: str
files: Mapping[str, Asset]
def _read(root: Path, path: str, limit: int) -> bytes:
if not valid_asset_path(path):
raise ValueError("Invalid browser asset path")
target = root / path
# Reject symlinks at every component rather than following an escaping link.
cursor = root
for part in path.split("/"):
cursor = cursor / part
if cursor.is_symlink():
raise ValueError("Browser assets must not contain symlinks")
resolved = target.resolve(strict=True)
if not resolved.is_relative_to(root) or not resolved.is_file():
raise ValueError("Browser asset must be a regular file inside its package")
with resolved.open("rb") as stream:
content = stream.read(limit + 1)
if len(content) > limit:
raise ValueError("Browser asset size limit exceeded")
return content
def _unique_object(pairs):
result = {}
for key, value in pairs:
if key in result:
raise ValueError("Duplicate browser manifest key")
result[key] = value
return result
def load_browser_assets(declaration: BrowserAssets) -> LoadedBrowserAssets:
declared_root = Path(declaration.root)
if declared_root.is_symlink():
raise ValueError("Browser asset root must not be a symlink")
# Parent path aliases (for example /var on macOS) remain supported.
root = declared_root.resolve(strict=True)
if not root.is_dir():
raise ValueError("Browser asset root must be a directory")
manifest = json.loads(_read(root, declaration.manifest, 64 * 1024), object_pairs_hook=_unique_object)
if not isinstance(manifest, dict) or set(manifest) != {"schema_version", "entry", "files"} or type(manifest["schema_version"]) is not int or manifest["schema_version"] != 1:
raise ValueError("Unsupported browser asset manifest; expected schema_version 1")
entry, paths = manifest["entry"], manifest["files"]
if not isinstance(paths, list) or not 1 <= len(paths) <= MAX_FILES or not all(valid_asset_path(path) for path in paths) or len(set(paths)) != len(paths):
raise ValueError("Browser manifest must list unique asset paths")
if not valid_asset_path(entry) or entry not in paths or Path(entry).suffix not in (".js", ".mjs"):
raise ValueError("Browser manifest entry must be a listed JavaScript module")
assets = {}
total = 0
digest = hashlib.sha256(json.dumps(manifest, sort_keys=True, separators=(",", ":")).encode())
for path in sorted(paths):
mime = MIME_TYPES.get(Path(path).suffix)
if mime is None:
raise ValueError("Unsupported browser asset file type")
content = _read(root, path, min(MAX_FILE_BYTES, MAX_PACKAGE_BYTES - total))
total += len(content)
digest.update(path.encode() + b"\0" + hashlib.sha256(content).digest())
assets[path] = Asset(content, mime)
return LoadedBrowserAssets(module=declaration.module, root=root, manifest=declaration.manifest, public_fields=declaration.public_fields, entry=entry, revision=digest.hexdigest(), files=MappingProxyType(assets))

View File

@ -11,7 +11,7 @@ import inspect
import re import re
from collections.abc import Iterator, Sequence from collections.abc import Iterator, Sequence
from contextlib import contextmanager from contextlib import contextmanager
from dataclasses import dataclass from dataclasses import dataclass, replace
from typing import Any from typing import Any
from deerflow_extension_api import ( from deerflow_extension_api import (
@ -24,7 +24,7 @@ from deerflow_extension_api import (
TaskLifecycleContributor, TaskLifecycleContributor,
) )
from deerflow_extension_api import ExtensionRegistry as ExtensionRegistryContract from deerflow_extension_api import ExtensionRegistry as ExtensionRegistryContract
from deerflow_extension_api.plugins import PluginContribution from deerflow_extension_api.plugins import BrowserAssets, BrowserModule, PluginContribution
_Entry = tuple[str, Any] _Entry = tuple[str, Any]
@ -117,8 +117,15 @@ class ExtensionRegistry(ExtensionRegistryContract):
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): 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") raise ValueError("Backend actions require unique names and async handlers")
names.add(action.name) names.add(action.name)
if contribution.frontend and (not contribution.frontend.code or len(contribution.frontend.code.encode()) > 512 * 1024): if isinstance(contribution.frontend, BrowserAssets):
from deerflow.extensions.browser_assets import load_browser_assets
contribution = replace(contribution, frontend=load_browser_assets(contribution.frontend))
elif isinstance(contribution.frontend, BrowserModule):
if not contribution.frontend.code or len(contribution.frontend.code.encode()) > 512 * 1024:
raise ValueError("Browser code must be nonempty and at most 512 KiB") raise ValueError("Browser code must be nonempty and at most 512 KiB")
elif contribution.frontend is not None:
raise ValueError("Unsupported browser transport")
# Validate everything before writing the plugin bucket; loader rollback also # Validate everything before writing the plugin bucket; loader rollback also
# covers a later failure elsewhere in this package's install function. # covers a later failure elsewhere in this package's install function.
from deerflow.config.plugin_settings import validate_contribution from deerflow.config.plugin_settings import validate_contribution

View File

@ -13,7 +13,7 @@ dependencies = [
# the contract version it implements, extensions declare ranges. A range # the contract version it implements, extensions declare ranges. A range
# here would let pip resolve a newer contract package than this harness # here would let pip resolve a newer contract package than this harness
# implements, making newer extensions look supported at runtime. # implements, making newer extensions look supported at runtime.
"deerflow-extension-api==0.2.2", "deerflow-extension-api==0.2.3",
"dotenv>=0.9.9", "dotenv>=0.9.9",
"exa-py>=1.0.0", "exa-py>=1.0.0",
"httpx>=0.28.0", "httpx>=0.28.0",

View File

@ -0,0 +1,181 @@
"""Packaged browser resources remain revisioned, authenticated and package-confined."""
import json
from dataclasses import replace
import pytest
from deerflow_extension_api import BrowserAssets, BrowserModule, PluginContribution
from deerflow_extension_api.auth import EXTENSION_PRINCIPAL_RESOLVER_KEY, ExtensionPrincipal
from fastapi import FastAPI
from fastapi.testclient import TestClient
from app.gateway.routers.plugins import router
from deerflow.extensions import browser_assets
from deerflow.extensions.browser_assets import load_browser_assets
from deerflow.extensions.registry import ExtensionRegistry
@pytest.fixture
def package(tmp_path):
(tmp_path / "static").mkdir()
for name, content in {"index.mjs": 'import "./chunk.mjs";', "chunk.mjs": "export default 1;", "style.css": "body{}", "icon.svg": "<svg/>", "font.woff2": "font", "private.json": "secret"}.items():
(tmp_path / "static" / name).write_text(content)
manifest = {"schema_version": 1, "entry": "static/index.mjs", "files": ["static/index.mjs", "static/chunk.mjs", "static/style.css", "static/icon.svg", "static/font.woff2"]}
(tmp_path / "ui_manifest.json").write_text(json.dumps(manifest))
return BrowserAssets("example.v1", tmp_path), manifest
def registry_for(declaration):
registry = ExtensionRegistry()
with registry.attributed_to("example"):
registry.plugin(PluginContribution(namespace="community.example", title="Example", enabled=True, frontend=declaration))
return registry
def test_routes_snapshot_all_files_and_keep_inline_compatibility(package):
declaration, _ = package
registry = registry_for(declaration)
with registry.attributed_to("inline"):
registry.plugin(PluginContribution(namespace="community.inline", title="Inline", frontend=BrowserModule("inline.v1", "export default {};")))
app = FastAPI()
app.state.extensions = registry.build()
setattr(app.state, EXTENSION_PRINCIPAL_RESOLVER_KEY, lambda request: ExtensionPrincipal("alice"))
app.include_router(router)
with TestClient(app) as client:
descriptor, inline = client.get("/api/plugins").json()
assert descriptor["transport"] == "assets-v1"
assert inline["transport"] == "inline-v1"
assert client.get(inline["entry"]).text == "export default {};"
assert client.get(descriptor["entry"].replace("community.example", "community.other")).status_code == 404
base = descriptor["entry"].removesuffix("index.mjs")
for file, mime in [("index.mjs", "text/javascript"), ("style.css", "text/css"), ("icon.svg", "image/svg+xml"), ("font.woff2", "font/woff2")]:
response = client.get(base + file)
assert response.status_code == 200
assert response.headers["content-type"].startswith(mime)
assert response.headers["cache-control"] == "private, max-age=31536000, immutable"
assert response.headers["vary"] == "Cookie, Authorization"
assert response.headers["x-content-type-options"] == "nosniff"
assert response.headers["content-security-policy"] == "sandbox"
for file in ["private.json", "missing.mjs", "%2e%2e%2fui_manifest.json", "%252e%252e/secret", "icon.svg/extra"]:
assert client.get(base + file).status_code == 404
original = client.get(descriptor["entry"]).content
(declaration.root / "static/index.mjs").write_text("changed")
assert client.get(descriptor["entry"]).content == original
app.state.extensions = registry_for(declaration).build()
updated = client.get("/api/plugins").json()[0]
assert updated["entry"] != descriptor["entry"]
assert client.get(descriptor["entry"]).status_code == 404
assert client.get(updated["entry"]).text == "changed"
setattr(app.state, EXTENSION_PRINCIPAL_RESOLVER_KEY, lambda request: None)
for entry in [updated["entry"], descriptor["entry"], base + "private.json"]:
assert client.get(entry).status_code == 401
def test_any_dependency_changes_revision_and_rollback_releases_snapshot(package):
declaration, _ = package
before = load_browser_assets(declaration)
(declaration.root / "static/style.css").write_text("body{color:red}")
assert load_browser_assets(declaration).revision != before.revision
assert before.files["static/style.css"].content == b"body{}"
with pytest.raises(TypeError):
before.files["static/style.css"] = None
registry = ExtensionRegistry()
mark = registry.mark()
with registry.attributed_to("example"):
registry.plugin(PluginContribution(namespace="community.example", title="Example", frontend=declaration))
registry.rollback_to(mark)
assert not registry.build().plugins
@pytest.mark.parametrize(
"path", ["../secret.mjs", "/tmp/secret.mjs", "static/../index.mjs", "static//index.mjs", "./static/index.mjs", ".hidden.mjs", "static\\index.mjs", "static/%2e%2e/file.mjs", "static/file.mjs?x", "https://example/file.mjs"]
)
def test_unsafe_manifest_paths_rejected(package, path):
declaration, manifest = package
manifest["files"].append(path)
(declaration.root / "ui_manifest.json").write_text(json.dumps(manifest))
with pytest.raises(ValueError):
registry_for(declaration)
@pytest.mark.parametrize(
"change",
[
{"schema_version": 2},
{"schema_version": True},
{"entry": "static/style.css"},
{"entry": "missing.mjs"},
{"files": []},
{"files": ["static/index.mjs", "static/index.mjs"]},
{"files": "static/index.mjs"},
{"unknown": True},
{"files": ["static/index.mjs", "secret.html"]},
],
)
def test_invalid_manifests_rejected(package, change):
declaration, manifest = package
manifest.update(change)
(declaration.root / "ui_manifest.json").write_text(json.dumps(manifest))
with pytest.raises(ValueError):
registry_for(declaration)
def test_missing_symlink_duplicate_keys_and_size_limits(package, monkeypatch):
declaration, manifest = package
file = declaration.root / "static/chunk.mjs"
file.unlink()
with pytest.raises(FileNotFoundError):
load_browser_assets(declaration)
file.symlink_to(declaration.root / "static/index.mjs")
with pytest.raises(ValueError, match="symlinks"):
load_browser_assets(declaration)
file.unlink()
file.write_text("export default 1;")
monkeypatch.setattr(browser_assets, "MAX_FILE_BYTES", 4)
with pytest.raises(ValueError, match="size limit"):
load_browser_assets(declaration)
monkeypatch.setattr(browser_assets, "MAX_FILE_BYTES", 4096)
monkeypatch.setattr(browser_assets, "MAX_PACKAGE_BYTES", 30)
with pytest.raises(ValueError, match="size limit"):
load_browser_assets(declaration)
(declaration.root / "ui_manifest.json").write_text('{"schema_version":1,"schema_version":1}')
with pytest.raises(ValueError, match="Duplicate"):
load_browser_assets(declaration)
with pytest.raises(ValueError, match="Invalid browser asset path"):
load_browser_assets(replace(declaration, manifest="../ui_manifest.json"))
def test_intermediate_symlink_and_manifest_limits(package, tmp_path, monkeypatch):
declaration, manifest = package
directory = tmp_path / "static"
directory.rename(tmp_path / "real")
directory.symlink_to(tmp_path / "real", target_is_directory=True)
with pytest.raises(ValueError, match="symlinks"):
load_browser_assets(declaration)
directory.unlink()
(tmp_path / "real").rename(directory)
monkeypatch.setattr(browser_assets, "MAX_FILES", 1)
with pytest.raises(ValueError, match="unique asset paths"):
load_browser_assets(declaration)
(tmp_path / "ui_manifest.json").write_bytes(b" " * (64 * 1024 + 1))
with pytest.raises(ValueError, match="size limit"):
load_browser_assets(declaration)
@pytest.mark.parametrize("dangling", [False, True])
def test_asset_root_symlink_is_rejected_before_resolution(package, dangling):
declaration, _ = package
link = declaration.root / "root-link"
link.symlink_to(declaration.root / "missing" if dangling else declaration.root, target_is_directory=True)
with pytest.raises(ValueError, match="root.*symlink"):
load_browser_assets(replace(declaration, root=link))
def test_asset_root_retains_normal_parent_symlink_resolution(package):
declaration, _ = package
# Deployment paths can legitimately traverse aliases such as /var -> /private/var.
alias = declaration.root / "parent-alias"
alias.symlink_to(declaration.root.parent, target_is_directory=True)
via_alias = alias / declaration.root.name
assert not via_alias.is_symlink()
assert load_browser_assets(replace(declaration, root=via_alias)).revision == load_browser_assets(declaration).revision

View File

@ -342,7 +342,7 @@ def test_runtime_api_version_matches_the_installed_contract_package():
"""Every additive contract slice bumps both gates together.""" """Every additive contract slice bumps both gates together."""
from importlib.metadata import version from importlib.metadata import version
assert API_VERSION == "0.2.2" assert API_VERSION == "0.2.3"
assert API_VERSION == version("deerflow-extension-api") assert API_VERSION == version("deerflow-extension-api")

2
backend/uv.lock generated
View File

@ -918,7 +918,7 @@ extensions = []
[[package]] [[package]]
name = "deerflow-extension-api" name = "deerflow-extension-api"
version = "0.2.2" version = "0.2.3"
source = { editable = "packages/extension-api" } source = { editable = "packages/extension-api" }
[[package]] [[package]]

View File

@ -3,11 +3,11 @@
A deployment-installed Python extension can register a `PluginContribution` with A deployment-installed Python extension can register a `PluginContribution` with
optional browser code, authenticated backend actions and model tools. This extends optional browser code, authenticated backend actions and model tools. This extends
the existing `install(registry, config)` workflow. MCP and Skills keep their existing 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). APIs and lifecycles. Public contracts live in `deerflow_extension_api` (0.2.3).
The browser contribution API in this slice is experimental. `BrowserModule(code=...)` The browser contribution API is experimental. `BrowserModule(code=...)` remains the
is an MVP transport for validating page/action host interfaces, not the final asset self-contained transport; `BrowserAssets(root=...)` adds manifest-listed resources
packaging contract or a requirement that all future plugins ship one JavaScript file. without changing the page/action API or requiring a host frontend rebuild.
## What users see ## What users see
@ -27,6 +27,8 @@ the existing positional rollback and source attribution.
- `BrowserModule(module, code, public_fields=())` contains a self-contained ES module, - `BrowserModule(module, code, public_fields=())` contains a self-contained ES module,
at most 512 KiB. It may export `surfaces` and `conversationActions` with `apiVersion: 1`. at most 512 KiB. It may export `surfaces` and `conversationActions` with `apiVersion: 1`.
- `BrowserAssets(module, root, manifest="ui_manifest.json", public_fields=())`
loads a versioned resource manifest from an installed package (details below).
- `BackendAction(name, handler)` declares an async handler receiving a JSON object and - `BackendAction(name, handler)` declares an async handler receiving a JSON object and
`ActionContext(principal, settings)`. The principal comes from host authentication. `ActionContext(principal, settings)`. The principal comes from host authentication.
- `ModelTool(name, description, input_schema, handler, group="extensions")` declares - `ModelTool(name, description, input_schema, handler, group="extensions")` declares
@ -53,18 +55,20 @@ the process default once at construction.
The authenticated host exposes: The authenticated host exposes:
| Route | Purpose | | Route | Purpose |
| ------------------------------------------------ | --------------------------------------------------------------- | | ------------------------------------------------------- | --------------------------------------------------------------- |
| `GET /api/plugins` | Deployed descriptors, public configuration and declared actions | | `GET /api/plugins` | Deployed descriptors, public configuration and declared actions |
| `GET /api/plugins/modules/{module}/{sha256}.mjs` | Installed code with a content revision | | `GET /api/plugins/modules/{module}/{sha256}.mjs` | Installed code with a content revision |
| `GET /api/plugins/{namespace}/assets/{revision}/{path}` | One manifest-listed static resource |
| `POST /api/plugins/{namespace}/actions/{name}` | Invoke one declared backend action | | `POST /api/plugins/{namespace}/actions/{name}` | Invoke one declared backend action |
The host does not accept filesystem paths, import strings or arbitrary remote URLs 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 from the browser. Inline modules use JavaScript content type and private no-store
no-store caching. Existing Gateway session/CSRF policies apply; PATs do not gain a new caching; packaged assets use their declared file type and private immutable caching.
Both transports send `nosniff`. 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 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. 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, Inline 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. 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 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 self-contained: relative imports and assets resolved against `import.meta.url` are
@ -96,38 +100,106 @@ names that collide with ordinary tools follow the host's ordinary-first deduplic
unrelated tools remain available. Duplicate names within the plugin tool set still fail unrelated tools remain available. Duplicate names within the plugin tool set still fail
strict validation. strict validation.
## Packaged assets and compatibility direction ## Packaged resources (`assets-v1`)
RFC #5510 proposes a manifest plus packaged static resources (`ui_manifest.json` and Register a package-owned directory alongside the Python implementation:
`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 ```python
inline form, rather than silently changing the meaning of `BrowserModule.code`: from pathlib import Path
from deerflow_extension_api import BrowserAssets, PluginContribution
- A validated manifest identifies the entry module and permitted files under a registry.plugin(PluginContribution(
package-owned asset root. The root comes from the installed package, never a browser namespace="community.example",
supplied filesystem path. title="Example",
- Namespace/revision-scoped URLs, for example frontend=BrowserAssets("example.v1", Path(__file__).parent),
`/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. Place `ui_manifest.json` at that root, and include it and every listed file in the
The stable packaging contract requires review before plugin authors rely on it. Neither installed wheel. The [bookmarks package](../examples/deerflow-extension-bookmarks/README.md)
transport should require rebuilding DeerFlow's frontend for each compatible plugin. is a working example. Its manifest uses this schema:
```json
{
"schema_version": 1,
"entry": "static/dist/index.mjs",
"files": [
"static/dist/index.mjs",
"static/dist/chunks/bookmarks.mjs",
"static/dist/styles.css",
"static/dist/bookmark.svg"
]
}
```
`entry` must be a listed `.js` or `.mjs` ES module exporting the existing browser
API v1 object. Relative static/dynamic imports and `new URL(..., import.meta.url)`
resolve within the revision's URL tree. Bundle third-party dependencies into the
package: bare npm imports and shared host React instances are not provided. Emit
relative URLs, not absolute `/assets/...` paths from a bundler's default public path.
The manifest accepts only `schema_version`, `entry` and `files`; unknown versions,
extra/duplicate keys, duplicate paths, missing files and symlinks are rejected at
registration. The declared root itself must not be a symlink (including a dangling
link); ordinary parent directory aliases are resolved before checking package files.
Paths use ASCII letters, digits, `_`, `-`, `.` and `/` separators;
segments must start with a letter, digit, `_` or `-`. Dotfiles, dot segments, empty
segments, percent encoding, query strings and backslashes are rejected. No directory
listing or unlisted file is served. Limits: 64 KiB manifest, 256 files, 4 MiB per
file and 16 MiB total per plugin. These limits bound the in-memory startup snapshot.
Supported types are JS/MJS, CSS, JSON/source maps, WASM, PNG/JPEG/GIF/WebP/SVG/ICO
and WOFF/WOFF2/TTF/OTF. HTML and executable server files are not served. SVG can
contain active document content, so asset responses carry
`Content-Security-Policy: sandbox`: direct navigation cannot execute scripts or
retain the Gateway's origin.
This document restriction preserves image, stylesheet and module subresource use;
it does not sandbox the plugin JavaScript deliberately loaded into the host page.
Do not list secrets or private build sources; any authenticated user can download listed assets,
including source maps, even when the contribution is disabled.
The host snapshots all listed bytes during registration and computes a SHA-256
revision over the manifest, paths and file content hashes. Changing any resource
changes every resource URL's revision. Requests read that immutable snapshot, not
the filesystem. Responses require authentication and send
`Cache-Control: private, max-age=31536000, immutable`, `Vary: Cookie, Authorization`
and the file's MIME type. Missing files/revisions return 404 with `private, no-store`.
There is no public CDN contract or retained historical snapshot. A browser can retain
already cached code after removal; uncached old resources require a page reload after
restart/upgrade. Installation and authorization are not instantaneous cache revocation.
Discovery labels the transport `inline-v1` or `assets-v1`. The frontend rejects unknown
transports and treats an omitted transport as legacy inline v1. Deploy the matching
host frontend/backend together before installing an assets-v1 plugin; this does not
make old host frontends support the new transport.
For packaged JavaScript the host inserts a native module script with
`crossorigin="use-credentials"`, then obtains exports from the document's module map.
This preserves authenticated static and lazy imports without rewriting source or Blob
URLs. See the [HTML module-script credential rules](https://html.spec.whatwg.org/multipage/scripting.html#attr-script-crossorigin).
URLs honor `NEXT_PUBLIC_BACKEND_BASE_URL`, including relative/absolute prefixes.
The host stops waiting after 30 seconds and rejects that contribution, removing its
loading script node. This is a waiting deadline, not execution cancellation: native
module fetching/evaluation can continue and top-level side effects can occur later.
A late completion cannot change the already rejected host result. Blocking synchronous
plugin code can also delay the deadline's timer. Trusted plugins should keep top-level
code free of user-visible side effects, start UI work in `mount` and release it in
`dispose`. This loader does not provide preemption, rollback or a security sandbox.
Module exports are shared within a document. Keep viewer data in per-mount state,
observe the host abort signal and clear it on dispose; never retain principals or
private results in module-level state across account changes.
Split-origin deployments need exact-origin credentialed CORS and working session
cookies; browser third-party cookie restrictions still apply.
Other resource requests are the plugin's responsibility: attach a stylesheet or image
with `crossOrigin = "use-credentials"`, and use `fetch(url, { credentials: "include" })`
for WASM, JSON or binary assets. CSS font/background requests do not universally carry
cross-origin session cookies. Prefer same-origin deployment for such CSS references,
or fetch with credentials and construct a `FontFace`/Blob URL, releasing it on dispose.
The host does not rewrite CSS URLs or add authorization tokens to URLs. CSP must allow
the backend origin in the applicable `script-src`, `style-src`, `img-src`, `font-src`
and `connect-src` directives. Inline-v1 still needs `blob:` in `script-src`.
## Trust and lifecycle ## Trust and lifecycle
@ -157,3 +229,11 @@ and SQLite, with synthetic authentication and scripted LangGraph ToolNode calls.
It covers persistence, owner isolation, read-only deployment management and the full 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. 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. The example uses single-host storage, not a multi-node persistence contract.
`plugin-assets.spec.ts` also checks authenticated static/lazy imports, CSS and images,
plus direct SVG navigation through the real Gateway asset route. Its script-execution
control removes the sandbox header from the same SVG to verify the restriction.
To exercise the actual Turbopack development build, start the frontend with
`DEER_FLOW_DEV_BUNDLER=turbo pnpm dev`, then run
`PLAYWRIGHT_SKIP_WEB_SERVER=1 pnpm exec playwright test tests/e2e/bookmark-plugin.spec.ts`.
Set `PLAYWRIGHT_BASE_URL` if the development server uses a port other than 3000.

View File

@ -8,7 +8,7 @@ The read-only `search_bookmarks`
model tool searches the authenticated user's saved excerpts. Each user sees only model tool searches the authenticated user's saved excerpts. Each user sees only
their own data, including when the Agent calls the tool. 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 **Requires a host with the full-stack plugin contract and extension-api 0.2.3.** Pi's original labels session entries; this web
adaptation is independently implemented. See [attribution](THIRD_PARTY_NOTICES.md). adaptation is independently implemented. See [attribution](THIRD_PARTY_NOTICES.md).
## Deployment ## Deployment
@ -78,8 +78,8 @@ file above stores user bookmarks, not deployment settings.
## Host contract exercised ## Host contract exercised
`PluginContribution` packages one `BrowserModule`, five `BackendAction`s and one `PluginContribution` packages one `BrowserAssets` declaration, five `BackendAction`s and one
`ModelTool`. The module contributes a conversation action and a `page` DOM surface. `ModelTool`. The entry module contributes a conversation action and a `page` DOM surface.
The page declares `navigation: { label: "My bookmarks", labelZh: "我的书签", icon: "bookmark" }`. 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 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 sidebar entry; the plugin cannot claim arbitrary host URLs. Disabled/unloaded pages
@ -100,3 +100,13 @@ repairs navigation for existing bookmarks without migrating the SQLite schema.
A missing/inaccessible conversation shows an error instead of opening the default A missing/inaccessible conversation shows an error instead of opening the default
agent. Hosts without this optional navigation helper cannot reopen conversations agent. Hosts without this optional navigation helper cannot reopen conversations
from this version of the example. from this version of the example.
## Browser resource layout
`ui_manifest.json` explicitly lists `static/dist/index.mjs`, its relative page module
under `chunks/`, the stylesheet and the bookmark SVG. These files ship in the Python
wheel. The host validates/snapshots the manifest at registration and exposes private,
revisioned URLs. The page resolves CSS and its image against `import.meta.url`, using
credentialed resource requests for split-origin Gateway deployments. No JavaScript
build step is needed for this dependency-free example; larger plugins can ship
bundler output using the same relative-path manifest contract.

View File

@ -9,7 +9,7 @@ from pathlib import Path
from deerflow_extension_api import extension from deerflow_extension_api import extension
from deerflow_extension_api.plugins import ( from deerflow_extension_api.plugins import (
BackendAction, BackendAction,
BrowserModule, BrowserAssets,
ModelTool, ModelTool,
PluginContribution, PluginContribution,
) )
@ -136,7 +136,7 @@ class Bookmarks:
return handle return handle
@extension(api="0.2.2", name="bookmarks") @extension(api="0.2.3", name="bookmarks")
def install(registry, config): def install(registry, config):
enabled = config.get("enabled", False) enabled = config.get("enabled", False)
if type(enabled) is not bool or not isinstance(config.get("storage_path"), str): if type(enabled) is not bool or not isinstance(config.get("storage_path"), str):
@ -152,9 +152,9 @@ def install(registry, config):
title="会话书签 / Bookmarks", title="会话书签 / Bookmarks",
description="收藏有用的回答,在独立页面查找与整理。每位用户只访问自己的书签。", description="收藏有用的回答,在独立页面查找与整理。每位用户只访问自己的书签。",
enabled=enabled, enabled=enabled,
frontend=BrowserModule( frontend=BrowserAssets(
"bookmarks.v1", "bookmarks.v1",
Path(__file__).with_name("client.mjs").read_text(encoding="utf-8"), Path(__file__).parent,
), ),
backend=tuple( backend=tuple(
BackendAction(name, store.handler(name)) BackendAction(name, store.handler(name))

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#216549" stroke-width="2"><path d="M6 3h12v18l-6-4-6 4z"/></svg>

After

Width:  |  Height:  |  Size: 145 B

View File

@ -1,5 +1,5 @@
// Original web adaptation of Pi's bookmark concept. No React/DeerFlow imports. // Original web adaptation of Pi's bookmark concept. No React/DeerFlow imports.
function mountBookmarks(root, context) { export function mountBookmarks(root, context) {
const zh = context.locale.startsWith("zh"); const zh = context.locale.startsWith("zh");
const words = zh const words = zh
? { ? {
@ -43,23 +43,23 @@ function mountBookmarks(root, context) {
element.setAttribute(key, value); element.setAttribute(key, value);
return element; return element;
}; };
const style = make( const style = make("link", "", {
"style", rel: "stylesheet",
` crossorigin: "use-credentials",
:host {display:block;color:inherit;font:inherit} * {box-sizing:border-box} href: new URL("../styles.css", import.meta.url).href,
.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" }); const intro = make("div", "", { class: "intro" });
intro.append(make("h3", words.heading), make("p", words.description)); intro.append(
make("img", "", {
crossorigin: "use-credentials",
src: new URL("../bookmark.svg", import.meta.url).href,
alt: "",
width: "28",
height: "28",
}),
make("h3", words.heading),
make("p", words.description),
);
const form = make("form"); const form = make("form");
const query = make("input", "", { const query = make("input", "", {
type: "search", type: "search",
@ -214,65 +214,3 @@ function mountBookmarks(root, context) {
}, },
}; };
} }
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.",
);
},
},
],
};
},
};

View File

@ -0,0 +1,63 @@
import { mountBookmarks } from "./chunks/bookmarks.mjs";
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.",
);
},
},
],
};
},
};

View File

@ -0,0 +1,108 @@
: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: -0.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: 0.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: 0.6;
margin-bottom: 12px;
}
[role="status"],
[role="alert"] {
font-size: 13px;
margin: 8px 0;
}
[role="alert"] {
color: #bd4040;
}

View File

@ -0,0 +1,10 @@
{
"schema_version": 1,
"entry": "static/dist/index.mjs",
"files": [
"static/dist/index.mjs",
"static/dist/chunks/bookmarks.mjs",
"static/dist/styles.css",
"static/dist/bookmark.svg"
]
}

View File

@ -1,11 +1,11 @@
[project] [project]
name = "deerflow-extension-bookmarks" name = "deerflow-extension-bookmarks"
version = "0.1.0" version = "0.2.0"
description = "Pi-inspired conversation bookmarks for the DeerFlow full-stack plugin API" description = "Pi-inspired conversation bookmarks for the DeerFlow full-stack plugin API"
requires-python = ">=3.12" requires-python = ">=3.12"
license = "MIT" license = "MIT"
license-files = ["THIRD_PARTY_NOTICES.md"] license-files = ["THIRD_PARTY_NOTICES.md"]
dependencies = ["deerflow-extension-api>=0.2.2,<0.3"] dependencies = ["deerflow-extension-api>=0.2.3,<0.3"]
[project.entry-points."deerflow.extensions"] [project.entry-points."deerflow.extensions"]
bookmarks = "deerflow_extension_bookmarks:install" bookmarks = "deerflow_extension_bookmarks:install"

View File

@ -266,9 +266,9 @@ callbacks. Static demos and non-admin users must not query the management API.
## Full-stack plugin UI ## Full-stack plugin UI
`core/extensions/` loads authenticated deployment-installed ES modules from `/api/plugins`. `core/extensions/` loads authenticated deployment-installed ES modules from `/api/plugins`.
Module downloads use the configured backend base and authenticated fetch, then import Inline modules use authenticated fetch plus a released Blob URL. Manifest assets use
and release a Blob URL; packages must be self-contained (no relative module/assets). native credentialed module scripts, preserving relative imports and resource URLs.
This inline transport is experimental; packaged-asset compatibility is documented in Both honor the backend base and prefixes; transport and cache semantics are documented in
`docs/full-stack-plugins.md`. Host copy belongs in the typed locale dictionaries. `docs/full-stack-plugins.md`. Host copy belongs in the typed locale dictionaries.
Conversation action factories, shapes and availability callbacks are guarded per plugin; Conversation action factories, shapes and availability callbacks are guarded per plugin;
only validated value snapshots reach the toolbar/sidebar render paths. only validated value snapshots reach the toolbar/sidebar render paths.

View File

@ -10,6 +10,8 @@ const contributions = z.array(
module: z.string().nullable(), module: z.string().nullable(),
backend_actions: z.array(z.string()).optional(), backend_actions: z.array(z.string()).optional(),
entry: z.string().nullable(), entry: z.string().nullable(),
// Keep unknown transports so the loader can reject only that contribution.
transport: z.string().nullable().optional(),
title: z.string(), title: z.string(),
description: z.string(), description: z.string(),
settings: z.record(z.union([z.boolean(), z.number(), z.string()])), settings: z.record(z.union([z.boolean(), z.number(), z.string()])),

View File

@ -0,0 +1,41 @@
/** Native module graphs preserve relative URLs and credentialed chunk imports. */
export function importAssetModule(url: string): Promise<{ default: unknown }> {
const absoluteURL = new URL(url, document.baseURI).href;
return new Promise((resolve, reject) => {
const script = document.createElement("script");
script.type = "module";
script.crossOrigin = "use-credentials";
script.src = absoluteURL;
const cleanup = () => {
clearTimeout(timer);
script.onload = script.onerror = null;
script.remove();
};
const fail = (error: unknown) => {
cleanup();
reject(
error instanceof Error
? error
: new Error("Plugin module failed", { cause: error }),
);
};
// Stop host waiting for loading/evaluation (including top-level await).
// Removing the node cannot cancel native evaluation or its late side effects.
const timer = setTimeout(
() => fail(new Error("Plugin module timed out")),
30_000,
);
script.onload = () => {
// The credentialed script populated the document's module map. Reuse that
// module instance to obtain its exports; do not fetch it as a Blob.
void import(
/* webpackIgnore: true */ /* turbopackIgnore: true */ absoluteURL
).then((module: { default: unknown }) => {
cleanup();
resolve(module);
}, fail);
};
script.onerror = () => fail(new Error("Plugin module unavailable"));
document.head.append(script);
});
}

View File

@ -34,6 +34,8 @@ export type FrontendContribution = {
namespace: string; namespace: string;
module: string | null; module: string | null;
entry: string | null; entry: string | null;
/** Known transports: inline-v1 and assets-v1; unknown values fail per plugin. */
transport?: string | null;
title: string; title: string;
description: string; description: string;
settings: ExtensionSettings; settings: ExtensionSettings;

View File

@ -11,6 +11,7 @@ import {
import { fetch } from "@/core/api/fetcher"; import { fetch } from "@/core/api/fetcher";
import { getBackendBaseURL } from "@/core/config"; import { getBackendBaseURL } from "@/core/config";
import { importAssetModule } from "./asset-module";
import type { FrontendContribution, FrontendExtension } from "./contracts"; import type { FrontendContribution, FrontendExtension } from "./contracts";
export function extensionIcon(name?: string): LucideIcon { export function extensionIcon(name?: string): LucideIcon {
@ -34,14 +35,28 @@ const importModule: ModuleImporter = (url) =>
export async function loadFrontendExtensions( export async function loadFrontendExtensions(
entries: FrontendContribution[], entries: FrontendContribution[],
importer: ModuleImporter = importModule, importer: ModuleImporter = importModule,
assetImporter: ModuleImporter = importAssetModule,
): Promise<LoadedContribution[]> { ): Promise<LoadedContribution[]> {
return Promise.all( return Promise.all(
entries.map(async (entry) => { entries.map(async (entry) => {
if (entry.settings.enabled !== true || entry.module === null) if (entry.settings.enabled !== true || entry.module === null)
return entry; return entry;
try { try {
// Only fetch installed Gateway assets, using the same base and credentials let loadedModule: FrontendExtension;
// as discovery/actions. Cross-origin import() would omit session cookies. const transport = entry.transport ?? "inline-v1";
if (transport === "assets-v1") {
const expected = `/api/plugins/${entry.namespace}/assets/`;
if (
!entry.entry?.startsWith(expected) ||
!/^[a-f0-9]{64}\/(?:[A-Za-z0-9_-][A-Za-z0-9_.-]*\/)*[A-Za-z0-9_-][A-Za-z0-9_.-]*\.m?js$/.test(
entry.entry.slice(expected.length),
)
)
throw new Error("Invalid installed asset entry");
loadedModule = (
await assetImporter(`${getBackendBaseURL()}${entry.entry}`)
).default as FrontendExtension;
} else if (transport === "inline-v1") {
const expected = `/api/plugins/modules/${entry.module}/`; const expected = `/api/plugins/modules/${entry.module}/`;
if ( if (
!entry.entry?.startsWith(expected) || !entry.entry?.startsWith(expected) ||
@ -53,17 +68,18 @@ export async function loadFrontendExtensions(
}); });
if (!response.ok) if (!response.ok)
throw new Error(`Plugin module unavailable (${response.status})`); throw new Error(`Plugin module unavailable (${response.status})`);
// BrowserModule is a self-contained ES module; it has no relative imports.
const moduleURL = URL.createObjectURL( const moduleURL = URL.createObjectURL(
new Blob([await response.text()], { type: "text/javascript" }), new Blob([await response.text()], { type: "text/javascript" }),
); );
let loadedModule: FrontendExtension;
try { try {
loadedModule = (await importer(moduleURL)) loadedModule = (await importer(moduleURL))
.default as FrontendExtension; .default as FrontendExtension;
} finally { } finally {
URL.revokeObjectURL(moduleURL); URL.revokeObjectURL(moduleURL);
} }
} else {
throw new Error("Unsupported browser asset transport");
}
if ( if (
loadedModule?.apiVersion !== 1 || loadedModule?.apiVersion !== 1 ||
loadedModule.module !== entry.module || loadedModule.module !== entry.module ||

View File

@ -124,7 +124,7 @@ for (const source of ["default", "custom-toolbar", "custom-sidebar"]) {
await route.fulfill({ status: 204, headers: cors }); await route.fulfill({ status: 204, headers: cors });
return; return;
} }
if (url.pathname.includes("/modules/")) { if (url.pathname.includes("/assets/")) {
moduleRequests.push(url.href); moduleRequests.push(url.href);
if ( if (
!(await route.request().allHeaders()).cookie?.includes( !(await route.request().allHeaders()).cookie?.includes(
@ -157,9 +157,9 @@ for (const source of ["default", "custom-toolbar", "custom-sidebar"]) {
exact: true, exact: true,
}); });
await expect(libraryLink).toHaveAttribute("href", libraryURL); await expect(libraryLink).toHaveAttribute("href", libraryURL);
expect(moduleRequests).toHaveLength(1); expect(moduleRequests).toHaveLength(2);
const modulePrefix = new URL( const modulePrefix = new URL(
`${backendBase.replace(/\/+$/, "")}/api/plugins/modules/`, `${backendBase.replace(/\/+$/, "")}/api/plugins/community.bookmarks/assets/`,
frontendURL, frontendURL,
).href; ).href;
expect(moduleRequests[0]?.startsWith(modulePrefix)).toBe(true); expect(moduleRequests[0]?.startsWith(modulePrefix)).toBe(true);
@ -219,6 +219,21 @@ for (const source of ["default", "custom-toolbar", "custom-sidebar"]) {
await expect( await expect(
page.getByText("Keep answers worth returning to", { exact: true }), page.getByText("Keep answers worth returning to", { exact: true }),
).toBeVisible(); ).toBeVisible();
await expect(page.locator(".intro")).toHaveCSS("border-radius", "16px");
await expect
.poll(() =>
page.locator(".intro img").evaluate((node) => {
const image = node as HTMLImageElement;
return image.complete && image.naturalWidth > 0;
}),
)
.toBe(true);
expect(moduleRequests.some((url) => url.endsWith("/styles.css"))).toBe(
true,
);
expect(moduleRequests.some((url) => url.endsWith("/bookmark.svg"))).toBe(
true,
);
const name = page.getByRole("textbox", { const name = page.getByRole("textbox", {
name: "Bookmark name", name: "Bookmark name",
exact: true, exact: true,

View File

@ -0,0 +1,314 @@
/** Run the production importer in Chromium, including authenticated lazy chunks. */
import { spawn, type ChildProcess } from "node:child_process";
import { readFile } from "node:fs/promises";
import { createServer, type Server, type RequestListener } from "node:http";
import path from "node:path";
import { expect, test } from "@playwright/test";
import { ScriptTarget, ModuleKind, transpileModule } from "typescript";
type TestPlugin = {
value: number;
lazy: () => Promise<{ lazy: string }>;
css: string;
image: string;
};
declare global {
interface Window {
loadPlugin: (url: string) => Promise<{ default: TestPlugin }>;
executions: number;
releasePlugin: () => void;
pluginResumed: Promise<void>;
latePluginEffects: number;
}
}
let frontend: Server;
let backend: Server;
let frontendURL: string;
let backendURL: string;
let gateway: ChildProcess;
let svgURL: string;
const requests: { path: string; cookie: string }[] = [];
async function listen(server: Server) {
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
const address = server.address();
if (!address || typeof address === "string") throw new Error("Missing port");
return `http://127.0.0.1:${address.port}`;
}
test.beforeAll(async ({ request }) => {
const source = await readFile("src/core/extensions/asset-module.ts", "utf8");
const loader = transpileModule(source, {
compilerOptions: { target: ScriptTarget.ES2022, module: ModuleKind.ESNext },
}).outputText;
const files: Record<string, [string, string]> = {
"pending.mjs": [
"text/javascript",
`window.latePluginEffects = 0;
let resumed;
window.pluginResumed = new Promise(resolve => { resumed = resolve; });
await new Promise(resolve => { window.releasePlugin = resolve; });
window.latePluginEffects++;
resumed();
export default {};`,
],
"index.mjs": [
"text/javascript",
`import { value } from './chunks/value.mjs';
globalThis.executions = (globalThis.executions || 0) + 1;
export default {value, lazy: () => import('./chunks/lazy.mjs'),
css: new URL('./style.css', import.meta.url).href,
image: new URL('./icon.svg', import.meta.url).href};`,
],
"chunks/value.mjs": ["text/javascript", "export const value = 42;"],
"chunks/lazy.mjs": [
"text/javascript",
'export const lazy = "authenticated lazy chunk";',
],
"style.css": ["text/css", "body { --plugin-loaded: yes; }"],
"icon.svg": [
"image/svg+xml",
'<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20"/>',
],
};
const handler: RequestListener = (req, res) => {
if (req.url === "/") {
res.setHeader("Content-Type", "text/html");
res.end(
'<!doctype html><script type="module">import { importAssetModule } from "/loader.mjs"; window.loadPlugin = importAssetModule;</script>',
);
return;
}
if (req.url === "/loader.mjs") {
res.setHeader("Content-Type", "text/javascript");
res.end(loader);
return;
}
res.setHeader("Access-Control-Allow-Origin", frontendURL);
res.setHeader("Access-Control-Allow-Credentials", "true");
const path = req.url ?? "";
requests.push({ path, cookie: req.headers.cookie ?? "" });
if (!req.headers.cookie?.includes("plugin_session=synthetic")) {
res.writeHead(401);
res.end();
return;
}
const file = files[path.replace(/^\/gateway\/assets\/[a-z]+\//, "")];
if (!file) {
res.writeHead(404);
res.end();
return;
}
res.setHeader("Content-Type", file[0]);
res.setHeader("X-Content-Type-Options", "nosniff");
res.setHeader("Content-Security-Policy", "sandbox");
res.end(file[1]);
};
frontend = createServer(handler);
frontendURL = await listen(frontend);
backend = createServer(handler);
backendURL = await listen(backend);
const probe = createServer();
const gatewayURL = await listen(probe);
await new Promise<void>((resolve) => probe.close(() => resolve()));
const backendDirectory = path.resolve("../backend");
gateway = spawn(
path.join(backendDirectory, ".venv/bin/python"),
[
"-m",
"extension_test_fixtures.browser_asset_gateway",
new URL(gatewayURL).port,
],
{ cwd: backendDirectory, stdio: "pipe" },
);
let diagnostics = "";
gateway.stderr?.on("data", (chunk) => {
diagnostics += String(chunk);
});
const headers = { cookie: "plugin_session=synthetic" };
await expect
.poll(
async () => {
if (gateway.exitCode !== null) throw new Error(diagnostics);
return request
.get(`${gatewayURL}/api/plugins`, { headers })
.then((r) => r.status())
.catch(() => 0);
},
{ timeout: 20_000 },
)
.toBe(200);
const response = await request.get(`${gatewayURL}/api/plugins`, { headers });
const [plugin] = (await response.json()) as { entry: string }[];
svgURL = `${gatewayURL}${plugin!.entry.replace("index.mjs", "active.svg")}`;
});
test.afterAll(async () => {
if (gateway?.exitCode === null) {
const exited = new Promise<void>((resolve) =>
gateway.once("exit", () => resolve()),
);
gateway.kill("SIGTERM");
await exited;
}
for (const server of [frontend, backend]) {
server?.closeAllConnections();
await new Promise<void>((resolve) => server?.close(() => resolve()));
}
});
test("Gateway SVG renders as an image but direct navigation cannot execute its script", async ({
page,
context,
}) => {
await context.addCookies([
{ name: "plugin_session", value: "synthetic", url: svgURL },
]);
await page.goto(frontendURL);
const width = await page.evaluate(async (url) => {
const image = new Image();
image.src = url;
document.body.append(image);
await image.decode();
return image.naturalWidth;
}, svgURL);
expect(width).toBe(20);
const response = await page.goto(svgURL);
await expect(page.locator("svg")).toBeVisible();
expect(await page.locator("svg").getAttribute("data-executed")).toBeNull();
expect(response?.headers()["content-security-policy"]).toBe("sandbox");
// Positive control: the same SVG really executes if its response loses the sandbox.
await page.route(svgURL, async (route) => {
const original = await route.fetch();
const headers = original.headers();
delete headers["content-security-policy"];
await route.fulfill({ response: original, headers });
});
await page.goto(svgURL);
await expect(page.locator("svg")).toHaveAttribute("data-executed", "yes");
});
for (const origin of ["same", "split"]) {
test(`native ${origin}-origin graph loads static/lazy chunks, CSS and images with credentials`, async ({
page,
context,
}) => {
await context.addCookies([
{ name: "plugin_session", value: "synthetic", url: frontendURL },
]);
requests.length = 0;
await page.goto(frontendURL);
await page.waitForFunction(() => typeof window.loadPlugin === "function");
const result = await page.evaluate(
async (url) => {
const { default: plugin } = await window.loadPlugin(url);
const lazy = await plugin.lazy();
const stylesheet = document.createElement("link");
stylesheet.rel = "stylesheet";
stylesheet.crossOrigin = "use-credentials";
stylesheet.href = plugin.css;
const cssLoaded = new Promise<void>((resolve, reject) => {
stylesheet.onload = () => resolve();
stylesheet.onerror = reject;
});
document.head.append(stylesheet);
const image = new Image();
image.crossOrigin = "use-credentials";
const imageLoaded = new Promise<void>((resolve, reject) => {
image.onload = () => resolve();
image.onerror = reject;
});
image.src = plugin.image;
document.body.append(image);
await Promise.all([cssLoaded, imageLoaded]);
const again = await window.loadPlugin(url);
return {
value: plugin.value,
lazy: lazy.lazy,
css: getComputedStyle(document.body)
.getPropertyValue("--plugin-loaded")
.trim(),
image: image.naturalWidth,
sameInstance: again.default === plugin,
executions: window.executions,
scripts: document.querySelectorAll("script[src]").length,
};
},
`${origin === "same" ? frontendURL : backendURL}/gateway/assets/${origin}/index.mjs`,
);
expect(result).toEqual({
value: 42,
lazy: "authenticated lazy chunk",
css: "yes",
image: 20,
sameInstance: true,
executions: 1,
scripts: 0,
});
expect(
requests.map((r) => r.path.split(`/assets/${origin}/`)[1]).sort(),
).toEqual([
"chunks/lazy.mjs",
"chunks/value.mjs",
"icon.svg",
"index.mjs",
"style.css",
]);
expect(
requests.every((r) => r.cookie.includes("plugin_session=synthetic")),
).toBe(true);
});
}
test("unauthenticated resource load rejects and removes the script", async ({
page,
}) => {
await page.goto(frontendURL);
await page.waitForFunction(() => typeof window.loadPlugin === "function");
const result = await page.evaluate(async (url) => {
try {
await window.loadPlugin(url);
return "unexpected success";
} catch (error) {
return `${String(error)}; scripts=${document.querySelectorAll("script[src]").length}`;
}
}, `${backendURL}/gateway/assets/denied/index.mjs`);
expect(result).toBe("Error: Plugin module unavailable; scripts=0");
});
test("timeout ends host waiting but cannot cancel late native module effects", async ({
page,
context,
}) => {
test.setTimeout(40_000);
await context.addCookies([
{ name: "plugin_session", value: "synthetic", url: frontendURL },
]);
await page.goto(frontendURL);
await page.waitForFunction(() => typeof window.loadPlugin === "function");
const result = await page.evaluate(async (url) => {
const attempt = window.loadPlugin(url).then(
() => "unexpected success",
(error: Error) => error.message,
);
const failure = await attempt;
const scripts = document.querySelectorAll("script[src]").length;
const effectsAtTimeout = window.latePluginEffects;
// Release evaluation only after the deadline has settled the host result.
window.releasePlugin();
await window.pluginResumed;
return {
failure,
scripts,
effectsAtTimeout,
effectsAfterResume: window.latePluginEffects,
resultAfterResume: await attempt,
};
}, `${backendURL}/gateway/assets/pending/pending.mjs`);
expect(result).toEqual({
failure: "Plugin module timed out",
scripts: 0,
effectsAtTimeout: 0,
effectsAfterResume: 1,
resultAfterResume: "Plugin module timed out",
});
});

View File

@ -1,5 +1,6 @@
import { afterEach, beforeEach, expect, rs, test } from "@rstest/core"; import { afterEach, beforeEach, expect, rs, test } from "@rstest/core";
import { fetchFrontendExtensions } from "@/core/extensions/api";
import { loadFrontendExtensions } from "@/core/extensions/registry"; import { loadFrontendExtensions } from "@/core/extensions/registry";
const config = rs.hoisted(() => ({ backend: "" })); const config = rs.hoisted(() => ({ backend: "" }));
@ -103,3 +104,90 @@ test("disabled, backend-only and invalid entries never fetch or import", async (
expect(request).not.toHaveBeenCalled(); expect(request).not.toHaveBeenCalled();
expect(importer).not.toHaveBeenCalled(); expect(importer).not.toHaveBeenCalled();
}); });
for (const backend of ["", "https://backend.example/prefix", "/gateway"]) {
test(`packaged entries preserve resource URLs with base ${backend}`, async () => {
config.backend = backend;
const assets = {
...entry,
transport: "assets-v1" as const,
entry: `/api/plugins/${entry.namespace}/assets/${"b".repeat(64)}/static/dist/index.mjs`,
};
const importer = rs.fn();
const assetImporter = rs.fn(async () => ({ default: extension }));
const result = await loadFrontendExtensions(
[assets],
importer,
assetImporter,
);
expect(result[0]?.extension).toEqual(extension);
expect(assetImporter).toHaveBeenCalledWith(backend + assets.entry);
expect(importer).not.toHaveBeenCalled();
assetImporter.mockClear();
for (const path of [
"../index.mjs",
"%2e%2e/index.mjs",
"index.mjs?x",
"index.css",
]) {
const invalid = {
...assets,
entry: `/api/plugins/${entry.namespace}/assets/${"b".repeat(64)}/${path}`,
};
expect(
(await loadFrontendExtensions([invalid], importer, assetImporter))[0]
?.error,
).toBeTruthy();
}
expect(assetImporter).not.toHaveBeenCalled();
});
}
test("unknown transports and failing packaged plugins are isolated", async () => {
const importer = rs.fn();
const assetImporter = rs
.fn()
.mockRejectedValueOnce(new Error("missing chunk"))
.mockResolvedValueOnce({ default: extension });
const assets = {
...entry,
transport: "assets-v1" as const,
entry: `/api/plugins/${entry.namespace}/assets/${"b".repeat(64)}/index.mjs`,
};
const unknown = { ...entry, transport: "future-v9" as "assets-v1" };
const result = await loadFrontendExtensions(
[unknown, assets, assets],
importer,
assetImporter,
);
expect(result.map((item) => !!item.extension)).toEqual([false, false, true]);
expect(importer).not.toHaveBeenCalled();
expect(assetImporter).toHaveBeenCalledTimes(2);
});
test("discovery preserves transport negotiation through parsing and isolates newer transports", async () => {
const assets = {
...entry,
transport: "assets-v1",
entry: `/api/plugins/${entry.namespace}/assets/${"b".repeat(64)}/index.mjs`,
};
rs.spyOn(globalThis, "fetch").mockResolvedValue(
new Response(
JSON.stringify([assets, { ...assets, transport: "future-v9" }]),
),
);
const discovered = await fetchFrontendExtensions();
expect(discovered.map((item) => item.transport)).toEqual([
"assets-v1",
"future-v9",
]);
const assetImporter = rs.fn(async () => ({ default: extension }));
const loaded = await loadFrontendExtensions(
discovered,
rs.fn(),
assetImporter,
);
expect(loaded[0]?.extension).toEqual(extension);
expect(loaded[1]?.error).toBeTruthy();
expect(assetImporter).toHaveBeenCalledTimes(1);
});