feat: surface loopback sidecar diagnostics

This commit is contained in:
santastabber
2026-06-21 19:49:18 +10:00
parent f5a0d047ed
commit 7080929f37
6 changed files with 637 additions and 31 deletions
+146 -13
View File
@@ -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"],
+64 -13
View File
@@ -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.
+88 -2
View File
@@ -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 `<span class="extension-sidecar-status-badge extension-sidecar-status-${safeStatus}">${esc(label||safeStatus)}</span>`;
}
function _extensionSidecarCard(sidecars){
const list=Array.isArray(sidecars)?sidecars:[];
const body=list.length?`<div class="extension-sidecar-list">${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 `<div class="extension-sidecar-row" data-sidecar-index="${index}">
<div class="extension-sidecar-row-head">
<div class="extension-sidecar-title">${esc(title)}</div>
<span id="extensionSidecarHealth${index}" data-sidecar-health-index="${index}">${_extensionSidecarHealthBadge('checking','checking')}</span>
</div>
<div class="extension-sidecar-meta">${esc(meta)}</div>
<div class="extension-sidecar-fields">
<div><span>Origin</span><code>${esc(origin)}</code></div>
<div><span>Health path</span><code>${esc(healthPath)}</code></div>
<div><span>Health URL</span><code>${esc(healthUrl)}</code></div>
</div>
</div>`;
}).join('')}</div>`:'<div class="extension-url-empty">No loopback sidecars declared.</div>';
return `
<div class="provider-card extension-sidecars-card">
<div class="provider-card-header plugin-card-header">
<div class="provider-card-info">
<div class="provider-card-name">Loopback sidecars</div>
<div class="provider-card-meta">Declared local companions; health is checked directly from this browser with WebUI credentials omitted.</div>
</div>
</div>
<div class="provider-card-body extension-card-body">
${body}
</div>
</div>`;
}
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=`
<div class="provider-card extension-status-card ${statusClass}">
<div class="provider-card-header plugin-card-header">
@@ -7568,8 +7648,10 @@ function _renderExtensionsPanel(data){
<div><span>Manifest entries inspected</span><code>${Number(manifest.entry_count)||0}</code></div>
<div><span>Manifest script count</span><code>${Number(manifest.script_count)||0}</code></div>
<div><span>Manifest stylesheet count</span><code>${Number(manifest.stylesheet_count)||0}</code></div>
<div><span>Manifest sidecar count</span><code>${Number(manifest.sidecar_count)||0}</code></div>
<div><span>Final script count</span><code>${scriptCount}</code></div>
<div><span>Final stylesheet count</span><code>${styleCount}</code></div>
<div><span>Loopback sidecar count</span><code>${sidecarCount}</code></div>
</div>
</div>
</div>
@@ -7587,6 +7669,7 @@ function _renderExtensionsPanel(data){
${_extensionAssetList(styles)}
</div>
</div>
${_extensionSidecarCard(sidecars)}
<div class="provider-card extension-warnings-card">
<div class="provider-card-header plugin-card-header">
<div class="provider-card-info">
@@ -7599,6 +7682,7 @@ function _renderExtensionsPanel(data){
</div>
</div>
`;
_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='<div class="extensions-loading">Loading extension diagnostics…</div>';
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;
+16
View File
@@ -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 ── */
+283 -2
View File
@@ -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
+40 -1
View File
@@ -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