diff --git a/docs/EXTENSIONS.md b/docs/EXTENSIONS.md
index 985b5368b..24b69c461 100644
--- a/docs/EXTENSIONS.md
+++ b/docs/EXTENSIONS.md
@@ -414,4 +414,9 @@ 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.
+raw health response bodies are never rendered. If a healthy response includes an
+optional top-level `runtime` object, the panel may parse it and render only
+allowlisted scalar fields such as `sidecar`, `native_host`, `bridge`,
+`last_seen_at`, and `webui_origin`. This keeps sidecar-specific diagnostics
+machine-readable without making WebUI depend on any one extension's private
+payload shape.
diff --git a/static/panels.js b/static/panels.js
index ddc565d60..d488b4657 100644
--- a/static/panels.js
+++ b/static/panels.js
@@ -8101,6 +8101,70 @@ function _extensionSidecarHealthBadge(status,label){
return `${esc(label||safeStatus)}`;
}
+function _extensionRuntimeStatusValue(value){
+ const normalized=String(value||'').trim().toLowerCase();
+ return ['running','connected','waiting','stale','unloaded','stopped','not_registered','unknown'].includes(normalized)
+ ? normalized
+ : 'unknown';
+}
+
+function _extensionRuntimeStatusLabel(value){
+ const normalized=_extensionRuntimeStatusValue(value);
+ if(normalized==='not_registered') return 'not registered';
+ return normalized.replace(/_/g,' ');
+}
+
+function _extensionRuntimeLastSeen(value){
+ const raw=Number(value);
+ if(!Number.isFinite(raw)||raw<=0) return '';
+ const seconds=raw>1000000000000?raw/1000:raw;
+ const age=Math.max(0,Math.floor(Date.now()/1000-seconds));
+ if(age<5) return 'just now';
+ if(age<60) return `${age}s ago`;
+ const minutes=Math.floor(age/60);
+ if(minutes<60) return `${minutes}m ago`;
+ const hours=Math.floor(minutes/60);
+ if(hours<24) return `${hours}h ago`;
+ return `${Math.floor(hours/24)}d ago`;
+}
+
+function _extensionRuntimeOrigin(value){
+ const text=String(value||'').trim();
+ if(!text) return '';
+ try{
+ const parsed=new URL(text);
+ if(parsed.protocol==='http:'&&(parsed.hostname==='127.0.0.1'||parsed.hostname==='localhost')){
+ return parsed.origin;
+ }
+ }catch(_e){}
+ return '';
+}
+
+function _extensionRuntimeRows(runtime){
+ if(!runtime||typeof runtime!=='object') return [];
+ const rows=[];
+ if(Object.prototype.hasOwnProperty.call(runtime,'sidecar')){
+ rows.push(['Sidecar',_extensionRuntimeStatusLabel(runtime.sidecar)]);
+ }
+ if(Object.prototype.hasOwnProperty.call(runtime,'native_host')){
+ rows.push(['Native host',_extensionRuntimeStatusLabel(runtime.native_host)]);
+ }
+ if(Object.prototype.hasOwnProperty.call(runtime,'bridge')){
+ rows.push(['Bridge',_extensionRuntimeStatusLabel(runtime.bridge)]);
+ }
+ const lastSeen=_extensionRuntimeLastSeen(runtime.last_seen_at);
+ if(lastSeen) rows.push(['Last update',lastSeen]);
+ const origin=_extensionRuntimeOrigin(runtime.webui_origin);
+ if(origin) rows.push(['WebUI origin',origin]);
+ return rows;
+}
+
+function _extensionRuntimeDetails(runtime){
+ const rows=_extensionRuntimeRows(runtime);
+ if(!rows.length) return '';
+ return rows.map(([label,value])=>`
${esc(label)}${esc(value)}
`).join('');
+}
+
function _extensionSidecarCard(sidecars){
const list=Array.isArray(sidecars)?sidecars:[];
const body=list.length?`${list.map((sidecar,index)=>{
@@ -8122,6 +8186,7 @@ function _extensionSidecarCard(sidecars){
Health path${esc(healthPath)}
Health URL${esc(healthUrl)}
+
`;
}).join('')}`:'No loopback sidecars declared.
';
return `
@@ -8143,10 +8208,24 @@ function _setExtensionSidecarHealth(index,status,label){
if(el) el.innerHTML=_extensionSidecarHealthBadge(status,label);
}
+function _setExtensionSidecarRuntime(index,runtime){
+ const el=document.querySelector(`[data-sidecar-runtime-index="${index}"]`);
+ if(!el) return;
+ const details=_extensionRuntimeDetails(runtime);
+ if(!details){
+ el.hidden=true;
+ el.innerHTML='';
+ return;
+ }
+ el.hidden=false;
+ el.innerHTML=details;
+}
+
async function _checkExtensionSidecarHealth(sidecar,index,seq){
const healthUrl=sidecar&&sidecar.health_url;
if(!healthUrl){
_setExtensionSidecarHealth(index,'blocked','unreachable / blocked');
+ _setExtensionSidecarRuntime(index,null);
return;
}
let controller=null;
@@ -8158,11 +8237,22 @@ async function _checkExtensionSidecarHealth(sidecar,index,seq){
}
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');
+ if(res.ok){
+ _setExtensionSidecarHealth(index,'healthy','healthy');
+ let body=null;
+ try{
+ body=await res.json();
+ }catch(_e){}
+ if(seq!==_extensionsSidecarMonitorSeq) return;
+ _setExtensionSidecarRuntime(index,body&&typeof body==='object'?body.runtime:null);
+ }else{
+ _setExtensionSidecarHealth(index,'unhealthy','unhealthy');
+ _setExtensionSidecarRuntime(index,null);
+ }
}catch(_e){
if(seq!==_extensionsSidecarMonitorSeq) return;
_setExtensionSidecarHealth(index,'blocked','unreachable / blocked');
+ _setExtensionSidecarRuntime(index,null);
}finally{
if(timeoutId) clearTimeout(timeoutId);
}
@@ -8314,6 +8404,7 @@ function switchExtensionsTab(tab){
document.querySelectorAll('[data-extensions-pane]').forEach(pane=>{
pane.hidden=pane.dataset.extensionsPane!==tab;
});
+ if(tab==='diagnostics') loadExtensionsPanel();
if(tab==='gallery'&&!_extensionsGalleryLoaded) loadExtensionsGallery();
}
diff --git a/static/style.css b/static/style.css
index 8d238c6dd..8e0b13d1c 100644
--- a/static/style.css
+++ b/static/style.css
@@ -4736,6 +4736,11 @@ main.main > #mainPlugin{display:none;}
.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-runtime{display:grid;grid-template-columns:repeat(auto-fit,minmax(132px,1fr));gap:6px;margin-top:9px;padding-top:9px;border-top:1px solid var(--border);}
+.extension-sidecar-runtime[hidden]{display:none!important;}
+.extension-sidecar-runtime>div{display:flex;flex-direction:column;gap:2px;min-width:0;border:1px solid var(--border);border-radius:7px;background:var(--card-bg,var(--sidebar-bg));padding:6px 8px;}
+.extension-sidecar-runtime span{font-size:10.5px;color:var(--muted);font-weight:600;text-transform:uppercase;letter-spacing:0;}
+.extension-sidecar-runtime 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;}
diff --git a/tests/test_extensions_settings_panel.py b/tests/test_extensions_settings_panel.py
index 0890ef2b1..ab4ac6c02 100644
--- a/tests/test_extensions_settings_panel.py
+++ b/tests/test_extensions_settings_panel.py
@@ -96,6 +96,13 @@ def test_extensions_panel_fetches_status_endpoint_without_mutating_settings():
assert "extensions-error" in load_block
+def test_extensions_diagnostics_tab_refreshes_runtime_status():
+ tab_block = _function_block("switchExtensionsTab", extra=900)
+
+ assert "if(tab==='diagnostics') loadExtensionsPanel();" in tab_block
+ assert "if(tab==='gallery'&&!_extensionsGalleryLoaded) loadExtensionsGallery();" in tab_block
+
+
def test_extensions_panel_renders_sanitized_status_payload():
render_block = _between("function _renderExtensionsPanel", "async function loadExtensionsPanel")
warning_block = _function_block("_extensionWarningList", extra=900)
@@ -128,7 +135,9 @@ def test_extensions_panel_renders_sanitized_status_payload():
def test_extensions_panel_renders_loopback_sidecar_monitor_safely():
+ runtime_block = _between("function _extensionRuntimeStatusValue", "function _extensionSidecarCard")
sidecar_block = _between("function _extensionSidecarCard", "function _setExtensionSidecarHealth")
+ runtime_setter_block = _between("function _setExtensionSidecarRuntime", "async function _checkExtensionSidecarHealth")
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")
@@ -140,6 +149,7 @@ def test_extensions_panel_renders_loopback_sidecar_monitor_safely():
assert "esc(origin)" in sidecar_block
assert "esc(healthPath)" in sidecar_block
assert "esc(healthUrl)" in sidecar_block
+ assert 'data-sidecar-runtime-index="${index}"' 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
@@ -150,7 +160,14 @@ def test_extensions_panel_renders_loopback_sidecar_monitor_safely():
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 "body=await res.json()" in monitor_block
+ assert "_setExtensionSidecarRuntime(index,body&&typeof body==='object'?body.runtime:null)" in monitor_block
+ assert "runtime.sidecar" in runtime_block
+ assert "runtime.native_host" in runtime_block
+ assert "runtime.bridge" in runtime_block
+ assert "runtime.last_seen_at" in runtime_block
+ assert "runtime.webui_origin" in runtime_block
+ assert "el.innerHTML=details" in runtime_setter_block
assert "api('/api/settings'" not in monitor_block
assert "api('/extensions/" not in monitor_block
assert "sidecar/*" not in monitor_block
@@ -196,6 +213,7 @@ def test_extensions_styles_are_scoped_to_extensions_panel():
assert ".extension-installed-list" in STYLE_CSS
assert ".extension-toggle-btn" in STYLE_CSS
assert ".extension-sidecar-list" in STYLE_CSS
+ assert ".extension-sidecar-runtime" in STYLE_CSS
assert ".extension-sidecar-status-badge" in STYLE_CSS
@@ -222,6 +240,8 @@ def test_extensions_docs_mentions_settings_panel_without_install_or_proxy_claims
assert "sanitized loopback sidecars" in diagnostics_section
assert "credentials: 'omit'" in diagnostics_section
assert "does **not** proxy sidecar requests" in diagnostics_section
+ assert "optional top-level `runtime` object" in diagnostics_section
+ assert "allowlisted scalar fields" in diagnostics_section
assert "do **not**" in diagnostics_section
assert "return `HERMES_WEBUI_EXTENSION_DIR`" in diagnostics_section
assert "override state-file path" in diagnostics_section