mirror of
https://github.com/nesquena/hermes-webui.git
synced 2026-07-09 09:11:04 +00:00
feat: surface loopback sidecar diagnostics
This commit is contained in:
+146
-13
@@ -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
@@ -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
@@ -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;
|
||||
|
||||
@@ -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 ── */
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user