mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-09-25 14:06:18 +00:00
* 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>
182 lines
8.7 KiB
Python
182 lines
8.7 KiB
Python
"""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
|