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