mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-09-25 05:56:18 +00:00
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:
parent
736b5a4216
commit
2a9beb34b9
2
.github/workflows/e2e-tests.yml
vendored
2
.github/workflows/e2e-tests.yml
vendored
@ -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'
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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."""
|
||||
|
||||
34
backend/extension_test_fixtures/browser_asset_gateway.py
Normal file
34
backend/extension_test_fixtures/browser_asset_gateway.py
Normal 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")
|
||||
@ -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",
|
||||
|
||||
@ -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, ...] = ()
|
||||
|
||||
@ -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
|
||||
|
||||
112
backend/packages/harness/deerflow/extensions/browser_assets.py
Normal file
112
backend/packages/harness/deerflow/extensions/browser_assets.py
Normal 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))
|
||||
@ -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
|
||||
|
||||
@ -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",
|
||||
|
||||
181
backend/tests/test_browser_assets.py
Normal file
181
backend/tests/test_browser_assets.py
Normal 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
|
||||
@ -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
2
backend/uv.lock
generated
@ -918,7 +918,7 @@ extensions = []
|
||||
|
||||
[[package]]
|
||||
name = "deerflow-extension-api"
|
||||
version = "0.2.2"
|
||||
version = "0.2.3"
|
||||
source = { editable = "packages/extension-api" }
|
||||
|
||||
[[package]]
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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))
|
||||
|
||||
1
examples/deerflow-extension-bookmarks/deerflow_extension_bookmarks/static/dist/bookmark.svg
vendored
Normal file
1
examples/deerflow-extension-bookmarks/deerflow_extension_bookmarks/static/dist/bookmark.svg
vendored
Normal 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 |
@ -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.",
|
||||
);
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
};
|
||||
63
examples/deerflow-extension-bookmarks/deerflow_extension_bookmarks/static/dist/index.mjs
vendored
Normal file
63
examples/deerflow-extension-bookmarks/deerflow_extension_bookmarks/static/dist/index.mjs
vendored
Normal 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.",
|
||||
);
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
};
|
||||
108
examples/deerflow-extension-bookmarks/deerflow_extension_bookmarks/static/dist/styles.css
vendored
Normal file
108
examples/deerflow-extension-bookmarks/deerflow_extension_bookmarks/static/dist/styles.css
vendored
Normal 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;
|
||||
}
|
||||
@ -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"
|
||||
]
|
||||
}
|
||||
@ -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"
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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()])),
|
||||
|
||||
41
frontend/src/core/extensions/asset-module.ts
Normal file
41
frontend/src/core/extensions/asset-module.ts
Normal 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);
|
||||
});
|
||||
}
|
||||
@ -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;
|
||||
|
||||
@ -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 ||
|
||||
|
||||
@ -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,
|
||||
|
||||
314
frontend/tests/e2e/plugin-assets.spec.ts
Normal file
314
frontend/tests/e2e/plugin-assets.spec.ts
Normal 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",
|
||||
});
|
||||
});
|
||||
@ -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);
|
||||
});
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user