diff --git a/.github/workflows/e2e-tests.yml b/.github/workflows/e2e-tests.yml
index beb0a0ad4..baa4b0dce 100644
--- a/.github/workflows/e2e-tests.yml
+++ b/.github/workflows/e2e-tests.yml
@@ -7,6 +7,7 @@ on:
- 'frontend/**'
- 'backend/app/gateway/routers/plugins.py'
- 'backend/extension_test_fixtures/bookmark_plugin_gateway.py'
+ - 'backend/extension_test_fixtures/browser_asset_gateway.py'
- 'backend/packages/extension-api/**'
- 'backend/packages/harness/deerflow/extensions/**'
- 'backend/packages/harness/deerflow/config/plugin_settings.py'
@@ -20,6 +21,7 @@ on:
- 'frontend/**'
- 'backend/app/gateway/routers/plugins.py'
- 'backend/extension_test_fixtures/bookmark_plugin_gateway.py'
+ - 'backend/extension_test_fixtures/browser_asset_gateway.py'
- 'backend/packages/extension-api/**'
- 'backend/packages/harness/deerflow/extensions/**'
- 'backend/packages/harness/deerflow/config/plugin_settings.py'
diff --git a/backend/app/gateway/AGENTS.md b/backend/app/gateway/AGENTS.md
index da62ee006..d224ecbc1 100644
--- a/backend/app/gateway/AGENTS.md
+++ b/backend/app/gateway/AGENTS.md
@@ -2,12 +2,12 @@
Studio retains sanitized creation metadata.
-Capability Center's `business` adapter validates only the bundled provider's
-credential fields and creates a normal MCP connection. The MCP API accepts the
-exact isolated interpreter/module/provider launcher generated by
-`deerflow.capabilities.business`, including the exact credential environment key
-set. Do not allow arbitrary Python commands, trust manifest metadata to bypass
-execution policy, or put credentials into tool schemas. Keep admin checks, masked edits, atomic configuration writes and MCP cache reloads.
+Capability Center's `business` adapter validates bundled-provider credentials
+and creates an MCP connection. The MCP API accepts only the exact isolated
+interpreter/module/provider launcher and credential environment keys generated
+by `deerflow.capabilities.business`. Reject arbitrary Python commands; manifest
+metadata cannot bypass execution policy, and credentials stay out of tool
+schemas. Preserve admin checks, masked edits, atomic writes and MCP cache reloads.
Memory shutdown resolves hot-reloaded config and the backend, flushes, then
closes as one `await_drained` operation. Keep config resolution inside the
diff --git a/backend/app/gateway/routers/plugins.py b/backend/app/gateway/routers/plugins.py
index ac65a99b5..bd793b2dc 100644
--- a/backend/app/gateway/routers/plugins.py
+++ b/backend/app/gateway/routers/plugins.py
@@ -9,6 +9,7 @@ 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"])
@@ -30,7 +31,14 @@ async def list_plugins(request: Request, response: Response):
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
+ 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(
{
@@ -39,7 +47,8 @@ async def list_plugins(request: Request, response: Response):
"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,
+ "entry": entry,
+ "transport": transport,
"settings": {key: settings[key] for key in public},
"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):
_principal(request)
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()
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."""
diff --git a/backend/extension_test_fixtures/browser_asset_gateway.py b/backend/extension_test_fixtures/browser_asset_gateway.py
new file mode 100644
index 000000000..1f41e06c1
--- /dev/null
+++ b/backend/extension_test_fixtures/browser_asset_gateway.py
@@ -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('')
+ (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")
diff --git a/backend/packages/extension-api/deerflow_extension_api/__init__.py b/backend/packages/extension-api/deerflow_extension_api/__init__.py
index 34a85f514..af92d6c9e 100644
--- a/backend/packages/extension-api/deerflow_extension_api/__init__.py
+++ b/backend/packages/extension-api/deerflow_extension_api/__init__.py
@@ -45,7 +45,7 @@ from deerflow_extension_api.placement import (
MiddlewarePlacement,
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 (
MESSAGE_CONTENT_KIND_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.
#: From 1.0 on, bump the major for breaking changes.
-API_VERSION = "0.2.2"
+API_VERSION = "0.2.3"
__all__ = [
"ActionContext",
"BackendAction",
+ "BrowserAssets",
"BrowserModule",
"ModelTool",
"PluginContribution",
diff --git a/backend/packages/extension-api/deerflow_extension_api/plugins.py b/backend/packages/extension-api/deerflow_extension_api/plugins.py
index ec31370ce..69abbdb8d 100644
--- a/backend/packages/extension-api/deerflow_extension_api/plugins.py
+++ b/backend/packages/extension-api/deerflow_extension_api/plugins.py
@@ -8,6 +8,7 @@ from __future__ import annotations
from collections.abc import Awaitable, Callable, Mapping
from dataclasses import dataclass
+from pathlib import Path
from typing import Any
from deerflow_extension_api.auth import ExtensionPrincipal
@@ -48,11 +49,7 @@ class ModelTool:
@dataclass(frozen=True)
class BrowserModule:
- """Experimental single-file browser transport, not the final asset package API.
-
- A future versioned packaged-asset transport will coexist with this inline
- form; see docs/full-stack-plugins.md for the compatibility direction.
- """
+ """Self-contained browser module; use BrowserAssets for relative resources."""
module: str
code: str
@@ -62,12 +59,29 @@ class BrowserModule:
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)
class PluginContribution:
"""One identity, one enabled switch, optional settings and implementations.
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
are installed through the existing operator-controlled Python loader.
"""
@@ -77,7 +91,7 @@ class PluginContribution:
description: str = ""
enabled: bool = False
fields: tuple[SettingsField, ...] = ()
- frontend: BrowserModule | None = None
+ frontend: BrowserModule | BrowserAssets | None = None
backend: tuple[BackendAction, ...] = ()
api_version: int = 1
tools: tuple[ModelTool, ...] = ()
diff --git a/backend/packages/extension-api/pyproject.toml b/backend/packages/extension-api/pyproject.toml
index fdcb0ea23..b3f045595 100644
--- a/backend/packages/extension-api/pyproject.toml
+++ b/backend/packages/extension-api/pyproject.toml
@@ -1,6 +1,6 @@
[project]
name = "deerflow-extension-api"
-version = "0.2.2"
+version = "0.2.3"
description = "Public contracts for DeerFlow extensions"
requires-python = ">=3.12"
# Keep the contract package import-light and independent from the host. Public
diff --git a/backend/packages/harness/deerflow/extensions/browser_assets.py b/backend/packages/harness/deerflow/extensions/browser_assets.py
new file mode 100644
index 000000000..098ed2c60
--- /dev/null
+++ b/backend/packages/harness/deerflow/extensions/browser_assets.py
@@ -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))
diff --git a/backend/packages/harness/deerflow/extensions/registry.py b/backend/packages/harness/deerflow/extensions/registry.py
index 898e54b6d..8b17c7865 100644
--- a/backend/packages/harness/deerflow/extensions/registry.py
+++ b/backend/packages/harness/deerflow/extensions/registry.py
@@ -11,7 +11,7 @@ import inspect
import re
from collections.abc import Iterator, Sequence
from contextlib import contextmanager
-from dataclasses import dataclass
+from dataclasses import dataclass, replace
from typing import Any
from deerflow_extension_api import (
@@ -24,7 +24,7 @@ from deerflow_extension_api import (
TaskLifecycleContributor,
)
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]
@@ -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):
raise ValueError("Backend actions require unique names and async handlers")
names.add(action.name)
- if contribution.frontend and (not contribution.frontend.code or len(contribution.frontend.code.encode()) > 512 * 1024):
- raise ValueError("Browser code must be nonempty and at most 512 KiB")
+ 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")
+ elif contribution.frontend is not None:
+ raise ValueError("Unsupported browser transport")
# Validate everything before writing the plugin bucket; loader rollback also
# covers a later failure elsewhere in this package's install function.
from deerflow.config.plugin_settings import validate_contribution
diff --git a/backend/packages/harness/pyproject.toml b/backend/packages/harness/pyproject.toml
index 572450a42..4d65f469d 100644
--- a/backend/packages/harness/pyproject.toml
+++ b/backend/packages/harness/pyproject.toml
@@ -13,7 +13,7 @@ dependencies = [
# the contract version it implements, extensions declare ranges. A range
# here would let pip resolve a newer contract package than this harness
# implements, making newer extensions look supported at runtime.
- "deerflow-extension-api==0.2.2",
+ "deerflow-extension-api==0.2.3",
"dotenv>=0.9.9",
"exa-py>=1.0.0",
"httpx>=0.28.0",
diff --git a/backend/tests/test_browser_assets.py b/backend/tests/test_browser_assets.py
new file mode 100644
index 000000000..f786233b8
--- /dev/null
+++ b/backend/tests/test_browser_assets.py
@@ -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": "", "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
diff --git a/backend/tests/test_extension_api_contracts.py b/backend/tests/test_extension_api_contracts.py
index 95d023539..8885b176f 100644
--- a/backend/tests/test_extension_api_contracts.py
+++ b/backend/tests/test_extension_api_contracts.py
@@ -342,7 +342,7 @@ def test_runtime_api_version_matches_the_installed_contract_package():
"""Every additive contract slice bumps both gates together."""
from importlib.metadata import version
- assert API_VERSION == "0.2.2"
+ assert API_VERSION == "0.2.3"
assert API_VERSION == version("deerflow-extension-api")
diff --git a/backend/uv.lock b/backend/uv.lock
index 53d208016..bc2d609ef 100644
--- a/backend/uv.lock
+++ b/backend/uv.lock
@@ -918,7 +918,7 @@ extensions = []
[[package]]
name = "deerflow-extension-api"
-version = "0.2.2"
+version = "0.2.3"
source = { editable = "packages/extension-api" }
[[package]]
diff --git a/docs/full-stack-plugins.md b/docs/full-stack-plugins.md
index c189a2067..546ec5bdf 100644
--- a/docs/full-stack-plugins.md
+++ b/docs/full-stack-plugins.md
@@ -3,11 +3,11 @@
A deployment-installed Python extension can register a `PluginContribution` with
optional browser code, authenticated backend actions and model tools. This extends
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=...)`
-is an MVP transport for validating page/action host interfaces, not the final asset
-packaging contract or a requirement that all future plugins ship one JavaScript file.
+The browser contribution API is experimental. `BrowserModule(code=...)` remains the
+self-contained transport; `BrowserAssets(root=...)` adds manifest-listed resources
+without changing the page/action API or requiring a host frontend rebuild.
## 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,
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
`ActionContext(principal, settings)`. The principal comes from host authentication.
- `ModelTool(name, description, input_schema, handler, group="extensions")` declares
@@ -52,19 +54,21 @@ the process default once at construction.
The authenticated host exposes:
-| Route | Purpose |
-| ------------------------------------------------ | --------------------------------------------------------------- |
-| `GET /api/plugins` | Deployed descriptors, public configuration and declared actions |
-| `GET /api/plugins/modules/{module}/{sha256}.mjs` | Installed code with a content revision |
-| `POST /api/plugins/{namespace}/actions/{name}` | Invoke one declared backend action |
+| Route | Purpose |
+| ------------------------------------------------------- | --------------------------------------------------------------- |
+| `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/{namespace}/assets/{revision}/{path}` | One manifest-listed static resource |
+| `POST /api/plugins/{namespace}/actions/{name}` | Invoke one declared backend action |
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
-no-store caching. Existing Gateway session/CSRF policies apply; PATs do not gain a new
+from the browser. Inline modules use JavaScript content type and private no-store
+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
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.
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
@@ -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
strict validation.
-## Packaged assets and compatibility direction
+## Packaged resources (`assets-v1`)
-RFC #5510 proposes a manifest plus packaged static resources (`ui_manifest.json` and
-`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.
+Register a package-owned directory alongside the Python implementation:
-A follow-up should add a distinct, versioned packaged-asset declaration alongside the
-inline form, rather than silently changing the meaning of `BrowserModule.code`:
+```python
+from pathlib import Path
+from deerflow_extension_api import BrowserAssets, PluginContribution
-- A validated manifest identifies the entry module and permitted files under a
- package-owned asset root. The root comes from the installed package, never a browser
- supplied filesystem path.
-- Namespace/revision-scoped URLs, for example
- `/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.
+registry.plugin(PluginContribution(
+ namespace="community.example",
+ title="Example",
+ frontend=BrowserAssets("example.v1", Path(__file__).parent),
+))
+```
-These are compatibility requirements for the follow-up, not implemented asset APIs.
-The stable packaging contract requires review before plugin authors rely on it. Neither
-transport should require rebuilding DeerFlow's frontend for each compatible plugin.
+Place `ui_manifest.json` at that root, and include it and every listed file in the
+installed wheel. The [bookmarks package](../examples/deerflow-extension-bookmarks/README.md)
+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
@@ -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
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.
+
+`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.
diff --git a/examples/deerflow-extension-bookmarks/README.md b/examples/deerflow-extension-bookmarks/README.md
index fa935284d..b834613e3 100644
--- a/examples/deerflow-extension-bookmarks/README.md
+++ b/examples/deerflow-extension-bookmarks/README.md
@@ -8,7 +8,7 @@ The read-only `search_bookmarks`
model tool searches the authenticated user's saved excerpts. Each user sees only
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).
## Deployment
@@ -78,8 +78,8 @@ file above stores user bookmarks, not deployment settings.
## Host contract exercised
-`PluginContribution` packages one `BrowserModule`, five `BackendAction`s and one
-`ModelTool`. The module contributes a conversation action and a `page` DOM surface.
+`PluginContribution` packages one `BrowserAssets` declaration, five `BackendAction`s and one
+`ModelTool`. The entry module contributes a conversation action and a `page` DOM surface.
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
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
agent. Hosts without this optional navigation helper cannot reopen conversations
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.
diff --git a/examples/deerflow-extension-bookmarks/deerflow_extension_bookmarks/__init__.py b/examples/deerflow-extension-bookmarks/deerflow_extension_bookmarks/__init__.py
index 34a53fdca..bd8e0335d 100644
--- a/examples/deerflow-extension-bookmarks/deerflow_extension_bookmarks/__init__.py
+++ b/examples/deerflow-extension-bookmarks/deerflow_extension_bookmarks/__init__.py
@@ -9,7 +9,7 @@ from pathlib import Path
from deerflow_extension_api import extension
from deerflow_extension_api.plugins import (
BackendAction,
- BrowserModule,
+ BrowserAssets,
ModelTool,
PluginContribution,
)
@@ -136,7 +136,7 @@ class Bookmarks:
return handle
-@extension(api="0.2.2", name="bookmarks")
+@extension(api="0.2.3", name="bookmarks")
def install(registry, config):
enabled = config.get("enabled", False)
if type(enabled) is not bool or not isinstance(config.get("storage_path"), str):
@@ -152,9 +152,9 @@ def install(registry, config):
title="会话书签 / Bookmarks",
description="收藏有用的回答,在独立页面查找与整理。每位用户只访问自己的书签。",
enabled=enabled,
- frontend=BrowserModule(
+ frontend=BrowserAssets(
"bookmarks.v1",
- Path(__file__).with_name("client.mjs").read_text(encoding="utf-8"),
+ Path(__file__).parent,
),
backend=tuple(
BackendAction(name, store.handler(name))
diff --git a/examples/deerflow-extension-bookmarks/deerflow_extension_bookmarks/static/dist/bookmark.svg b/examples/deerflow-extension-bookmarks/deerflow_extension_bookmarks/static/dist/bookmark.svg
new file mode 100644
index 000000000..0cd09eb63
--- /dev/null
+++ b/examples/deerflow-extension-bookmarks/deerflow_extension_bookmarks/static/dist/bookmark.svg
@@ -0,0 +1 @@
+
diff --git a/examples/deerflow-extension-bookmarks/deerflow_extension_bookmarks/client.mjs b/examples/deerflow-extension-bookmarks/deerflow_extension_bookmarks/static/dist/chunks/bookmarks.mjs
similarity index 66%
rename from examples/deerflow-extension-bookmarks/deerflow_extension_bookmarks/client.mjs
rename to examples/deerflow-extension-bookmarks/deerflow_extension_bookmarks/static/dist/chunks/bookmarks.mjs
index fb48b9c98..3c078808c 100644
--- a/examples/deerflow-extension-bookmarks/deerflow_extension_bookmarks/client.mjs
+++ b/examples/deerflow-extension-bookmarks/deerflow_extension_bookmarks/static/dist/chunks/bookmarks.mjs
@@ -1,5 +1,5 @@
// 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 words = zh
? {
@@ -43,23 +43,23 @@ function mountBookmarks(root, context) {
element.setAttribute(key, value);
return element;
};
- const style = make(
- "style",
- `
- :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:-.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 style = make("link", "", {
+ rel: "stylesheet",
+ crossorigin: "use-credentials",
+ href: new URL("../styles.css", import.meta.url).href,
+ });
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 query = make("input", "", {
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.",
- );
- },
- },
- ],
- };
- },
-};
diff --git a/examples/deerflow-extension-bookmarks/deerflow_extension_bookmarks/static/dist/index.mjs b/examples/deerflow-extension-bookmarks/deerflow_extension_bookmarks/static/dist/index.mjs
new file mode 100644
index 000000000..c52be2abc
--- /dev/null
+++ b/examples/deerflow-extension-bookmarks/deerflow_extension_bookmarks/static/dist/index.mjs
@@ -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.",
+ );
+ },
+ },
+ ],
+ };
+ },
+};
diff --git a/examples/deerflow-extension-bookmarks/deerflow_extension_bookmarks/static/dist/styles.css b/examples/deerflow-extension-bookmarks/deerflow_extension_bookmarks/static/dist/styles.css
new file mode 100644
index 000000000..c219d6db9
--- /dev/null
+++ b/examples/deerflow-extension-bookmarks/deerflow_extension_bookmarks/static/dist/styles.css
@@ -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;
+}
diff --git a/examples/deerflow-extension-bookmarks/deerflow_extension_bookmarks/ui_manifest.json b/examples/deerflow-extension-bookmarks/deerflow_extension_bookmarks/ui_manifest.json
new file mode 100644
index 000000000..1304e6e16
--- /dev/null
+++ b/examples/deerflow-extension-bookmarks/deerflow_extension_bookmarks/ui_manifest.json
@@ -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"
+ ]
+}
diff --git a/examples/deerflow-extension-bookmarks/pyproject.toml b/examples/deerflow-extension-bookmarks/pyproject.toml
index a7fbb1213..71d2082c1 100644
--- a/examples/deerflow-extension-bookmarks/pyproject.toml
+++ b/examples/deerflow-extension-bookmarks/pyproject.toml
@@ -1,11 +1,11 @@
[project]
name = "deerflow-extension-bookmarks"
-version = "0.1.0"
+version = "0.2.0"
description = "Pi-inspired conversation bookmarks for the DeerFlow full-stack plugin API"
requires-python = ">=3.12"
license = "MIT"
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"]
bookmarks = "deerflow_extension_bookmarks:install"
diff --git a/frontend/AGENTS.md b/frontend/AGENTS.md
index c30be7f58..b159d7879 100644
--- a/frontend/AGENTS.md
+++ b/frontend/AGENTS.md
@@ -266,9 +266,9 @@ callbacks. Static demos and non-admin users must not query the management API.
## Full-stack plugin UI
`core/extensions/` loads authenticated deployment-installed ES modules from `/api/plugins`.
-Module downloads use the configured backend base and authenticated fetch, then import
-and release a Blob URL; packages must be self-contained (no relative module/assets).
-This inline transport is experimental; packaged-asset compatibility is documented in
+Inline modules use authenticated fetch plus a released Blob URL. Manifest assets use
+native credentialed module scripts, preserving relative imports and resource URLs.
+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.
Conversation action factories, shapes and availability callbacks are guarded per plugin;
only validated value snapshots reach the toolbar/sidebar render paths.
diff --git a/frontend/src/core/extensions/api.ts b/frontend/src/core/extensions/api.ts
index 51e4543a7..2606c1dc5 100644
--- a/frontend/src/core/extensions/api.ts
+++ b/frontend/src/core/extensions/api.ts
@@ -10,6 +10,8 @@ const contributions = z.array(
module: z.string().nullable(),
backend_actions: z.array(z.string()).optional(),
entry: z.string().nullable(),
+ // Keep unknown transports so the loader can reject only that contribution.
+ transport: z.string().nullable().optional(),
title: z.string(),
description: z.string(),
settings: z.record(z.union([z.boolean(), z.number(), z.string()])),
diff --git a/frontend/src/core/extensions/asset-module.ts b/frontend/src/core/extensions/asset-module.ts
new file mode 100644
index 000000000..576154728
--- /dev/null
+++ b/frontend/src/core/extensions/asset-module.ts
@@ -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);
+ });
+}
diff --git a/frontend/src/core/extensions/contracts.ts b/frontend/src/core/extensions/contracts.ts
index 591870e77..d1048980d 100644
--- a/frontend/src/core/extensions/contracts.ts
+++ b/frontend/src/core/extensions/contracts.ts
@@ -34,6 +34,8 @@ export type FrontendContribution = {
namespace: string;
module: string | null;
entry: string | null;
+ /** Known transports: inline-v1 and assets-v1; unknown values fail per plugin. */
+ transport?: string | null;
title: string;
description: string;
settings: ExtensionSettings;
diff --git a/frontend/src/core/extensions/registry.ts b/frontend/src/core/extensions/registry.ts
index 8d436f49a..99fba591e 100644
--- a/frontend/src/core/extensions/registry.ts
+++ b/frontend/src/core/extensions/registry.ts
@@ -11,6 +11,7 @@ import {
import { fetch } from "@/core/api/fetcher";
import { getBackendBaseURL } from "@/core/config";
+import { importAssetModule } from "./asset-module";
import type { FrontendContribution, FrontendExtension } from "./contracts";
export function extensionIcon(name?: string): LucideIcon {
@@ -34,35 +35,50 @@ const importModule: ModuleImporter = (url) =>
export async function loadFrontendExtensions(
entries: FrontendContribution[],
importer: ModuleImporter = importModule,
+ assetImporter: ModuleImporter = importAssetModule,
): Promise {
return Promise.all(
entries.map(async (entry) => {
if (entry.settings.enabled !== true || entry.module === null)
return entry;
try {
- // Only fetch installed Gateway assets, using the same base and credentials
- // as discovery/actions. Cross-origin import() would omit session cookies.
- const expected = `/api/plugins/modules/${entry.module}/`;
- if (
- !entry.entry?.startsWith(expected) ||
- !/^[a-f0-9]{64}\.mjs$/.test(entry.entry.slice(expected.length))
- )
- throw new Error("Invalid installed module entry");
- const response = await fetch(`${getBackendBaseURL()}${entry.entry}`, {
- cache: "no-store",
- });
- if (!response.ok)
- throw new Error(`Plugin module unavailable (${response.status})`);
- // BrowserModule is a self-contained ES module; it has no relative imports.
- const moduleURL = URL.createObjectURL(
- new Blob([await response.text()], { type: "text/javascript" }),
- );
let loadedModule: FrontendExtension;
- try {
- loadedModule = (await importer(moduleURL))
- .default as FrontendExtension;
- } finally {
- URL.revokeObjectURL(moduleURL);
+ 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}/`;
+ if (
+ !entry.entry?.startsWith(expected) ||
+ !/^[a-f0-9]{64}\.mjs$/.test(entry.entry.slice(expected.length))
+ )
+ throw new Error("Invalid installed module entry");
+ const response = await fetch(`${getBackendBaseURL()}${entry.entry}`, {
+ cache: "no-store",
+ });
+ if (!response.ok)
+ throw new Error(`Plugin module unavailable (${response.status})`);
+ const moduleURL = URL.createObjectURL(
+ new Blob([await response.text()], { type: "text/javascript" }),
+ );
+ try {
+ loadedModule = (await importer(moduleURL))
+ .default as FrontendExtension;
+ } finally {
+ URL.revokeObjectURL(moduleURL);
+ }
+ } else {
+ throw new Error("Unsupported browser asset transport");
}
if (
loadedModule?.apiVersion !== 1 ||
diff --git a/frontend/tests/e2e/bookmark-plugin.spec.ts b/frontend/tests/e2e/bookmark-plugin.spec.ts
index 8cc96b672..8617ed9b5 100644
--- a/frontend/tests/e2e/bookmark-plugin.spec.ts
+++ b/frontend/tests/e2e/bookmark-plugin.spec.ts
@@ -124,7 +124,7 @@ for (const source of ["default", "custom-toolbar", "custom-sidebar"]) {
await route.fulfill({ status: 204, headers: cors });
return;
}
- if (url.pathname.includes("/modules/")) {
+ if (url.pathname.includes("/assets/")) {
moduleRequests.push(url.href);
if (
!(await route.request().allHeaders()).cookie?.includes(
@@ -157,9 +157,9 @@ for (const source of ["default", "custom-toolbar", "custom-sidebar"]) {
exact: true,
});
await expect(libraryLink).toHaveAttribute("href", libraryURL);
- expect(moduleRequests).toHaveLength(1);
+ expect(moduleRequests).toHaveLength(2);
const modulePrefix = new URL(
- `${backendBase.replace(/\/+$/, "")}/api/plugins/modules/`,
+ `${backendBase.replace(/\/+$/, "")}/api/plugins/community.bookmarks/assets/`,
frontendURL,
).href;
expect(moduleRequests[0]?.startsWith(modulePrefix)).toBe(true);
@@ -219,6 +219,21 @@ for (const source of ["default", "custom-toolbar", "custom-sidebar"]) {
await expect(
page.getByText("Keep answers worth returning to", { exact: true }),
).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", {
name: "Bookmark name",
exact: true,
diff --git a/frontend/tests/e2e/plugin-assets.spec.ts b/frontend/tests/e2e/plugin-assets.spec.ts
new file mode 100644
index 000000000..8dd34370e
--- /dev/null
+++ b/frontend/tests/e2e/plugin-assets.spec.ts
@@ -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;
+ 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((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 = {
+ "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",
+ '',
+ ],
+ };
+ const handler: RequestListener = (req, res) => {
+ if (req.url === "/") {
+ res.setHeader("Content-Type", "text/html");
+ res.end(
+ '',
+ );
+ 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((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((resolve) =>
+ gateway.once("exit", () => resolve()),
+ );
+ gateway.kill("SIGTERM");
+ await exited;
+ }
+ for (const server of [frontend, backend]) {
+ server?.closeAllConnections();
+ await new Promise((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((resolve, reject) => {
+ stylesheet.onload = () => resolve();
+ stylesheet.onerror = reject;
+ });
+ document.head.append(stylesheet);
+ const image = new Image();
+ image.crossOrigin = "use-credentials";
+ const imageLoaded = new Promise((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",
+ });
+});
diff --git a/frontend/tests/unit/core/extensions/registry.test.ts b/frontend/tests/unit/core/extensions/registry.test.ts
index b94a310b9..4b69839b7 100644
--- a/frontend/tests/unit/core/extensions/registry.test.ts
+++ b/frontend/tests/unit/core/extensions/registry.test.ts
@@ -1,5 +1,6 @@
import { afterEach, beforeEach, expect, rs, test } from "@rstest/core";
+import { fetchFrontendExtensions } from "@/core/extensions/api";
import { loadFrontendExtensions } from "@/core/extensions/registry";
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(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);
+});