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 `
+ `;
+}
+
+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=`
@@ -7587,6 +7669,7 @@ function _renderExtensionsPanel(data){
${_extensionAssetList(styles)}
+ ${_extensionSidecarCard(sidecars)}
`;
+ _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