stage #4561 + stamp v0.51.545 — extension manifest asset bundles

santastabber. HERMES_WEBUI_EXTENSION_MANIFEST: bundle extension assets via a 64KB JSON manifest
instead of long URL lists. Same same-origin allowlist validator, traversal-guarded manifest path,
default-off. Nathan blessed (richer extension system). Co-authored-by: santastabber <santastabber@users.noreply.github.com>
This commit is contained in:
nesquena-hermes committed 2026-06-21 00:37:08 +00:00
1 parent 36611b7e20
commit 3e229141af
5 files changed
+584 -35

No files matched your search

+6
View File
@@ -3,6 +3,12 @@
## [Unreleased]
## [v0.51.545] — 2026-06-20 — Release TD (extension manifest asset bundles)
### Added
- **Bundle extension assets with a manifest instead of long comma-separated URL lists (#4505).** A new `HERMES_WEBUI_EXTENSION_MANIFEST` env points to a small JSON manifest (capped at 64 KB) that lists an extension's `scripts`/`stylesheets`, so self-hosters running multiple local extensions no longer have to maintain unwieldy `HERMES_WEBUI_EXTENSION_SCRIPT_URLS` strings. Per-extension `enabled: false` skips a bundle. The manifest path is traversal-guarded (must resolve inside the extension dir; absolute/`~` paths rejected), and every asset URL still goes through the exact same same-origin allowlist validator as before (`/extensions/` or `/static/` only — never third-party fetch), capped at 32 entries and deduped. Fully opt-in and default-off; with no manifest env set, behavior is unchanged. Thanks @santastabber.
## [v0.51.544] — 2026-06-20 — Release TC (opt-in: render markdown in your own messages)
### Added
+3 -2
View File
@@ -344,8 +344,9 @@ Full list of environment variables:
| `HERMES_WEBUI_CSP_CONNECT_EXTRA` | *(unset)* | Optional space-separated `http(s)://` or `ws(s)://` origins to append to the report-only CSP `connect-src` directive for reverse-proxy or tunnel deployments |
| `HERMES_WEBUI_SSE_CHUNKED` | *(unset)* | Set truthy (`1`/`true`/`yes`/`on`) to send SSE with `Transfer-Encoding: chunked`. Needed behind buffering reverse proxies (e.g. `jupyter-server-proxy`) that otherwise buffer the whole stream; harmless but unnecessary for directly-served deployments |
| `HERMES_WEBUI_EXTENSION_DIR` | *(unset)* | Optional local directory served at `/extensions/`; must point to an existing directory before extension injection is enabled |
| `HERMES_WEBUI_EXTENSION_SCRIPT_URLS` | *(unset)* | Optional comma-separated same-origin script URLs to inject; see [WebUI Extensions](docs/EXTENSIONS.md) |
| `HERMES_WEBUI_EXTENSION_STYLESHEET_URLS` | *(unset)* | Optional comma-separated same-origin stylesheet URLs to inject; see [WebUI Extensions](docs/EXTENSIONS.md) |
| `HERMES_WEBUI_EXTENSION_MANIFEST` | *(unset)* | Optional relative JSON manifest inside `HERMES_WEBUI_EXTENSION_DIR` listing bundled scripts/styles to inject; see [WebUI Extensions](docs/EXTENSIONS.md) |
| `HERMES_WEBUI_EXTENSION_SCRIPT_URLS` | *(unset)* | Optional comma-separated same-origin script URLs to inject; appended after manifest scripts; see [WebUI Extensions](docs/EXTENSIONS.md) |
| `HERMES_WEBUI_EXTENSION_STYLESHEET_URLS` | *(unset)* | Optional comma-separated same-origin stylesheet URLs to inject; appended after manifest stylesheets; see [WebUI Extensions](docs/EXTENSIONS.md) |
| `HERMES_HOME` | Windows: `%LOCALAPPDATA%\hermes`; POSIX: `~/.hermes` | Base directory for Hermes state (affects all paths) |
| `HERMES_CONFIG_PATH` | `$HERMES_HOME/config.yaml` | Path to Hermes config file |
| `HERMES_WEBUI_SERVER_CWD` | *(unset)* | Working directory for the server process. Defaults to the agent dir; point it at a writable workspace when the agent dir is read-only so fallback relative writes land somewhere writable |
+181 -32
View File
@@ -6,10 +6,11 @@ It is disabled by default and never executes or fetches third-party URLs.
"""
import html
import json
import logging
import os
from pathlib import Path
from typing import Dict, List, Optional
from typing import Dict, List, Optional, Tuple
from urllib.parse import unquote, urlsplit
from api.helpers import _security_headers, j
@@ -22,14 +23,24 @@ _log = logging.getLogger(__name__)
# avoids rendering tens of thousands of <script> tags into every page load.
_MAX_URL_LIST = 32
# Keep extension manifests small and auditable. The manifest is a convenience for
# bundling static assets, not a package manager or dependency lockfile.
_MAX_MANIFEST_BYTES = 64 * 1024
# Tracks rejected URL strings we've already warned about so a misconfigured env
# var doesn't spam the log on every request that re-reads it.
_warned_urls: set = set()
class _ManifestTooLarge(ValueError):
pass
EXTENSION_ROUTE_PREFIX = "/extensions/"
_EXTENSION_DIR_ENV = "HERMES_WEBUI_EXTENSION_DIR"
_EXTENSION_SCRIPT_URLS_ENV = "HERMES_WEBUI_EXTENSION_SCRIPT_URLS"
_EXTENSION_STYLESHEET_URLS_ENV = "HERMES_WEBUI_EXTENSION_STYLESHEET_URLS"
_EXTENSION_MANIFEST_ENV = "HERMES_WEBUI_EXTENSION_MANIFEST"
_ALLOWED_ASSET_PREFIXES = ("/extensions/", "/static/")
_EXTENSION_MIME = {
@@ -107,47 +118,185 @@ def _is_safe_asset_url(value: str) -> bool:
return False
def _read_url_list(env_name: str) -> List[str]:
raw = os.getenv(env_name, "")
urls = []
for item in raw.split(","):
value = item.strip()
if not value:
continue
if _is_safe_asset_url(value):
urls.append(value)
if len(urls) >= _MAX_URL_LIST:
# Stop accumulating after the cap. Anything past this point
# would be silently dropped anyway; logging once makes the
# truncation visible to a confused operator.
if env_name not in _warned_urls:
_warned_urls.add(env_name)
_log.warning(
"Extension URL list %s truncated at %d entries",
env_name, _MAX_URL_LIST,
)
break
elif value not in _warned_urls:
# First-time-seen invalid URL: log once per process so a typo
# in HERMES_WEBUI_EXTENSION_*_URLS doesn't disappear silently.
_warned_urls.add(value)
def _warn_rejected_url(value: str, source: str) -> None:
if value in _warned_urls:
return
_warned_urls.add(value)
_log.warning(
"Rejected extension URL %r from %s (not a same-origin "
"/extensions/ or /static/ path, or contains unsafe chars)",
value, source,
)
def _append_safe_asset_url(
urls: List[str], value: str, source: str, *, dedupe: bool = True
) -> bool:
"""Append a validated URL while preserving order and the global cap.
Returns False when the caller should stop accumulating entries for this list.
Manifest paths dedupe by default, while env-only lists preserve their legacy
behavior unless they are appending after manifest-provided assets.
"""
value = value.strip() if isinstance(value, str) else ""
if not value:
return True
if not _is_safe_asset_url(value):
_warn_rejected_url(value, source)
return True
if dedupe and value in urls:
return True
if len(urls) >= _MAX_URL_LIST:
if source not in _warned_urls:
_warned_urls.add(source)
_log.warning(
"Rejected extension URL %r from %s (not a same-origin "
"/extensions/ or /static/ path, or contains unsafe chars)",
value, env_name,
"Extension URL list %s truncated at %d entries",
source, _MAX_URL_LIST,
)
return False
urls.append(value)
return True
def _read_url_list(env_name: str, existing: Optional[List[str]] = None) -> List[str]:
raw = os.getenv(env_name, "")
urls = list(existing or [])
# Preserve legacy env-only behavior: duplicate env URLs injected twice before
# manifests existed. When a manifest seeds the list, dedupe appended env URLs
# so bundle manifests and explicit overrides do not double-load an asset.
dedupe = existing is not None
for item in raw.split(","):
if not _append_safe_asset_url(urls, item, env_name, dedupe=dedupe):
break
return urls
def _manifest_path(root: Path) -> Optional[Path]:
raw = os.getenv(_EXTENSION_MANIFEST_ENV, "").strip()
if not raw:
return None
if raw.startswith(("/", "~")):
_log.warning("Rejected extension manifest path from %s", _EXTENSION_MANIFEST_ENV)
return None
rel = _fully_unquote_path(raw)
if not _is_safe_relative_path(rel):
_log.warning("Rejected extension manifest path from %s", _EXTENSION_MANIFEST_ENV)
return None
manifest = (root / rel).resolve()
try:
manifest.relative_to(root)
except ValueError:
_log.warning("Rejected extension manifest path from %s", _EXTENSION_MANIFEST_ENV)
return None
return manifest
def _manifest_asset_url(value: object) -> str:
"""Normalize a manifest asset entry to the existing same-origin URL format."""
if not isinstance(value, str):
return ""
item = value.strip()
if not item:
return ""
parsed = urlsplit(item)
if parsed.scheme or parsed.netloc or item.startswith("//"):
return item
# Manifests are meant to make bundled local assets less noisy to list, so
# bare relative paths resolve under /extensions/. Absolute same-origin paths
# are still allowed and go through the same validator as env-configured URLs.
if item.startswith("/"):
return item
return EXTENSION_ROUTE_PREFIX + item
def _iter_manifest_entries(manifest: object) -> List[Tuple[str, object]]:
entries: List[Tuple[str, object]] = []
extension_entries: object = []
if isinstance(manifest, dict):
entries.append(("manifest", manifest))
extension_entries = manifest.get("extensions", [])
elif isinstance(manifest, list):
extension_entries = manifest
if isinstance(extension_entries, list):
for index, extension in enumerate(extension_entries):
if isinstance(extension, dict) and extension.get("enabled", True) is False:
continue
entries.append((f"manifest.extensions[{index}]", extension))
return entries
def _entry_asset_values(entry: Dict[str, object], key: str) -> List[object]:
values = entry.get(key, [])
return values if isinstance(values, list) else []
def _read_manifest_text(manifest_file: Path) -> str:
with manifest_file.open("rb") as fh:
data = fh.read(_MAX_MANIFEST_BYTES + 1)
if len(data) > _MAX_MANIFEST_BYTES:
raise _ManifestTooLarge("manifest too large")
return data.decode("utf-8")
def _read_manifest_urls(root: Path) -> Tuple[List[str], List[str]]:
manifest_file = _manifest_path(root)
if manifest_file is None:
return [], []
try:
if not manifest_file.exists() or not manifest_file.is_file():
_log.warning("Configured extension manifest was not found")
return [], []
manifest = json.loads(_read_manifest_text(manifest_file))
except _ManifestTooLarge:
_log.warning("Configured extension manifest exceeds %d bytes", _MAX_MANIFEST_BYTES)
return [], []
except json.JSONDecodeError:
_log.warning("Configured extension manifest is not valid JSON")
return [], []
except (OSError, UnicodeDecodeError):
_log.warning("Configured extension manifest could not be read")
return [], []
scripts: List[str] = []
stylesheets: List[str] = []
scripts_full = False
stylesheets_full = False
for _source, entry in _iter_manifest_entries(manifest):
if not isinstance(entry, dict):
continue
if not scripts_full:
for value in _entry_asset_values(entry, "scripts"):
if not _append_safe_asset_url(
scripts, _manifest_asset_url(value), "manifest:scripts"
):
scripts_full = True
break
if not stylesheets_full:
for value in _entry_asset_values(entry, "stylesheets"):
if not _append_safe_asset_url(
stylesheets, _manifest_asset_url(value), "manifest:stylesheets"
):
stylesheets_full = True
break
if scripts_full and stylesheets_full:
break
return scripts, stylesheets
def get_extension_config() -> Dict[str, object]:
"""Return public extension config without exposing filesystem paths."""
enabled = _extension_root() is not None
if not enabled:
root = _extension_root()
if root is None:
return {"enabled": False, "script_urls": [], "stylesheet_urls": []}
manifest_scripts, manifest_stylesheets = _read_manifest_urls(root)
return {
"enabled": True,
"script_urls": _read_url_list(_EXTENSION_SCRIPT_URLS_ENV),
"stylesheet_urls": _read_url_list(_EXTENSION_STYLESHEET_URLS_ENV),
"script_urls": _read_url_list(
_EXTENSION_SCRIPT_URLS_ENV, manifest_scripts or None
),
"stylesheet_urls": _read_url_list(
_EXTENSION_STYLESHEET_URLS_ENV, manifest_stylesheets or None
),
}
+43 -1
View File
@@ -23,6 +23,7 @@ Extensions can:
- serve files from one configured local directory at `/extensions/...`
- inject configured same-origin stylesheets into `<head>`
- inject configured same-origin scripts before `</body>`
- read a local JSON manifest that lists bundled scripts/styles to inject
- call the normal WebUI APIs available to the browser session
Extensions cannot, by themselves:
@@ -53,14 +54,53 @@ export HERMES_WEBUI_EXTENSION_SCRIPT_URLS=/extensions/runtime.js,/extensions/app
export HERMES_WEBUI_EXTENSION_STYLESHEET_URLS=/extensions/base.css,/extensions/theme.css
```
For bundled or multi-extension installs, you may list assets in a manifest file
inside `HERMES_WEBUI_EXTENSION_DIR` instead of maintaining long comma-separated
environment variables:
```bash
cat > ~/.hermes/webui-extension-bundle/extensions.json <<'JSON'
{
"extensions": [
{
"id": "templates",
"scripts": ["templates/templates.js"],
"stylesheets": ["templates/templates.css"]
},
{
"id": "sidebar-tools",
"scripts": ["sidebar-tools/sidebar-tools.js"],
"stylesheets": ["sidebar-tools/sidebar-tools.css"]
}
]
}
JSON
HERMES_WEBUI_EXTENSION_DIR=~/.hermes/webui-extension-bundle \
HERMES_WEBUI_EXTENSION_MANIFEST=extensions.json \
./start.sh
```
Manifest entries use the same URL safety rules as the environment variables.
Bare relative entries such as `templates/templates.js` resolve to
`/extensions/templates/templates.js`; absolute same-origin entries such as
`/extensions/shared.js` or `/static/theme.css` are also accepted. A manifest may
be an object with top-level `scripts` / `stylesheets`, an object with an
`extensions` array, or a top-level array of extension objects. Disabled entries
may be kept in the manifest with the JSON boolean `"enabled": false`. Explicit
`HERMES_WEBUI_EXTENSION_SCRIPT_URLS` and
`HERMES_WEBUI_EXTENSION_STYLESHEET_URLS` still work and are appended after
manifest assets, with duplicates ignored.
## URL rules
Injected asset URLs are deliberately restricted:
- must be same-origin paths
- must start with `/extensions/` or `/static/`
- must start with `/extensions/` or `/static/` after manifest normalization
- must not include a URL scheme, host, fragment, quote, angle bracket, newline,
NUL byte, or backslash
- must not contain dot-segments or dotfiles after percent-decoding
Allowed examples:
@@ -101,6 +141,8 @@ The static handler is sandboxed:
- dotfiles and dot-directories are not served
- symlinks that resolve outside the extension directory are rejected
- missing or invalid extension directories behave as disabled
- manifest paths must be relative files inside the configured extension directory
- malformed, missing, or oversized manifests are ignored without enabling unsafe URLs
- failures return a generic 404 without exposing local filesystem paths
## Security notes
+351
View File
@@ -7,8 +7,21 @@ The extension surface must stay deliberately small and safe:
- static assets sandboxed to the configured extension directory
"""
import logging
from types import SimpleNamespace
import pytest
@pytest.fixture(autouse=True)
def _clear_extension_env(monkeypatch):
for name in (
"HERMES_WEBUI_EXTENSION_DIR",
"HERMES_WEBUI_EXTENSION_MANIFEST",
"HERMES_WEBUI_EXTENSION_SCRIPT_URLS",
"HERMES_WEBUI_EXTENSION_STYLESHEET_URLS",
):
monkeypatch.delenv(name, raising=False)
class FakeHandler:
def __init__(self):
@@ -127,6 +140,344 @@ def test_extension_route_remains_behind_webui_auth(monkeypatch):
assert check_auth(static, SimpleNamespace(path="/static/ui.js", query="")) is True
def test_extension_manifest_adds_bundled_assets_before_env_urls(tmp_path, monkeypatch):
root = tmp_path / "extensions"
root.mkdir()
(root / "manifest.json").write_text(
"""
{
"scripts": ["runtime.js"],
"stylesheets": ["base.css"],
"extensions": [
{
"id": "templates",
"scripts": ["templates/app.js", "/extensions/shared.js"],
"stylesheets": ["templates/app.css"]
},
{
"id": "disabled",
"enabled": false,
"scripts": ["disabled.js"],
"stylesheets": ["disabled.css"]
}
]
}
""",
encoding="utf-8",
)
monkeypatch.setenv("HERMES_WEBUI_EXTENSION_DIR", str(root))
monkeypatch.setenv("HERMES_WEBUI_EXTENSION_MANIFEST", "manifest.json")
monkeypatch.setenv(
"HERMES_WEBUI_EXTENSION_SCRIPT_URLS",
"/extensions/templates/app.js, /extensions/env-only.js",
)
monkeypatch.setenv(
"HERMES_WEBUI_EXTENSION_STYLESHEET_URLS",
"/extensions/env-only.css",
)
from api.extensions import get_extension_config
assert get_extension_config() == {
"enabled": True,
"script_urls": [
"/extensions/runtime.js",
"/extensions/templates/app.js",
"/extensions/shared.js",
"/extensions/env-only.js",
],
"stylesheet_urls": [
"/extensions/base.css",
"/extensions/templates/app.css",
"/extensions/env-only.css",
],
}
def test_extension_manifest_reuses_url_safety_rules(tmp_path, monkeypatch):
monkeypatch.delenv("HERMES_WEBUI_EXTENSION_SCRIPT_URLS", raising=False)
monkeypatch.delenv("HERMES_WEBUI_EXTENSION_STYLESHEET_URLS", raising=False)
root = tmp_path / "extensions"
root.mkdir()
(root / "bundle.json").write_text(
"""
{
"scripts": [
"safe.js",
"../escape.js",
"/api/session",
"https://example.com/evil.js",
"/extensions/%252e%252e/api/session"
],
"stylesheets": ["safe.css", "nested/../escape.css"]
}
""",
encoding="utf-8",
)
monkeypatch.setenv("HERMES_WEBUI_EXTENSION_DIR", str(root))
monkeypatch.setenv("HERMES_WEBUI_EXTENSION_MANIFEST", "bundle.json")
from api.extensions import get_extension_config
assert get_extension_config() == {
"enabled": True,
"script_urls": ["/extensions/safe.js"],
"stylesheet_urls": ["/extensions/safe.css"],
}
def test_extension_manifest_path_must_stay_inside_extension_root(tmp_path, monkeypatch):
monkeypatch.delenv("HERMES_WEBUI_EXTENSION_STYLESHEET_URLS", raising=False)
root = tmp_path / "extensions"
root.mkdir()
outside = tmp_path / "outside.json"
outside.write_text('{"scripts":["outside.js"]}', encoding="utf-8")
monkeypatch.setenv("HERMES_WEBUI_EXTENSION_DIR", str(root))
monkeypatch.setenv("HERMES_WEBUI_EXTENSION_MANIFEST", "../outside.json")
monkeypatch.setenv("HERMES_WEBUI_EXTENSION_SCRIPT_URLS", "/extensions/env.js")
from api.extensions import get_extension_config
assert get_extension_config() == {
"enabled": True,
"script_urls": ["/extensions/env.js"],
"stylesheet_urls": [],
}
def test_extension_manifest_url_list_shares_cap_with_env_urls(tmp_path, monkeypatch):
monkeypatch.delenv("HERMES_WEBUI_EXTENSION_STYLESHEET_URLS", raising=False)
root = tmp_path / "extensions"
root.mkdir()
scripts = ",".join(f'"script{i}.js"' for i in range(40))
(root / "manifest.json").write_text(f'{{"scripts":[{scripts}]}}', encoding="utf-8")
monkeypatch.setenv("HERMES_WEBUI_EXTENSION_DIR", str(root))
monkeypatch.setenv("HERMES_WEBUI_EXTENSION_MANIFEST", "manifest.json")
monkeypatch.setenv("HERMES_WEBUI_EXTENSION_SCRIPT_URLS", "/extensions/env.js")
from api.extensions import _MAX_URL_LIST, get_extension_config
config = get_extension_config()
assert len(config["script_urls"]) == _MAX_URL_LIST
assert config["script_urls"][0] == "/extensions/script0.js"
assert config["script_urls"][-1] == f"/extensions/script{_MAX_URL_LIST - 1}.js"
assert "/extensions/env.js" not in config["script_urls"]
def test_extension_manifest_logs_invalid_json_separately_from_oversize(tmp_path, monkeypatch, caplog):
root = tmp_path / "extensions"
root.mkdir()
monkeypatch.setenv("HERMES_WEBUI_EXTENSION_DIR", str(root))
monkeypatch.setenv("HERMES_WEBUI_EXTENSION_MANIFEST", "manifest.json")
(root / "manifest.json").write_text('{"scripts": [', encoding="utf-8")
from api.extensions import get_extension_config
caplog.set_level(logging.WARNING, logger="api.extensions")
assert get_extension_config() == {
"enabled": True,
"script_urls": [],
"stylesheet_urls": [],
}
assert any("not valid JSON" in record.message for record in caplog.records)
assert not any("could not be read" in record.message for record in caplog.records)
assert not any("exceeds" in record.message for record in caplog.records)
def test_extension_manifest_logs_invalid_utf8_as_unreadable(tmp_path, monkeypatch, caplog):
root = tmp_path / "extensions"
root.mkdir()
monkeypatch.setenv("HERMES_WEBUI_EXTENSION_DIR", str(root))
monkeypatch.setenv("HERMES_WEBUI_EXTENSION_MANIFEST", "manifest.json")
(root / "manifest.json").write_bytes(b"{\xff}")
from api.extensions import get_extension_config
caplog.set_level(logging.WARNING, logger="api.extensions")
assert get_extension_config() == {
"enabled": True,
"script_urls": [],
"stylesheet_urls": [],
}
assert any("could not be read" in record.message for record in caplog.records)
assert not any("exceeds" in record.message for record in caplog.records)
def test_extension_manifest_logs_oversize_distinctly(tmp_path, monkeypatch, caplog):
root = tmp_path / "extensions"
root.mkdir()
monkeypatch.setenv("HERMES_WEBUI_EXTENSION_DIR", str(root))
monkeypatch.setenv("HERMES_WEBUI_EXTENSION_MANIFEST", "manifest.json")
from api import extensions
(root / "manifest.json").write_text(
'{"scripts":["pwn.js"]}' + (" " * extensions._MAX_MANIFEST_BYTES),
encoding="utf-8",
)
caplog.set_level(logging.WARNING, logger="api.extensions")
assert extensions.get_extension_config() == {
"enabled": True,
"script_urls": [],
"stylesheet_urls": [],
}
assert any("exceeds" in record.message for record in caplog.records)
assert not any("not valid JSON" in record.message for record in caplog.records)
def test_extension_manifest_reads_only_bounded_size(tmp_path, monkeypatch):
root = tmp_path / "extensions"
root.mkdir()
monkeypatch.setenv("HERMES_WEBUI_EXTENSION_DIR", str(root))
monkeypatch.setenv("HERMES_WEBUI_EXTENSION_MANIFEST", "manifest.json")
from api import extensions
# If the bounded read/cap check is removed, this parses as valid JSON and
# would inject /extensions/pwn.js. The trailing padding makes it oversize.
oversize = '{"scripts":["pwn.js"]}' + (" " * extensions._MAX_MANIFEST_BYTES)
(root / "manifest.json").write_text(oversize, encoding="utf-8")
assert extensions.get_extension_config() == {
"enabled": True,
"script_urls": [],
"stylesheet_urls": [],
}
def test_extension_manifest_multibyte_payload_is_bounded_by_bytes(tmp_path, monkeypatch):
root = tmp_path / "extensions"
root.mkdir()
monkeypatch.setenv("HERMES_WEBUI_EXTENSION_DIR", str(root))
monkeypatch.setenv("HERMES_WEBUI_EXTENSION_MANIFEST", "manifest.json")
from api import extensions
oversize_multibyte = '{"scripts":["pwn.js"]}' + ("€" * extensions._MAX_MANIFEST_BYTES)
(root / "manifest.json").write_text(oversize_multibyte, encoding="utf-8")
assert extensions.get_extension_config() == {
"enabled": True,
"script_urls": [],
"stylesheet_urls": [],
}
def test_extension_manifest_cap_warning_logs_once_across_many_entries(tmp_path, monkeypatch, caplog):
root = tmp_path / "extensions"
root.mkdir()
entries = [
{
"id": f"ext{i}",
"scripts": [f"script{i}.js"],
"stylesheets": [f"style{i}.css"],
}
for i in range(40)
]
(root / "manifest.json").write_text(
__import__("json").dumps({"extensions": entries}), encoding="utf-8"
)
monkeypatch.setenv("HERMES_WEBUI_EXTENSION_DIR", str(root))
monkeypatch.setenv("HERMES_WEBUI_EXTENSION_MANIFEST", "manifest.json")
from api.extensions import _MAX_URL_LIST, _warned_urls, get_extension_config
_warned_urls.clear()
caplog.set_level(logging.WARNING, logger="api.extensions")
config = get_extension_config()
assert len(config["script_urls"]) == _MAX_URL_LIST
assert len(config["stylesheet_urls"]) == _MAX_URL_LIST
truncation_records = [
record for record in caplog.records
if "truncated at" in record.message and "Extension URL list" in record.message
]
assert len(truncation_records) == 2
assert any("manifest:scripts" in record.message for record in truncation_records)
assert any("manifest:stylesheets" in record.message for record in truncation_records)
def test_extension_manifest_ignores_non_list_asset_fields(tmp_path, monkeypatch):
root = tmp_path / "extensions"
root.mkdir()
(root / "manifest.json").write_text(
"""
{
"scripts": "app.js",
"stylesheets": {"href": "app.css"},
"extensions": {
"bad": {"scripts": ["bad.js"]}
}
}
""",
encoding="utf-8",
)
monkeypatch.setenv("HERMES_WEBUI_EXTENSION_DIR", str(root))
monkeypatch.setenv("HERMES_WEBUI_EXTENSION_MANIFEST", "manifest.json")
monkeypatch.setenv("HERMES_WEBUI_EXTENSION_SCRIPT_URLS", "/extensions/env.js")
monkeypatch.delenv("HERMES_WEBUI_EXTENSION_STYLESHEET_URLS", raising=False)
from api.extensions import get_extension_config
assert get_extension_config() == {
"enabled": True,
"script_urls": ["/extensions/env.js"],
"stylesheet_urls": [],
}
def test_extension_env_only_duplicate_urls_preserve_legacy_behavior(tmp_path, monkeypatch):
root = tmp_path / "extensions"
root.mkdir()
monkeypatch.setenv("HERMES_WEBUI_EXTENSION_DIR", str(root))
monkeypatch.delenv("HERMES_WEBUI_EXTENSION_MANIFEST", raising=False)
monkeypatch.setenv(
"HERMES_WEBUI_EXTENSION_SCRIPT_URLS",
"/extensions/app.js, /extensions/app.js",
)
monkeypatch.delenv("HERMES_WEBUI_EXTENSION_STYLESHEET_URLS", raising=False)
from api.extensions import get_extension_config
assert get_extension_config() == {
"enabled": True,
"script_urls": ["/extensions/app.js", "/extensions/app.js"],
"stylesheet_urls": [],
}
def test_extension_manifest_accepts_top_level_extension_array(tmp_path, monkeypatch):
root = tmp_path / "extensions"
root.mkdir()
(root / "extensions.json").write_text(
"""
[
{"id": "a", "scripts": ["a/a.js"], "stylesheets": ["a/a.css"]},
{"id": "b", "scripts": ["b/b.js"]}
]
""",
encoding="utf-8",
)
monkeypatch.setenv("HERMES_WEBUI_EXTENSION_DIR", str(root))
monkeypatch.setenv("HERMES_WEBUI_EXTENSION_MANIFEST", "extensions.json")
monkeypatch.delenv("HERMES_WEBUI_EXTENSION_SCRIPT_URLS", raising=False)
monkeypatch.delenv("HERMES_WEBUI_EXTENSION_STYLESHEET_URLS", raising=False)
from api.extensions import get_extension_config
assert get_extension_config() == {
"enabled": True,
"script_urls": ["/extensions/a/a.js", "/extensions/b/b.js"],
"stylesheet_urls": ["/extensions/a/a.css"],
}
def test_extension_static_serving_is_sandboxed(tmp_path, monkeypatch):
root = tmp_path / "extensions"
root.mkdir()