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/**'
- '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'

View File

@ -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

View File

@ -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."""

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,
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",

View File

@ -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, ...] = ()

View File

@ -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

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
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

View File

@ -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",

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."""
from importlib.metadata import version
assert API_VERSION == "0.2.2"
assert API_VERSION == "0.2.3"
assert API_VERSION == version("deerflow-extension-api")

2
backend/uv.lock generated
View File

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

View File

@ -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.

View File

@ -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.

View File

@ -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))

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.
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.",
);
},
},
],
};
},
};

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]
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"

View File

@ -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.

View File

@ -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()])),

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;
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;

View File

@ -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<LoadedContribution[]> {
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 ||

View File

@ -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,

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 { 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);
});