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>
113 lines
4.3 KiB
Python
113 lines
4.3 KiB
Python
"""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))
|