diff --git a/api/extensions.py b/api/extensions.py index 18cd14935..72e2327a9 100644 --- a/api/extensions.py +++ b/api/extensions.py @@ -42,6 +42,9 @@ _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/") +_SIDECAR_WARNING_SOURCE = "manifest:sidecars" +_DEFAULT_SIDECAR_HEALTH_PATH = "/health" +_LOOPBACK_SIDECAR_HOSTS = {"127.0.0.1", "localhost", "::1"} _EXTENSION_MIME = { "css": "text/css", @@ -261,6 +264,122 @@ def _manifest_asset_url(value: object) -> str: return EXTENSION_ROUTE_PREFIX + item +def _manifest_entry_text(entry: Dict[str, object], key: str) -> str: + value = entry.get(key) + if not isinstance(value, str): + return "" + return value.strip() + + +def _normalize_loopback_sidecar_origin(value: object) -> Optional[str]: + """Return a canonical loopback origin or None when unsafe. + + Only browser-addressable loopback HTTP(S) origins are accepted. The returned + value is rebuilt from parsed components so rejected raw input is never echoed + into diagnostics. + """ + if not isinstance(value, str): + return None + origin = value.strip() + if not origin or any( + ch in origin for ch in ("\x00", "\r", "\n", '"', "'", "<", ">", "\\") + ): + return None + parsed = urlsplit(origin) + if parsed.scheme not in ("http", "https") or not parsed.netloc: + return None + if parsed.username or parsed.password: + return None + if parsed.path or parsed.query or parsed.fragment: + return None + host = (parsed.hostname or "").lower() + if host not in _LOOPBACK_SIDECAR_HOSTS: + return None + try: + port = parsed.port + except ValueError: + return None + display_host = f"[{host}]" if ":" in host else host + return f"{parsed.scheme}://{display_host}{':' + str(port) if port is not None else ''}" + + +def _normalize_sidecar_health_path(value: object) -> Optional[str]: + """Return a safe sidecar health path, or None when unsafe. + + Health paths are same-origin paths relative to the validated sidecar origin. + Queries are rejected even though they are not cross-origin: health checks are + diagnostics, and query strings often accidentally carry tokens. + """ + if not isinstance(value, str): + return None + path = value.strip() + if not path or not path.startswith("/") or path.startswith("//"): + return None + if any(ch in path for ch in ("\x00", "\r", "\n", '"', "'", "<", ">", "\\")): + return None + parsed = urlsplit(path) + if parsed.scheme or parsed.netloc or parsed.query or parsed.fragment: + return None + decoded_path = _fully_unquote_path(parsed.path) + if any(ch in decoded_path for ch in ("\x00", "\r", "\n", '"', "'", "<", ">", "\\")): + return None + if any(ch.isspace() for ch in decoded_path): + return None + if not decoded_path.startswith("/") or decoded_path.startswith("//"): + return None + segments = decoded_path.split("/")[1:] + if not segments: + return None + for segment in segments: + if not segment or segment in (".", ".."): + return None + return decoded_path + + +def _sidecar_from_manifest_entry( + entry: Dict[str, object], diagnostics: Optional[Dict[str, Any]] = None +) -> Optional[Dict[str, str]]: + raw = entry.get("sidecar") + if raw is None: + return None + if not isinstance(raw, dict): + _add_diagnostic_warning(diagnostics, "sidecar_invalid", _SIDECAR_WARNING_SOURCE) + return None + if raw.get("type") != "loopback": + _add_diagnostic_warning( + diagnostics, "sidecar_type_unsupported", _SIDECAR_WARNING_SOURCE + ) + return None + origin = _normalize_loopback_sidecar_origin(raw.get("origin")) + if origin is None: + _add_diagnostic_warning( + diagnostics, "sidecar_origin_rejected", _SIDECAR_WARNING_SOURCE + ) + return None + if "health_path" in raw: + health_path = _normalize_sidecar_health_path(raw.get("health_path")) + if health_path is None: + # Missing health_path defaults to /health; an explicitly invalid path + # rejects the sidecar so the browser does not probe a declaration the + # administrator needs to fix. + _add_diagnostic_warning( + diagnostics, "sidecar_health_path_rejected", _SIDECAR_WARNING_SOURCE + ) + return None + else: + health_path = _DEFAULT_SIDECAR_HEALTH_PATH + sidecar_id = _manifest_entry_text(entry, "id") + name = _manifest_entry_text(entry, "name") + return { + "id": sidecar_id, + "name": name, + "type": "loopback", + "origin": origin, + "health_path": health_path, + "health_url": f"{origin}{health_path}", + } + + def _iter_manifest_entries(manifest: object) -> List[Tuple[str, object]]: entries: List[Tuple[str, object]] = [] extension_entries: object = [] @@ -294,7 +413,7 @@ def _read_manifest_text(manifest_file: Path) -> str: def _read_manifest_urls_with_diagnostics( root: Path, diagnostics: Optional[Dict[str, Any]] = None -) -> Tuple[List[str], List[str], Dict[str, Any]]: +) -> Tuple[List[str], List[str], List[Dict[str, str]], Dict[str, Any]]: manifest_file, path_status = _manifest_path_with_status(root) manifest_status: Dict[str, Any] = { "configured": path_status != "not_configured", @@ -303,28 +422,29 @@ def _read_manifest_urls_with_diagnostics( "entry_count": 0, "script_count": 0, "stylesheet_count": 0, + "sidecar_count": 0, } if manifest_file is None: if path_status == "invalid_path": _add_diagnostic_warning(diagnostics, "manifest_invalid_path", "manifest") - return [], [], manifest_status + return [], [], [], manifest_status try: if not manifest_file.exists() or not manifest_file.is_file(): _log.warning("Configured extension manifest was not found") manifest_status["status"] = "missing" _add_diagnostic_warning(diagnostics, "manifest_missing", "manifest") - return [], [], manifest_status + return [], [], [], manifest_status manifest = json.loads(_read_manifest_text(manifest_file)) except _ManifestTooLarge: _log.warning("Configured extension manifest exceeds %d bytes", _MAX_MANIFEST_BYTES) manifest_status["status"] = "oversized" _add_diagnostic_warning(diagnostics, "manifest_oversized", "manifest") - return [], [], manifest_status + return [], [], [], manifest_status except json.JSONDecodeError: _log.warning("Configured extension manifest is not valid JSON") manifest_status["status"] = "malformed" _add_diagnostic_warning(diagnostics, "manifest_malformed", "manifest") - return [], [], manifest_status + return [], [], [], manifest_status except RecursionError: # A <=64KB but deeply-nested manifest makes json.loads exceed the # interpreter recursion limit. Without this, the RecursionError escapes @@ -332,15 +452,16 @@ def _read_manifest_urls_with_diagnostics( _log.warning("Configured extension manifest is too deeply nested") manifest_status["status"] = "too_deeply_nested" _add_diagnostic_warning(diagnostics, "manifest_too_deeply_nested", "manifest") - return [], [], manifest_status + return [], [], [], manifest_status except (OSError, UnicodeDecodeError): _log.warning("Configured extension manifest could not be read") manifest_status["status"] = "unreadable" _add_diagnostic_warning(diagnostics, "manifest_unreadable", "manifest") - return [], [], manifest_status + return [], [], [], manifest_status scripts: List[str] = [] stylesheets: List[str] = [] + sidecars: List[Dict[str, str]] = [] entries = _iter_manifest_entries(manifest) manifest_status["entry_count"] = len(entries) scripts_full = False @@ -348,6 +469,15 @@ def _read_manifest_urls_with_diagnostics( for _source, entry in entries: if not isinstance(entry, dict): continue + if _source.startswith("manifest.extensions["): + sidecar = _sidecar_from_manifest_entry(entry, diagnostics) + if sidecar is not None: + if len(sidecars) < _MAX_URL_LIST: + sidecars.append(sidecar) + else: + _add_diagnostic_warning( + diagnostics, "sidecar_list_truncated", _SIDECAR_WARNING_SOURCE + ) script_source = "manifest:scripts" stylesheet_source = "manifest:stylesheets" if not scripts_full: @@ -370,21 +500,20 @@ def _read_manifest_urls_with_diagnostics( ): stylesheets_full = True break - if scripts_full and stylesheets_full: - break manifest_status.update( { "loaded": True, "status": "loaded", "script_count": len(scripts), "stylesheet_count": len(stylesheets), + "sidecar_count": len(sidecars), } ) - return scripts, stylesheets, manifest_status + return scripts, stylesheets, sidecars, manifest_status def _read_manifest_urls(root: Path) -> Tuple[List[str], List[str]]: - scripts, stylesheets, _ = _read_manifest_urls_with_diagnostics(root) + scripts, stylesheets, _, _ = _read_manifest_urls_with_diagnostics(root) return scripts, stylesheets @@ -417,6 +546,7 @@ def get_extension_status() -> Dict[str, Any]: "entry_count": 0, "script_count": 0, "stylesheet_count": 0, + "sidecar_count": 0, } if dir_configured and not dir_valid: _add_diagnostic_warning(diagnostics, "extension_dir_unavailable", "extension_dir") @@ -428,12 +558,13 @@ def get_extension_status() -> Dict[str, Any]: "extension_dir_valid": False, "script_urls": [], "stylesheet_urls": [], - "counts": {"script_urls": 0, "stylesheet_urls": 0}, + "sidecars": [], + "counts": {"script_urls": 0, "stylesheet_urls": 0, "sidecars": 0}, "manifest": manifest_status, "warnings": diagnostics["warnings"], } - manifest_scripts, manifest_stylesheets, manifest_status = _read_manifest_urls_with_diagnostics( + manifest_scripts, manifest_stylesheets, sidecars, manifest_status = _read_manifest_urls_with_diagnostics( root, diagnostics ) script_urls = _read_url_list( @@ -452,9 +583,11 @@ def get_extension_status() -> Dict[str, Any]: "extension_dir_valid": True, "script_urls": script_urls, "stylesheet_urls": stylesheet_urls, + "sidecars": sidecars, "counts": { "script_urls": len(script_urls), "stylesheet_urls": len(stylesheet_urls), + "sidecars": len(sidecars), }, "manifest": manifest_status, "warnings": diagnostics["warnings"], diff --git a/docs/EXTENSIONS.md b/docs/EXTENSIONS.md index 6727154db..f6997077b 100644 --- a/docs/EXTENSIONS.md +++ b/docs/EXTENSIONS.md @@ -95,6 +95,30 @@ may be kept in the manifest with the JSON boolean `"enabled": false`. Explicit `HERMES_WEBUI_EXTENSION_STYLESHEET_URLS` still work and are appended after manifest assets, with duplicates ignored. +Extension entries may also declare a read-only loopback sidecar for diagnostics: + +```json +{ + "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" + } + } + ] +} +``` + +Loopback sidecars do **not** change asset injection behavior. They are only +reported by diagnostics so an operator can see that a local companion service was +declared and optionally check its health from the browser. + ## URL rules Injected asset URLs are deliberately restricted: @@ -153,10 +177,7 @@ control, append the exact origin with `HERMES_WEBUI_CSP_CONNECT_EXTRA` before starting WebUI: ```bash -HERMES_WEBUI_CSP_CONNECT_EXTRA=https://companion.example.internal \ -HERMES_WEBUI_EXTENSION_DIR=/path/to/my-extension/static \ -HERMES_WEBUI_EXTENSION_MANIFEST=extensions.json \ -./start.sh +HERMES_WEBUI_CSP_CONNECT_EXTRA=https://companion.example.internal HERMES_WEBUI_EXTENSION_DIR=/path/to/my-extension/static HERMES_WEBUI_EXTENSION_MANIFEST=extensions.json ./start.sh ``` `HERMES_WEBUI_CSP_CONNECT_EXTRA` accepts space-separated `http(s)://` or @@ -164,6 +185,26 @@ HERMES_WEBUI_EXTENSION_MANIFEST=extensions.json \ numbers. Avoid wildcard or remote origins unless you fully control the target; extension JavaScript runs with the logged-in WebUI session's authority. +## Loopback sidecar declarations + +Sidecar declarations are sanitized before they appear in diagnostics: + +- only `"type": "loopback"` is supported +- `origin` must be an `http` or `https` origin on `127.0.0.1`, `localhost`, or + `[::1]` +- `origin` must not include a username, password, path, query string, or fragment +- `health_path` is optional and defaults to `/health` +- when present, `health_path` must start with `/` and must not contain a scheme, + host, query string, fragment, quotes, control characters, backslashes, empty + segments, whitespace, or path traversal + +Invalid sidecars are skipped with a stable warning code such as +`sidecar_origin_rejected`, `sidecar_type_unsupported`, +`sidecar_health_path_rejected`, or `sidecar_invalid`. Raw rejected origins and +paths are never returned by the status endpoint. If `health_path` is omitted, +diagnostics use `/health`; if `health_path` is present but invalid, the sidecar is +skipped rather than probed. + ## Static file serving When `HERMES_WEBUI_EXTENSION_DIR` points at an existing directory, files under @@ -307,12 +348,22 @@ not enable, disable, install, or mutate extensions; it only fetches `/api/extensions/status` and offers a copy-diagnostics action. The diagnostics return the same public asset URLs that can already be injected -into the HTML, plus coarse manifest status, asset counts, and warning codes for -rejected or unavailable configuration. `manifest.script_count` and -`manifest.stylesheet_count` count accepted assets from the manifest only; -`counts.script_urls` and `counts.stylesheet_urls` count the final post-env-merge -URLs. `manifest.entry_count` counts the loaded top-level manifest object and -enabled extension entries that were inspected, not every extension object in the -file. The endpoint and Settings panel do **not** return -`HERMES_WEBUI_EXTENSION_DIR`, resolved manifest paths, raw environment values, or -rejected URL strings. +into the HTML, plus coarse manifest status, asset counts, sanitized declared +loopback sidecars, and warning codes for rejected or unavailable configuration. +`manifest.script_count` and `manifest.stylesheet_count` count accepted assets +from the manifest only; `manifest.sidecar_count` counts accepted enabled loopback +sidecars from the manifest. `counts.script_urls` and `counts.stylesheet_urls` +count the final post-env-merge URLs, while `counts.sidecars` counts the sanitized +sidecar list returned in `sidecars`. `manifest.entry_count` counts the loaded +top-level manifest object and enabled extension entries that were inspected, not +every extension object in the file. The endpoint and Settings panel do **not** +return `HERMES_WEBUI_EXTENSION_DIR`, resolved manifest paths, raw environment +values, rejected URL strings, rejected sidecar origins, or rejected health paths. + +When sanitized loopback sidecars are present, **Settings → Extensions** renders a +read-only sidecar monitor card. The browser checks each declared `health_url` +directly with `fetch(..., { credentials: 'omit', cache: 'no-store' })` and a +short timeout. WebUI does **not** proxy sidecar requests and does not send WebUI +cookies to sidecars. A successful HTTP response is shown as healthy, a non-OK +HTTP response as unhealthy, and CORS/network/timeouts as unreachable or blocked; +health response bodies are never rendered. diff --git a/static/panels.js b/static/panels.js index 871c18a1c..8fc6a4a0b 100644 --- a/static/panels.js +++ b/static/panels.js @@ -6223,6 +6223,7 @@ let _settingsIndex = null; let _settingsIndexPromise = null; let _settingsSearchSeq = 0; let _extensionsStatusData = null; +let _extensionsSidecarMonitorSeq = 0; let _settingsSearchDismissListenerRegistered = false; let _settingsAppearanceAutosaveTimer = null; let _settingsAppearanceAutosaveRetryPayload = null; @@ -7536,7 +7537,84 @@ function _extensionCountValue(counts,key,urls){ return Array.isArray(urls)?urls.length:0; } -function _renderExtensionsPanel(data){ +function _extensionSidecarHealthBadge(status,label){ + const safeStatus=['checking','healthy','unhealthy','blocked'].includes(status)?status:'checking'; + return `${esc(label||safeStatus)}`; +} + +function _extensionSidecarCard(sidecars){ + const list=Array.isArray(sidecars)?sidecars:[]; + const body=list.length?`
${list.map((sidecar,index)=>{ + const id=(sidecar&&sidecar.id)||''; + const name=(sidecar&&sidecar.name)||''; + const title=name||id||'Unnamed extension'; + const meta=(name&&id)?id:(sidecar&&sidecar.type)||'loopback'; + const origin=(sidecar&&sidecar.origin)||''; + const healthPath=(sidecar&&sidecar.health_path)||''; + const healthUrl=(sidecar&&sidecar.health_url)||''; + return `
+
+
${esc(title)}
+ ${_extensionSidecarHealthBadge('checking','checking')} +
+
${esc(meta)}
+
+
Origin${esc(origin)}
+
Health path${esc(healthPath)}
+
Health URL${esc(healthUrl)}
+
+
`; + }).join('')}
`:'
No loopback sidecars declared.
'; + return ` +
+
+
+
Loopback sidecars
+
Declared local companions; health is checked directly from this browser with WebUI credentials omitted.
+
+
+
+ ${body} +
+
`; +} + +function _setExtensionSidecarHealth(index,status,label){ + const el=document.querySelector(`[data-sidecar-health-index="${index}"]`); + if(el) el.innerHTML=_extensionSidecarHealthBadge(status,label); +} + +async function _checkExtensionSidecarHealth(sidecar,index,seq){ + const healthUrl=sidecar&&sidecar.health_url; + if(!healthUrl){ + _setExtensionSidecarHealth(index,'blocked','unreachable / blocked'); + return; + } + let controller=null; + let timeoutId=null; + try{ + if(typeof AbortController!=='undefined'){ + controller=new AbortController(); + timeoutId=setTimeout(()=>controller.abort(),2500); + } + const res=await fetch(healthUrl,{credentials:'omit',cache:'no-store',signal:controller?controller.signal:undefined}); + if(seq!==_extensionsSidecarMonitorSeq) return; + if(res.ok) _setExtensionSidecarHealth(index,'healthy','healthy'); + else _setExtensionSidecarHealth(index,'unhealthy','unhealthy'); + }catch(_e){ + if(seq!==_extensionsSidecarMonitorSeq) return; + _setExtensionSidecarHealth(index,'blocked','unreachable / blocked'); + }finally{ + if(timeoutId) clearTimeout(timeoutId); + } +} + +function _monitorExtensionSidecars(sidecars,seq){ + if(!Array.isArray(sidecars)||sidecars.length===0) return; + sidecars.forEach((sidecar,index)=>_checkExtensionSidecarHealth(sidecar,index,seq)); +} + +function _renderExtensionsPanel(data,seq){ const target=$('extensionsDiagnostics'); const copyBtn=$('extensionsCopyDiagnosticsBtn'); if(!target) return; @@ -7546,9 +7624,11 @@ function _renderExtensionsPanel(data){ const counts=(data&&data.counts)||{}; const scripts=Array.isArray(data&&data.script_urls)?data.script_urls:[]; const styles=Array.isArray(data&&data.stylesheet_urls)?data.stylesheet_urls:[]; + const sidecars=Array.isArray(data&&data.sidecars)?data.sidecars:[]; const statusClass=(data&&data.enabled)?'extension-card-enabled':'extension-card-disabled'; const scriptCount=_extensionCountValue(counts,'script_urls',scripts); const styleCount=_extensionCountValue(counts,'stylesheet_urls',styles); + const sidecarCount=_extensionCountValue(counts,'sidecars',sidecars); target.innerHTML=`
@@ -7568,8 +7648,10 @@ function _renderExtensionsPanel(data){
Manifest entries inspected${Number(manifest.entry_count)||0}
Manifest script count${Number(manifest.script_count)||0}
Manifest stylesheet count${Number(manifest.stylesheet_count)||0}
+
Manifest sidecar count${Number(manifest.sidecar_count)||0}
Final script count${scriptCount}
Final stylesheet count${styleCount}
+
Loopback sidecar count${sidecarCount}
@@ -7587,6 +7669,7 @@ function _renderExtensionsPanel(data){ ${_extensionAssetList(styles)} + ${_extensionSidecarCard(sidecars)}
@@ -7599,6 +7682,7 @@ function _renderExtensionsPanel(data){
`; + _monitorExtensionSidecars(sidecars,seq); } async function loadExtensionsPanel(){ @@ -7606,10 +7690,12 @@ async function loadExtensionsPanel(){ const copyBtn=$('extensionsCopyDiagnosticsBtn'); if(!target) return; if(copyBtn) copyBtn.disabled=true; + const seq=++_extensionsSidecarMonitorSeq; target.innerHTML='
Loading extension diagnostics…
'; try{ const data=await api('/api/extensions/status'); - _renderExtensionsPanel(data); + if(seq!==_extensionsSidecarMonitorSeq) return; + _renderExtensionsPanel(data,seq); }catch(e){ _extensionsStatusData=null; if(copyBtn) copyBtn.disabled=true; diff --git a/static/style.css b/static/style.css index 08583fab7..77c1490ca 100644 --- a/static/style.css +++ b/static/style.css @@ -4592,9 +4592,25 @@ main.main > #mainPlugin{display:none;} .extension-url-list code,.extension-warning-list code{font-family:var(--font-mono);font-size:11px;color:var(--text);overflow-wrap:anywhere;word-break:break-word;} .extension-warning-list span{font-size:11px;color:var(--muted);overflow-wrap:anywhere;} .extension-url-empty{margin-top:6px;font-size:12px;color:var(--muted);font-style:italic;} +.extension-sidecar-list{display:flex;flex-direction:column;gap:8px;} +.extension-sidecar-row{padding:9px 10px;border:1px solid var(--border);border-radius:8px;background:var(--surface);min-width:0;} +.extension-sidecar-row-head{display:flex;align-items:center;justify-content:space-between;gap:10px;min-width:0;} +.extension-sidecar-title{font-size:13px;font-weight:600;color:var(--text);overflow-wrap:anywhere;min-width:0;} +.extension-sidecar-meta{margin-top:2px;font-size:11px;color:var(--muted);overflow-wrap:anywhere;} +.extension-sidecar-fields{display:grid;grid-template-columns:1fr;gap:5px;margin-top:8px;} +.extension-sidecar-fields>div{display:flex;align-items:flex-start;gap:8px;min-width:0;font-size:12px;color:var(--muted);} +.extension-sidecar-fields span{flex:0 0 82px;} +.extension-sidecar-fields code{font-family:var(--font-mono);font-size:11px;color:var(--text);overflow-wrap:anywhere;word-break:break-word;} +.extension-sidecar-status-badge{display:inline-flex;align-items:center;white-space:nowrap;border-radius:999px;padding:2px 7px;font-size:10.5px;font-weight:700;font-family:var(--font-mono);line-height:1.45;background:var(--code-bg);color:var(--muted);} +.extension-sidecar-status-healthy{background:rgba(34,197,94,.14);color:#22c55e;} +.extension-sidecar-status-unhealthy{background:rgba(245,158,11,.14);color:#f59e0b;} +.extension-sidecar-status-blocked{background:rgba(239,68,68,.14);color:var(--error);} @media (max-width: 768px){ .extensions-copy-diagnostics-btn{width:100%;} .extension-summary-grid{grid-template-columns:1fr;} + .extension-sidecar-row-head{align-items:flex-start;flex-direction:column;} + .extension-sidecar-fields>div{flex-direction:column;gap:2px;} + .extension-sidecar-fields span{flex:0 0 auto;} } /* ── Provider model tags ── */ diff --git a/tests/test_extension_status_endpoint.py b/tests/test_extension_status_endpoint.py index f174f6ec0..c62da385f 100644 --- a/tests/test_extension_status_endpoint.py +++ b/tests/test_extension_status_endpoint.py @@ -58,7 +58,8 @@ def test_extension_status_disabled_by_default(): "extension_dir_valid": False, "script_urls": [], "stylesheet_urls": [], - "counts": {"script_urls": 0, "stylesheet_urls": 0}, + "sidecars": [], + "counts": {"script_urls": 0, "stylesheet_urls": 0, "sidecars": 0}, "manifest": { "configured": False, "loaded": False, @@ -66,6 +67,7 @@ def test_extension_status_disabled_by_default(): "entry_count": 0, "script_count": 0, "stylesheet_count": 0, + "sidecar_count": 0, }, "warnings": [], } @@ -124,7 +126,8 @@ def test_extension_status_reports_loaded_manifest_counts_and_urls(tmp_path, monk "/extensions/base.css", "/extensions/templates/app.css", ] - assert status["counts"] == {"script_urls": 3, "stylesheet_urls": 2} + assert status["sidecars"] == [] + assert status["counts"] == {"script_urls": 3, "stylesheet_urls": 2, "sidecars": 0} assert status["manifest"] == { "configured": True, "loaded": True, @@ -132,6 +135,7 @@ def test_extension_status_reports_loaded_manifest_counts_and_urls(tmp_path, monk "entry_count": 2, "script_count": 2, "stylesheet_count": 2, + "sidecar_count": 0, } assert status["warnings"] == [] @@ -163,6 +167,8 @@ def test_extension_status_ignores_non_dict_manifest_extensions_in_entry_count( 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"] == [] @@ -332,6 +338,281 @@ def test_extension_status_reports_rejected_env_assets_without_rejected_values(tm 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", + }, + { + "id": "implicit-health", + "name": "", + "type": "loopback", + "origin": "http://localhost:17788", + "health_path": "/health", + "health_url": "http://localhost:17788/health", + }, + { + "id": "ipv6-loopback", + "name": "", + "type": "loopback", + "origin": "http://[::1]:17789", + "health_path": "/ready", + "health_url": "http://[::1]:17789/ready", + }, + ] + assert status["counts"]["sidecars"] == 3 + assert status["manifest"]["sidecar_count"] == 3 + assert status["warnings"] == [] + + +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_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_status_route_is_wired(monkeypatch): from api import routes diff --git a/tests/test_extensions_settings_panel.py b/tests/test_extensions_settings_panel.py index c0e85cdfe..5e5177aee 100644 --- a/tests/test_extensions_settings_panel.py +++ b/tests/test_extensions_settings_panel.py @@ -109,8 +109,11 @@ def test_extensions_panel_renders_sanitized_status_payload(): assert "manifest.entry_count" in render_block assert "manifest.script_count" in render_block assert "manifest.stylesheet_count" in render_block + assert "manifest.sidecar_count" in render_block assert "script_urls" in render_block assert "stylesheet_urls" in render_block + assert "data&&data.sidecars" in render_block + assert "_extensionSidecarCard(sidecars)" in render_block assert "data&&data.warnings" in render_block assert "esc(url)" in asset_block assert "esc(manifest.status||'unknown')" in render_block @@ -119,6 +122,36 @@ def test_extensions_panel_renders_sanitized_status_payload(): assert "Rejected" not in render_block # rejected values must never be rendered directly +def test_extensions_panel_renders_loopback_sidecar_monitor_safely(): + sidecar_block = _between("function _extensionSidecarCard", "function _setExtensionSidecarHealth") + monitor_block = _between("async function _checkExtensionSidecarHealth", "function _renderExtensionsPanel") + render_block = _between("function _renderExtensionsPanel", "async function loadExtensionsPanel") + load_block = _between("async function loadExtensionsPanel", "async function copyExtensionsDiagnostics") + + assert "Loopback sidecars" in sidecar_block + assert "No loopback sidecars declared." in sidecar_block + assert "esc(title)" in sidecar_block + assert "esc(meta)" in sidecar_block + assert "esc(origin)" in sidecar_block + assert "esc(healthPath)" in sidecar_block + assert "esc(healthUrl)" in sidecar_block + assert "fetch(healthUrl,{credentials:'omit',cache:'no-store'" in monitor_block + assert "function _monitorExtensionSidecars(sidecars,seq)" in monitor_block + assert "const seq=_extensionsSidecarMonitorSeq" not in monitor_block + assert "_monitorExtensionSidecars(sidecars,seq)" in render_block + assert "function _renderExtensionsPanel(data,seq)" in render_block + assert "const seq=++_extensionsSidecarMonitorSeq" in load_block + assert "if(seq!==_extensionsSidecarMonitorSeq) return;" in load_block + assert "_renderExtensionsPanel(data,seq)" in load_block + assert "res.ok" in monitor_block + assert "res.text" not in monitor_block + assert "res.json" not in monitor_block + assert "api('/api/settings'" not in monitor_block + assert "api('/extensions/" not in monitor_block + assert "sidecar/*" not in monitor_block + assert not _contains_post_method(monitor_block) + + def test_copy_extensions_diagnostics_copies_current_sanitized_payload(): copy_block = _between("function copyExtensionsDiagnostics", "// ── Plugins panel") @@ -135,6 +168,8 @@ def test_extensions_styles_are_scoped_to_extensions_panel(): assert ".extension-summary-grid" in STYLE_CSS assert ".extension-warning-list" in STYLE_CSS assert ".extension-url-list" in STYLE_CSS + assert ".extension-sidecar-list" in STYLE_CSS + assert ".extension-sidecar-status-badge" in STYLE_CSS def test_extensions_tab_i18n_key_exists_for_all_locales(): @@ -151,4 +186,8 @@ def test_extensions_docs_mentions_settings_panel_without_mutation_claims(): assert "Settings → Extensions" in diagnostics_section assert "enable, disable, install, or mutate extensions" in diagnostics_section assert "`/api/extensions/status`" in diagnostics_section - assert "do **not** return" in diagnostics_section + assert "sanitized loopback sidecars" in diagnostics_section + assert "credentials: 'omit'" in diagnostics_section + assert "does **not** proxy sidecar requests" in diagnostics_section + assert "do **not**" in diagnostics_section + assert "return `HERMES_WEBUI_EXTENSION_DIR`" in diagnostics_section