"""Tests for sanitized WebUI extension diagnostics.""" from types import SimpleNamespace import json import pytest class FakeHandler: def __init__(self): self.status = None self.headers = {} self.sent_headers = [] self.body = bytearray() self.wfile = self def send_response(self, status): self.status = status def send_header(self, name, value): self.sent_headers.append((name, value)) def end_headers(self): pass def write(self, data): self.body.extend(data) def header(self, name): for key, value in self.sent_headers: if key.lower() == name.lower(): return value return None @pytest.fixture(autouse=True) def _clear_extension_env(monkeypatch): from api import auth as auth_mod for name in ( "HERMES_WEBUI_EXTENSION_DIR", "HERMES_WEBUI_EXTENSION_MANIFEST", "HERMES_WEBUI_EXTENSION_SCRIPT_URLS", "HERMES_WEBUI_EXTENSION_STYLESHEET_URLS", "HERMES_WEBUI_PASSWORD", ): monkeypatch.delenv(name, raising=False) auth_mod._invalidate_password_hash_cache() yield auth_mod._invalidate_password_hash_cache() def _use_extension_state_dir(monkeypatch, tmp_path): state_dir = tmp_path / "webui-state" state_dir.mkdir() monkeypatch.setenv("HERMES_WEBUI_STATE_DIR", str(state_dir)) import api.extensions as extensions monkeypatch.setattr(extensions, "_extension_state_dir", lambda: state_dir) return state_dir def _status_counts_empty(): return { "script_urls": 0, "stylesheet_urls": 0, "sidecars": 0, "manifest_extensions": 0, "user_disabled": 0, } def test_extension_status_disabled_by_default(tmp_path, monkeypatch): # With no HERMES_WEBUI_EXTENSION_DIR and no managed default dir yet, the # gallery is "configured" (a managed install target is always available) # but not yet valid/enabled until the first install creates the directory. _use_extension_state_dir(monkeypatch, tmp_path) from api.extensions import get_extension_status assert get_extension_status() == { "enabled": False, "extension_dir_configured": True, "extension_dir_valid": False, "script_urls": [], "stylesheet_urls": [], "sidecars": [], "counts": _status_counts_empty(), "manifest": { "configured": False, "loaded": False, "status": "not_configured", "entry_count": 0, "script_count": 0, "stylesheet_count": 0, "sidecar_count": 0, }, "extensions": [], "warnings": [], } def test_extension_status_reports_invalid_extension_dir_without_path(tmp_path, monkeypatch): missing = tmp_path / "missing-extension-dir" monkeypatch.setenv("HERMES_WEBUI_EXTENSION_DIR", str(missing)) monkeypatch.setenv("HERMES_WEBUI_EXTENSION_MANIFEST", "extensions.json") from api.extensions import get_extension_status status = get_extension_status() assert status["enabled"] is False assert status["extension_dir_configured"] is True assert status["extension_dir_valid"] is False assert status["manifest"]["status"] == "extension_disabled" assert status["warnings"] == [ {"code": "extension_dir_unavailable", "source": "extension_dir"} ] assert str(missing) not in repr(status) def test_extension_status_reports_loaded_manifest_counts_and_urls(tmp_path, monkeypatch): root = tmp_path / "extensions" root.mkdir() (root / "extensions.json").write_text( """ { "scripts": ["runtime.js"], "stylesheets": ["base.css"], "extensions": [ {"id": "templates", "scripts": ["templates/app.js"], "stylesheets": ["templates/app.css"]}, {"id": "off", "enabled": false, "scripts": ["off.js"]} ] } """, encoding="utf-8", ) monkeypatch.setenv("HERMES_WEBUI_EXTENSION_DIR", str(root)) monkeypatch.setenv("HERMES_WEBUI_EXTENSION_MANIFEST", "extensions.json") monkeypatch.setenv("HERMES_WEBUI_EXTENSION_SCRIPT_URLS", "/extensions/env.js") from api.extensions import get_extension_status status = get_extension_status() assert status["enabled"] is True assert status["extension_dir_configured"] is True assert status["extension_dir_valid"] is True assert status["script_urls"] == [ "/extensions/runtime.js", "/extensions/templates/app.js", "/extensions/env.js", ] assert status["stylesheet_urls"] == [ "/extensions/base.css", "/extensions/templates/app.css", ] assert status["sidecars"] == [] assert status["counts"] == {"script_urls": 3, "stylesheet_urls": 2, "sidecars": 0, "manifest_extensions": 2, "user_disabled": 0} assert status["extensions"] == [ { "id": "templates", "name": "templates", "manifest_enabled": True, "user_enabled": True, "user_disabled": False, "effective_enabled": True, "can_toggle": True, "reload_required": True, "storage_owned": False, "settings_schema": [], "status": "enabled", }, { "id": "off", "name": "off", "manifest_enabled": False, "user_enabled": False, "user_disabled": False, "effective_enabled": False, "can_toggle": False, "reload_required": True, "storage_owned": False, "settings_schema": [], "status": "manifest_disabled", }, ] assert status["manifest"] == { "configured": True, "loaded": True, "status": "loaded", "entry_count": 2, "script_count": 2, "stylesheet_count": 2, "sidecar_count": 0, } assert status["warnings"] == [] def test_extension_status_sanitizes_settings_schema_only_for_owned_storage(tmp_path, monkeypatch): root = tmp_path / "extensions" root.mkdir() (root / "extensions.json").write_text( json.dumps( { "extensions": [ { "id": "settings-ok", "permissions": {"storage": {"owned": True}}, "settings_schema": [ {"key": "flag", "type": "boolean", "label": "Flag", "default": True}, {"key": "name", "type": "string", "default": "Ada"}, {"key": "ratio", "type": "number", "default": 1.5}, {"key": "count", "type": "integer", "default": 2}, { "key": "mode", "type": "enum", "options": [ {"value": "compact", "label": "Compact"}, "full", ], "default": "compact", }, {"key": "secret", "type": "string", "sensitive": True, "default": "x"}, {"key": "badtype", "type": "object", "default": {}}, {"key": "bad_enum", "type": "enum", "options": [{"label": "No value"}]}, {"key": "flag", "type": "boolean", "default": False}, {"key": "bad_default", "type": "integer", "default": 1.2}, {"key": "null_flag", "type": "boolean", "default": None}, {"key": "null_name", "type": "string", "default": None}, ], }, { "id": "settings-denied", "permissions": {"storage": {"owned": False}}, "settings_schema": [{"key": "flag", "type": "boolean", "default": True}], }, ] } ), encoding="utf-8", ) monkeypatch.setenv("HERMES_WEBUI_EXTENSION_DIR", str(root)) monkeypatch.setenv("HERMES_WEBUI_EXTENSION_MANIFEST", "extensions.json") from api.extensions import get_extension_config, get_extension_status status = get_extension_status() by_id = {entry["id"]: entry for entry in status["extensions"]} assert by_id["settings-ok"]["storage_owned"] is True assert [field["key"] for field in by_id["settings-ok"]["settings_schema"]] == ["flag", "name", "ratio", "count", "mode"] assert by_id["settings-ok"]["settings_schema"][0]["default"] is True assert by_id["settings-ok"]["settings_schema"][4]["options"] == [ {"value": "compact", "label": "Compact"}, {"value": "full", "label": "full"}, ] assert by_id["settings-denied"]["storage_owned"] is False assert by_id["settings-denied"]["settings_schema"] == [] config = get_extension_config() config_by_id = {entry["id"]: entry for entry in config["extensions"]} assert config_by_id["settings-ok"]["settings_schema"] == by_id["settings-ok"]["settings_schema"] assert config_by_id["settings-denied"]["settings_schema"] == [] def test_extension_status_ignores_non_dict_manifest_extensions_in_entry_count( tmp_path, monkeypatch ): root = tmp_path / "extensions" root.mkdir() (root / "extensions.json").write_text( """ { "extensions": [ null, "not-an-extension", {"id": "templates", "scripts": ["templates/app.js"]}, {"id": "off", "enabled": false, "scripts": ["off.js"]} ] } """, encoding="utf-8", ) monkeypatch.setenv("HERMES_WEBUI_EXTENSION_DIR", str(root)) monkeypatch.setenv("HERMES_WEBUI_EXTENSION_MANIFEST", "extensions.json") from api.extensions import get_extension_status status = get_extension_status() assert status["script_urls"] == ["/extensions/templates/app.js"] assert status["manifest"]["entry_count"] == 2 assert status["manifest"]["script_count"] == 1 assert status["manifest"]["sidecar_count"] == 0 assert status["sidecars"] == [] assert status["warnings"] == [] def test_extension_status_reports_missing_manifest_safely(tmp_path, monkeypatch): root = tmp_path / "extensions" root.mkdir() monkeypatch.setenv("HERMES_WEBUI_EXTENSION_DIR", str(root)) monkeypatch.setenv("HERMES_WEBUI_EXTENSION_MANIFEST", "missing.json") from api.extensions import get_extension_status status = get_extension_status() assert status["manifest"]["status"] == "missing" assert status["manifest"]["loaded"] is False assert status["warnings"] == [{"code": "manifest_missing", "source": "manifest"}] assert str(root) not in repr(status) assert "missing.json" not in repr(status) def test_extension_status_reports_malformed_manifest_safely(tmp_path, monkeypatch): root = tmp_path / "extensions" root.mkdir() (root / "bad.json").write_text("{not json", encoding="utf-8") monkeypatch.setenv("HERMES_WEBUI_EXTENSION_DIR", str(root)) monkeypatch.setenv("HERMES_WEBUI_EXTENSION_MANIFEST", "bad.json") from api.extensions import get_extension_status status = get_extension_status() assert status["manifest"]["status"] == "malformed" assert status["warnings"] == [{"code": "manifest_malformed", "source": "manifest"}] assert "bad.json" not in repr(status) def test_extension_status_reports_unreadable_manifest_safely(tmp_path, monkeypatch): root = tmp_path / "extensions" root.mkdir() (root / "bad-utf8.json").write_bytes(b"\xff\xfe") monkeypatch.setenv("HERMES_WEBUI_EXTENSION_DIR", str(root)) monkeypatch.setenv("HERMES_WEBUI_EXTENSION_MANIFEST", "bad-utf8.json") from api.extensions import get_extension_status status = get_extension_status() assert status["manifest"]["status"] == "unreadable" assert status["warnings"] == [{"code": "manifest_unreadable", "source": "manifest"}] assert "bad-utf8.json" not in repr(status) def test_extension_status_reports_manifest_disabled_when_dir_unconfigured(tmp_path, monkeypatch): # No managed default dir exists yet, so even though the gallery target is # "configured", a manifest env points at a directory that isn't valid yet. _use_extension_state_dir(monkeypatch, tmp_path) monkeypatch.setenv("HERMES_WEBUI_EXTENSION_MANIFEST", "extensions.json") from api.extensions import get_extension_status status = get_extension_status() assert status["enabled"] is False assert status["extension_dir_configured"] is True assert status["extension_dir_valid"] is False assert status["manifest"]["status"] == "extension_disabled" assert status["manifest"]["configured"] is True assert status["warnings"] == [] assert "extensions.json" not in repr(status) def test_extension_status_reports_oversized_manifest_safely(tmp_path, monkeypatch): root = tmp_path / "extensions" root.mkdir() (root / "huge.json").write_text(" " * (64 * 1024 + 1), encoding="utf-8") monkeypatch.setenv("HERMES_WEBUI_EXTENSION_DIR", str(root)) monkeypatch.setenv("HERMES_WEBUI_EXTENSION_MANIFEST", "huge.json") from api.extensions import get_extension_status status = get_extension_status() assert status["manifest"]["status"] == "oversized" assert status["warnings"] == [{"code": "manifest_oversized", "source": "manifest"}] assert "huge.json" not in repr(status) def test_extension_status_reports_invalid_manifest_path_safely(tmp_path, monkeypatch): root = tmp_path / "extensions" root.mkdir() monkeypatch.setenv("HERMES_WEBUI_EXTENSION_DIR", str(root)) monkeypatch.setenv("HERMES_WEBUI_EXTENSION_MANIFEST", "../outside.json") from api.extensions import get_extension_status status = get_extension_status() assert status["manifest"]["status"] == "invalid_path" assert status["warnings"] == [ {"code": "manifest_invalid_path", "source": "manifest"} ] assert "outside.json" not in repr(status) assert str(root) not in repr(status) def test_extension_status_reports_recursion_error_safely(tmp_path, monkeypatch): root = tmp_path / "extensions" root.mkdir() (root / "deep.json").write_text("[]", encoding="utf-8") monkeypatch.setenv("HERMES_WEBUI_EXTENSION_DIR", str(root)) monkeypatch.setenv("HERMES_WEBUI_EXTENSION_MANIFEST", "deep.json") import api.extensions as extensions def raise_recursion_error(_manifest_file): raise RecursionError("manifest nesting exceeded") monkeypatch.setattr(extensions, "_read_manifest_text", raise_recursion_error) status = extensions.get_extension_status() assert status["manifest"]["status"] == "too_deeply_nested" assert status["warnings"] == [ {"code": "manifest_too_deeply_nested", "source": "manifest"} ] assert "deep.json" not in repr(status) def test_extension_status_reports_rejected_assets_without_rejected_values(tmp_path, monkeypatch): root = tmp_path / "extensions" root.mkdir() (root / "extensions.json").write_text( """ { "scripts": ["safe.js", "https://evil.example/app.js", "../escape.js"], "stylesheets": ["safe.css", "nested/../escape.css"] } """, encoding="utf-8", ) monkeypatch.setenv("HERMES_WEBUI_EXTENSION_DIR", str(root)) monkeypatch.setenv("HERMES_WEBUI_EXTENSION_MANIFEST", "extensions.json") from api.extensions import get_extension_status status = get_extension_status() assert status["script_urls"] == ["/extensions/safe.js"] assert status["stylesheet_urls"] == ["/extensions/safe.css"] assert {tuple(sorted(item.items())) for item in status["warnings"]} == { tuple(sorted({"code": "asset_url_rejected", "source": "manifest:scripts"}.items())), tuple(sorted({"code": "asset_url_rejected", "source": "manifest:stylesheets"}.items())), } rendered = repr(status) assert "evil.example" not in rendered assert "escape.js" not in rendered assert "escape.css" not in rendered def test_extension_status_reports_rejected_env_assets_without_rejected_values(tmp_path, monkeypatch): root = tmp_path / "extensions" root.mkdir() monkeypatch.setenv("HERMES_WEBUI_EXTENSION_DIR", str(root)) monkeypatch.setenv( "HERMES_WEBUI_EXTENSION_SCRIPT_URLS", "/extensions/safe.js, https://evil.example/env.js", ) from api.extensions import get_extension_status status = get_extension_status() assert status["script_urls"] == ["/extensions/safe.js"] assert status["warnings"] == [ {"code": "asset_url_rejected", "source": "HERMES_WEBUI_EXTENSION_SCRIPT_URLS"} ] rendered = repr(status) assert "evil.example" not in rendered assert "env.js" not in rendered def test_extension_status_reports_sanitized_loopback_sidecars(tmp_path, monkeypatch): root = tmp_path / "extensions" root.mkdir() (root / "extensions.json").write_text( """ { "extensions": [ { "id": "desktop-companion", "name": "Desktop Companion", "scripts": ["companion-adapter.js"], "stylesheets": ["companion-adapter.css"], "sidecar": { "type": "loopback", "origin": "http://127.0.0.1:17787", "health_path": "/health" } }, { "id": "implicit-health", "sidecar": { "type": "loopback", "origin": "http://localhost:17788" } }, { "id": "ipv6-loopback", "sidecar": { "type": "loopback", "origin": "http://[::1]:17789", "health_path": "/ready" } } ] } """, encoding="utf-8", ) monkeypatch.setenv("HERMES_WEBUI_EXTENSION_DIR", str(root)) monkeypatch.setenv("HERMES_WEBUI_EXTENSION_MANIFEST", "extensions.json") from api.extensions import get_extension_status status = get_extension_status() assert status["script_urls"] == ["/extensions/companion-adapter.js"] assert status["stylesheet_urls"] == ["/extensions/companion-adapter.css"] assert status["sidecars"] == [ { "id": "desktop-companion", "name": "Desktop Companion", "type": "loopback", "origin": "http://127.0.0.1:17787", "health_path": "/health", "health_url": "http://127.0.0.1:17787/health", "proxy": { "available": True, "consented": False, "consent_required": True, "path": "/api/extensions/desktop-companion/sidecar/", "origin_changed": False, }, }, { "id": "implicit-health", "name": "", "type": "loopback", "origin": "http://localhost:17788", "health_path": "/health", "health_url": "http://localhost:17788/health", "proxy": { "available": True, "consented": False, "consent_required": True, "path": "/api/extensions/implicit-health/sidecar/", "origin_changed": False, }, }, { "id": "ipv6-loopback", "name": "", "type": "loopback", "origin": "http://[::1]:17789", "health_path": "/ready", "health_url": "http://[::1]:17789/ready", "proxy": { "available": True, "consented": False, "consent_required": True, "path": "/api/extensions/ipv6-loopback/sidecar/", "origin_changed": False, }, }, ] assert status["counts"]["sidecars"] == 3 assert status["manifest"]["sidecar_count"] == 3 assert status["warnings"] == [] def test_extension_status_reports_sidecar_proxy_consent_and_origin_change(tmp_path, monkeypatch): state_dir = _use_extension_state_dir(monkeypatch, tmp_path) root = tmp_path / "extensions" root.mkdir() manifest_file = root / "extensions.json" manifest_file.write_text( json.dumps( { "extensions": [ { "id": "desktop-companion", "sidecar": { "type": "loopback", "origin": "http://127.0.0.1:17787", }, } ] } ), encoding="utf-8", ) (state_dir / "extension-overrides.json").write_text( json.dumps( { "version": 1, "disabled_extensions": [], "sidecar_proxy_consents": { "desktop-companion": "http://127.0.0.1:17787", }, } ), encoding="utf-8", ) monkeypatch.setenv("HERMES_WEBUI_EXTENSION_DIR", str(root)) monkeypatch.setenv("HERMES_WEBUI_EXTENSION_MANIFEST", "extensions.json") from api.extensions import get_extension_status current = get_extension_status() assert current["sidecars"][0]["proxy"] == { "available": True, "consented": True, "consent_required": False, "path": "/api/extensions/desktop-companion/sidecar/", "origin_changed": False, } manifest_file.write_text( json.dumps( { "extensions": [ { "id": "desktop-companion", "sidecar": { "type": "loopback", "origin": "http://127.0.0.1:17788", }, } ] } ), encoding="utf-8", ) changed = get_extension_status() assert changed["sidecars"][0]["proxy"] == { "available": True, "consented": False, "consent_required": True, "path": "/api/extensions/desktop-companion/sidecar/", "origin_changed": True, } def test_extension_status_skips_disabled_sidecar_entries(tmp_path, monkeypatch): root = tmp_path / "extensions" root.mkdir() (root / "extensions.json").write_text( """ { "extensions": [ { "id": "off", "enabled": false, "sidecar": {"type": "loopback", "origin": "http://127.0.0.1:17787"} } ] } """, encoding="utf-8", ) monkeypatch.setenv("HERMES_WEBUI_EXTENSION_DIR", str(root)) monkeypatch.setenv("HERMES_WEBUI_EXTENSION_MANIFEST", "extensions.json") from api.extensions import get_extension_status status = get_extension_status() assert status["sidecars"] == [] # The top-level manifest object is still inspected; the disabled extension # entry is skipped and must not contribute a sidecar. assert status["manifest"]["entry_count"] == 1 assert status["manifest"]["sidecar_count"] == 0 assert status["warnings"] == [] def test_extension_status_rejects_non_loopback_sidecars_without_raw_value_leak(tmp_path, monkeypatch): root = tmp_path / "extensions" root.mkdir() (root / "extensions.json").write_text( """ { "extensions": [ { "id": "bad-origin", "sidecar": { "type": "loopback", "origin": "http://10.0.0.5:17787", "health_path": "/health" } } ] } """, encoding="utf-8", ) monkeypatch.setenv("HERMES_WEBUI_EXTENSION_DIR", str(root)) monkeypatch.setenv("HERMES_WEBUI_EXTENSION_MANIFEST", "extensions.json") from api.extensions import get_extension_status status = get_extension_status() assert status["sidecars"] == [] assert status["manifest"]["sidecar_count"] == 0 assert status["warnings"] == [ {"code": "sidecar_origin_rejected", "source": "manifest:sidecars"} ] rendered = repr(status) assert "10.0.0.5" not in rendered assert "17787" not in rendered def test_extension_status_rejects_invalid_sidecar_health_path_without_leak(tmp_path, monkeypatch): root = tmp_path / "extensions" root.mkdir() (root / "extensions.json").write_text( """ { "extensions": [ { "id": "bad-health", "sidecar": { "type": "loopback", "origin": "http://127.0.0.1:17787", "health_path": "/../secret-health" } } ] } """, encoding="utf-8", ) monkeypatch.setenv("HERMES_WEBUI_EXTENSION_DIR", str(root)) monkeypatch.setenv("HERMES_WEBUI_EXTENSION_MANIFEST", "extensions.json") from api.extensions import get_extension_status status = get_extension_status() assert status["sidecars"] == [] assert status["manifest"]["sidecar_count"] == 0 assert status["warnings"] == [ {"code": "sidecar_health_path_rejected", "source": "manifest:sidecars"} ] assert "secret-health" not in repr(status) def test_extension_status_rejects_decoded_whitespace_sidecar_health_path(tmp_path, monkeypatch): root = tmp_path / "extensions" root.mkdir() (root / "extensions.json").write_text( """ { "extensions": [ { "id": "bad-health-space", "sidecar": { "type": "loopback", "origin": "http://127.0.0.1:17787", "health_path": "/health%20check" } } ] } """, encoding="utf-8", ) monkeypatch.setenv("HERMES_WEBUI_EXTENSION_DIR", str(root)) monkeypatch.setenv("HERMES_WEBUI_EXTENSION_MANIFEST", "extensions.json") from api.extensions import get_extension_status status = get_extension_status() assert status["sidecars"] == [] assert status["warnings"] == [ {"code": "sidecar_health_path_rejected", "source": "manifest:sidecars"} ] rendered = repr(status) assert "health check" not in rendered assert "health%20check" not in rendered def test_extension_status_rejects_encoded_query_or_fragment_sidecar_health_path(tmp_path, monkeypatch): """#4612 (Codex gate): the raw query/fragment ban runs BEFORE percent-decoding, so an encoded delimiter ("/health%3Ftoken=abc" -> "?token=abc", "/health%23frag" -> "#frag") must be re-rejected on the decoded path — otherwise a query/fragment survives into the browser-probed health URL despite the documented ban.""" for bad_path, leaked in ( ("/health%3Ftoken=abc", "token=abc"), ("/health%23frag", "frag"), ): root = tmp_path / f"extensions_{leaked}" root.mkdir() (root / "extensions.json").write_text( """ { "extensions": [ { "id": "bad-health-delim", "sidecar": { "type": "loopback", "origin": "http://127.0.0.1:17787", "health_path": "%s" } } ] } """ % bad_path, encoding="utf-8", ) monkeypatch.setenv("HERMES_WEBUI_EXTENSION_DIR", str(root)) monkeypatch.setenv("HERMES_WEBUI_EXTENSION_MANIFEST", "extensions.json") import importlib import api.extensions as _ext importlib.reload(_ext) status = _ext.get_extension_status() assert status["sidecars"] == [], f"{bad_path} must be rejected, not probed" assert {"code": "sidecar_health_path_rejected", "source": "manifest:sidecars"} in status["warnings"] rendered = repr(status) assert leaked not in rendered, f"rejected {bad_path} must not leak {leaked!r} into status" def test_extension_status_rejects_unsupported_sidecar_type_without_origin_probe(tmp_path, monkeypatch): root = tmp_path / "extensions" root.mkdir() (root / "extensions.json").write_text( """ { "extensions": [ { "id": "unsupported", "sidecar": {"type": "unix-socket", "origin": "http://127.0.0.1:17787"} } ] } """, encoding="utf-8", ) monkeypatch.setenv("HERMES_WEBUI_EXTENSION_DIR", str(root)) monkeypatch.setenv("HERMES_WEBUI_EXTENSION_MANIFEST", "extensions.json") from api.extensions import get_extension_status status = get_extension_status() assert status["sidecars"] == [] assert status["warnings"] == [ {"code": "sidecar_type_unsupported", "source": "manifest:sidecars"} ] def test_extension_status_truncates_many_sidecars_with_sanitized_warning(tmp_path, monkeypatch): import json root = tmp_path / "extensions" root.mkdir() entries = [ { "id": f"sidecar-{index}", "sidecar": { "type": "loopback", "origin": f"http://127.0.0.1:{18000 + index}", }, } for index in range(40) ] (root / "extensions.json").write_text( json.dumps({"extensions": entries}), encoding="utf-8", ) monkeypatch.setenv("HERMES_WEBUI_EXTENSION_DIR", str(root)) monkeypatch.setenv("HERMES_WEBUI_EXTENSION_MANIFEST", "extensions.json") from api.extensions import get_extension_status status = get_extension_status() assert len(status["sidecars"]) == 32 assert status["counts"]["sidecars"] == 32 assert status["manifest"]["sidecar_count"] == 32 assert status["warnings"] == [ {"code": "sidecar_list_truncated", "source": "manifest:sidecars"} ] def test_extension_user_disabled_override_suppresses_manifest_assets_and_sidecars(tmp_path, monkeypatch): state_dir = _use_extension_state_dir(monkeypatch, tmp_path) root = tmp_path / "extensions" root.mkdir() (root / "extensions.json").write_text( """ { "extensions": [ { "id": "desktop-companion", "name": "Desktop Companion", "scripts": ["companion.js"], "stylesheets": ["companion.css"], "sidecar": {"type": "loopback", "origin": "http://127.0.0.1:17787"} }, {"id": "templates", "scripts": ["templates.js"]} ] } """, encoding="utf-8", ) (state_dir / "extension-overrides.json").write_text( json.dumps({"version": 1, "disabled_extensions": ["desktop-companion"]}), encoding="utf-8", ) monkeypatch.setenv("HERMES_WEBUI_EXTENSION_DIR", str(root)) monkeypatch.setenv("HERMES_WEBUI_EXTENSION_MANIFEST", "extensions.json") from api.extensions import get_extension_config, get_extension_status status = get_extension_status() assert status["script_urls"] == ["/extensions/templates.js"] assert status["stylesheet_urls"] == [] assert status["sidecars"] == [] assert status["manifest"]["script_count"] == 1 assert status["manifest"]["stylesheet_count"] == 0 assert status["manifest"]["sidecar_count"] == 0 assert status["counts"]["manifest_extensions"] == 2 assert status["counts"]["user_disabled"] == 1 companion = next(item for item in status["extensions"] if item["id"] == "desktop-companion") assert companion["user_disabled"] is True assert companion["user_enabled"] is False assert companion["effective_enabled"] is False assert companion["can_toggle"] is True assert companion["status"] == "user_disabled" assert "17787" not in repr(status) config = get_extension_config() assert config["script_urls"] == ["/extensions/templates.js"] assert config["stylesheet_urls"] == [] def test_extension_state_invalid_file_fails_safe_without_path_leak(tmp_path, monkeypatch): state_dir = _use_extension_state_dir(monkeypatch, tmp_path) root = tmp_path / "extensions" root.mkdir() (root / "extensions.json").write_text( '{"extensions":[{"id":"templates","scripts":["templates.js"]}]}', encoding="utf-8", ) state_file = state_dir / "extension-overrides.json" state_file.write_text("{not json", encoding="utf-8") monkeypatch.setenv("HERMES_WEBUI_EXTENSION_DIR", str(root)) monkeypatch.setenv("HERMES_WEBUI_EXTENSION_MANIFEST", "extensions.json") from api.extensions import get_extension_status status = get_extension_status() assert status["script_urls"] == ["/extensions/templates.js"] assert status["warnings"] == [ {"code": "extension_state_unreadable", "source": "extension_state"} ] rendered = repr(status) assert str(state_file) not in rendered assert str(state_dir) not in rendered def test_set_extension_user_enabled_persists_override_and_reenables(tmp_path, monkeypatch): state_dir = _use_extension_state_dir(monkeypatch, tmp_path) root = tmp_path / "extensions" root.mkdir() (root / "extensions.json").write_text( '{"extensions":[{"id":"templates","scripts":["templates.js"]}]}', encoding="utf-8", ) monkeypatch.setenv("HERMES_WEBUI_EXTENSION_DIR", str(root)) monkeypatch.setenv("HERMES_WEBUI_EXTENSION_MANIFEST", "extensions.json") from api.extensions import set_extension_user_enabled disabled = set_extension_user_enabled("templates", False) assert disabled["script_urls"] == [] assert disabled["counts"]["user_disabled"] == 1 assert json.loads((state_dir / "extension-overrides.json").read_text(encoding="utf-8")) == { "version": 1, "disabled_extensions": ["templates"], "sidecar_proxy_consents": {}, } enabled = set_extension_user_enabled("templates", True) assert enabled["script_urls"] == ["/extensions/templates.js"] assert enabled["counts"]["user_disabled"] == 0 assert json.loads((state_dir / "extension-overrides.json").read_text(encoding="utf-8")) == { "version": 1, "disabled_extensions": [], "sidecar_proxy_consents": {}, } def test_set_extension_user_enabled_preserves_sidecar_proxy_consent(tmp_path, monkeypatch): state_dir = _use_extension_state_dir(monkeypatch, tmp_path) root = tmp_path / "extensions" root.mkdir() (root / "extensions.json").write_text( json.dumps( { "extensions": [ { "id": "templates", "scripts": ["templates.js"], "sidecar": { "type": "loopback", "origin": "http://127.0.0.1:17787", }, } ] } ), encoding="utf-8", ) (state_dir / "extension-overrides.json").write_text( json.dumps( { "version": 1, "disabled_extensions": [], "sidecar_proxy_consents": { "templates": "http://127.0.0.1:17787", }, } ), encoding="utf-8", ) monkeypatch.setenv("HERMES_WEBUI_EXTENSION_DIR", str(root)) monkeypatch.setenv("HERMES_WEBUI_EXTENSION_MANIFEST", "extensions.json") from api.extensions import set_extension_user_enabled disabled = set_extension_user_enabled("templates", False) assert disabled["sidecars"] == [] assert json.loads((state_dir / "extension-overrides.json").read_text(encoding="utf-8")) == { "version": 1, "disabled_extensions": ["templates"], "sidecar_proxy_consents": { "templates": "http://127.0.0.1:17787", }, } enabled = set_extension_user_enabled("templates", True) assert enabled["sidecars"][0]["proxy"]["consented"] is True assert json.loads((state_dir / "extension-overrides.json").read_text(encoding="utf-8")) == { "version": 1, "disabled_extensions": [], "sidecar_proxy_consents": { "templates": "http://127.0.0.1:17787", }, } def test_set_extension_user_enabled_rejects_unknown_manifest_disabled_and_bad_shapes(tmp_path, monkeypatch): _use_extension_state_dir(monkeypatch, tmp_path) root = tmp_path / "extensions" root.mkdir() (root / "extensions.json").write_text( '{"extensions":[{"id":"templates"},{"id":"off","enabled":false}]}', encoding="utf-8", ) monkeypatch.setenv("HERMES_WEBUI_EXTENSION_DIR", str(root)) monkeypatch.setenv("HERMES_WEBUI_EXTENSION_MANIFEST", "extensions.json") from api.extensions import ExtensionToggleError, set_extension_user_enabled with pytest.raises(ExtensionToggleError) as bad_id: set_extension_user_enabled("../templates", False) assert bad_id.value.status == 400 with pytest.raises(ExtensionToggleError) as bad_enabled: set_extension_user_enabled("templates", "false") assert bad_enabled.value.status == 400 with pytest.raises(ExtensionToggleError) as unknown: set_extension_user_enabled("missing", False) assert unknown.value.status == 404 with pytest.raises(ExtensionToggleError) as manifest_disabled_enable: set_extension_user_enabled("off", True) assert manifest_disabled_enable.value.status == 409 with pytest.raises(ExtensionToggleError) as manifest_disabled_disable: set_extension_user_enabled("off", False) assert manifest_disabled_disable.value.status == 409 def test_extension_state_fails_safe_for_invalid_shapes_without_path_leak(tmp_path, monkeypatch): state_dir = _use_extension_state_dir(monkeypatch, tmp_path) root = tmp_path / "extensions" root.mkdir() (root / "extensions.json").write_text( '{"extensions":[{"id":"templates","scripts":["templates.js"]}]}', encoding="utf-8", ) state_file = state_dir / "extension-overrides.json" monkeypatch.setenv("HERMES_WEBUI_EXTENSION_DIR", str(root)) monkeypatch.setenv("HERMES_WEBUI_EXTENSION_MANIFEST", "extensions.json") from api.extensions import get_extension_status cases = [ (b"\xff\xfe", "extension_state_unreadable"), (json.dumps(["templates"]).encode("utf-8"), "extension_state_invalid"), (json.dumps({"disabled_extensions": "templates"}).encode("utf-8"), "extension_state_invalid"), ] for raw, warning_code in cases: state_file.write_bytes(raw) status = get_extension_status() assert status["script_urls"] == ["/extensions/templates.js"] assert {tuple(sorted(item.items())) for item in status["warnings"]} == { tuple(sorted({"code": warning_code, "source": "extension_state"}.items())) } rendered = repr(status) assert str(state_file) not in rendered assert str(state_dir) not in rendered def test_extension_state_oversized_and_truncated_are_sanitized(tmp_path, monkeypatch): state_dir = _use_extension_state_dir(monkeypatch, tmp_path) root = tmp_path / "extensions" root.mkdir() entries = [ {"id": f"ext-{index}", "scripts": [f"ext-{index}.js"] if index in (0, 511, 512) else []} for index in range(520) ] (root / "extensions.json").write_text( json.dumps({"extensions": entries}), encoding="utf-8", ) state_file = state_dir / "extension-overrides.json" monkeypatch.setenv("HERMES_WEBUI_EXTENSION_DIR", str(root)) monkeypatch.setenv("HERMES_WEBUI_EXTENSION_MANIFEST", "extensions.json") from api.extensions import get_extension_status state_file.write_text(" " * (32 * 1024 + 1), encoding="utf-8") oversized = get_extension_status() assert oversized["counts"]["user_disabled"] == 0 assert oversized["warnings"] == [ {"code": "extension_state_oversized", "source": "extension_state"} ] assert str(state_file) not in repr(oversized) state_file.write_text( json.dumps({"disabled_extensions": [f"ext-{index}" for index in range(520)]}), encoding="utf-8", ) truncated = get_extension_status() assert truncated["counts"]["user_disabled"] == 512 assert {tuple(sorted(item.items())) for item in truncated["warnings"]} == { tuple(sorted({"code": "extension_state_truncated", "source": "extension_state"}.items())) } assert "/extensions/ext-0.js" not in truncated["script_urls"] assert "/extensions/ext-511.js" not in truncated["script_urls"] assert "/extensions/ext-512.js" in truncated["script_urls"] def test_extension_state_recursion_error_fails_safe(tmp_path, monkeypatch): state_dir = _use_extension_state_dir(monkeypatch, tmp_path) state_file = state_dir / "extension-overrides.json" state_file.write_text('{"disabled_extensions":["templates"]}', encoding="utf-8") import api.extensions as extensions def raise_recursion_error(_text): raise RecursionError("state nesting exceeded") monkeypatch.setattr(extensions.json, "loads", raise_recursion_error) state = extensions._load_extension_state({"warnings": []}) assert state == {"version": 1, "disabled_extensions": [], "sidecar_proxy_consents": {}} diagnostics = {"warnings": []} extensions._load_extension_state(diagnostics) assert diagnostics["warnings"] == [ {"code": "extension_state_unreadable", "source": "extension_state"} ] def test_extension_state_invalid_entries_and_stale_ids_are_sanitized(tmp_path, monkeypatch): state_dir = _use_extension_state_dir(monkeypatch, tmp_path) root = tmp_path / "extensions" root.mkdir() (root / "extensions.json").write_text( '{"extensions":[{"id":"templates","scripts":["templates.js"]}]}', encoding="utf-8", ) (state_dir / "extension-overrides.json").write_text( json.dumps({"disabled_extensions": ["templates", "../bad", "missing"]}), encoding="utf-8", ) monkeypatch.setenv("HERMES_WEBUI_EXTENSION_DIR", str(root)) monkeypatch.setenv("HERMES_WEBUI_EXTENSION_MANIFEST", "extensions.json") from api.extensions import get_extension_status status = get_extension_status() assert status["script_urls"] == [] assert status["counts"]["user_disabled"] == 1 assert {tuple(sorted(item.items())) for item in status["warnings"]} == { tuple(sorted({"code": "extension_state_invalid_entries", "source": "extension_state"}.items())), tuple(sorted({"code": "extension_state_unknown_ids", "source": "extension_state"}.items())), } rendered = repr(status) assert "../bad" not in rendered assert "missing" not in rendered def test_set_extension_user_enabled_is_idempotent_and_preserves_stale_ids(tmp_path, monkeypatch): state_dir = _use_extension_state_dir(monkeypatch, tmp_path) root = tmp_path / "extensions" root.mkdir() (root / "extensions.json").write_text( '{"extensions":[{"id":"templates","scripts":["templates.js"]}]}', encoding="utf-8", ) (state_dir / "extension-overrides.json").write_text( json.dumps({"version": 1, "disabled_extensions": ["stale"]}), encoding="utf-8", ) monkeypatch.setenv("HERMES_WEBUI_EXTENSION_DIR", str(root)) monkeypatch.setenv("HERMES_WEBUI_EXTENSION_MANIFEST", "extensions.json") from api.extensions import set_extension_user_enabled set_extension_user_enabled("templates", False) set_extension_user_enabled("templates", False) assert json.loads((state_dir / "extension-overrides.json").read_text(encoding="utf-8")) == { "version": 1, "disabled_extensions": ["stale", "templates"], "sidecar_proxy_consents": {}, } status = set_extension_user_enabled("templates", True) assert status["script_urls"] == ["/extensions/templates.js"] assert json.loads((state_dir / "extension-overrides.json").read_text(encoding="utf-8")) == { "version": 1, "disabled_extensions": ["stale"], "sidecar_proxy_consents": {}, } assert status["warnings"] == [ {"code": "extension_state_unknown_ids", "source": "extension_state"} ] def test_set_extension_user_enabled_rejects_when_extensions_unconfigured(monkeypatch): from api.extensions import ExtensionToggleError, set_extension_user_enabled with pytest.raises(ExtensionToggleError) as exc: set_extension_user_enabled("templates", False) assert exc.value.status == 404 def test_extension_toggle_route_uses_csrf_gate(monkeypatch): monkeypatch.setenv("HERMES_WEBUI_PASSWORD", "test-password") from api import auth as auth_mod, routes auth_mod._invalidate_password_hash_cache() handler = FakeHandler() handler.headers = { "Origin": "http://example.com", "Host": "example.com", "Content-Length": "2", } result = routes.handle_post(handler, SimpleNamespace(path="/api/extensions/toggle")) assert result is None assert handler.status == 403 assert json.loads(handler.body.decode("utf-8"))["error"] == "Session expired - reload the page" assert routes._csrf_exempt_path("/api/extensions/toggle") is False def test_extension_toggle_route_requires_webui_auth(monkeypatch): monkeypatch.setenv("HERMES_WEBUI_PASSWORD", "test-password") from api import auth as auth_mod from api.auth import check_auth auth_mod._invalidate_password_hash_cache() handler = FakeHandler() assert check_auth(handler, SimpleNamespace(path="/api/extensions/toggle", query="")) is False assert handler.status == 401 assert handler.header("Location") is None assert json.loads(handler.body.decode("utf-8"))["error"] == "Authentication required" def test_extension_toggle_route_is_wired_for_enable_disable(monkeypatch, tmp_path): from api import routes captured = {} def fake_j(handler, data, status=200, headers=None): captured["data"] = data captured["status"] = status return True monkeypatch.setattr(routes, "_check_csrf", lambda handler: True) monkeypatch.setattr(routes, "read_body", lambda handler: {"id": "templates", "enabled": False}) monkeypatch.setattr(routes, "j", fake_j) monkeypatch.setattr( "api.extensions.set_extension_user_enabled", lambda extension_id, enabled: {"ok": True, "id": extension_id, "enabled": enabled}, ) handler = FakeHandler() assert routes.handle_post(handler, SimpleNamespace(path="/api/extensions/toggle")) is True assert captured == {"status": 200, "data": {"ok": True, "id": "templates", "enabled": False}} def test_extension_toggle_route_returns_sanitized_errors(monkeypatch): from api import routes from api.extensions import ExtensionToggleError captured = {} def fake_bad(handler, msg, status=400): captured["error"] = msg captured["status"] = status return True monkeypatch.setattr(routes, "_check_csrf", lambda handler: True) monkeypatch.setattr(routes, "read_body", lambda handler: {"id": "missing", "enabled": False}) monkeypatch.setattr(routes, "bad", fake_bad) def raise_toggle_error(_extension_id, _enabled): raise ExtensionToggleError("Extension not found", status=404) monkeypatch.setattr("api.extensions.set_extension_user_enabled", raise_toggle_error) handler = FakeHandler() assert routes.handle_post(handler, SimpleNamespace(path="/api/extensions/toggle")) is True assert captured == {"error": "Extension not found", "status": 404} def test_extension_status_route_is_wired(monkeypatch): from api import routes captured = {} def fake_j(handler, data, status=200, headers=None): captured["data"] = data captured["status"] = status return True monkeypatch.setattr(routes, "j", fake_j) handler = FakeHandler() assert routes.handle_get(handler, SimpleNamespace(path="/api/extensions/status")) is True assert captured["status"] == 200 assert captured["data"]["enabled"] is False def test_extension_status_route_requires_webui_auth(monkeypatch): monkeypatch.setenv("HERMES_WEBUI_PASSWORD", "test-password") from api.auth import check_auth handler = FakeHandler() assert check_auth(handler, SimpleNamespace(path="/api/extensions/status", query="")) is False assert handler.status == 401 assert handler.header("Location") is None