diff --git a/api/dashboard_probe.py b/api/dashboard_probe.py new file mode 100644 index 00000000..cc15ef91 --- /dev/null +++ b/api/dashboard_probe.py @@ -0,0 +1,211 @@ +"""Safe server-side probe for the official Hermes Agent dashboard. + +The official `hermes dashboard` binds to 127.0.0.1:9119 by default and exposes +GET /api/status as a public, read-only identity/status endpoint. Keep all +probing server-side to avoid browser CORS/mixed-content failures, and only allow +loopback targets so a user-controlled setting cannot become an SSRF primitive. +""" + +from __future__ import annotations + +import json +import logging +import os +import urllib.request +from urllib.parse import urlparse + +logger = logging.getLogger(__name__) + +DEFAULT_DASHBOARD_PORT = 9119 +DEFAULT_DASHBOARD_TIMEOUT = 0.5 +DEFAULT_DASHBOARD_TARGETS = (("127.0.0.1", DEFAULT_DASHBOARD_PORT), ("localhost", DEFAULT_DASHBOARD_PORT)) +_DASHBOARD_ENABLED_VALUES = {"auto", "always", "never"} +_LOOPBACK_HOSTS = {"127.0.0.1", "localhost", "::1"} + + +def _base_url(host: str, port: int, scheme: str = "http") -> str: + display_host = f"[{host}]" if ":" in host and not host.startswith("[") else host + return f"{scheme}://{display_host}:{port}" + + +def normalize_dashboard_url(raw_url: str | None) -> tuple[str, int, str, str] | None: + """Return (host, port, scheme, base_url) for a safe loopback dashboard URL. + + Overrides intentionally accept only scheme + loopback host + explicit port. + Paths, query strings, fragments, and credentials are rejected: the probe + appends the official `/api/status` fingerprint itself and must not become an + arbitrary local URL fetcher. + """ + raw = str(raw_url or "").strip() + if not raw: + return None + parsed = urlparse(raw) + if parsed.scheme not in {"http", "https"}: + raise ValueError("invalid dashboard URL scheme") + if parsed.username or parsed.password: + raise ValueError("invalid dashboard URL credentials") + host = parsed.hostname or "" + normalized_host = host.strip().lower() + if normalized_host not in _LOOPBACK_HOSTS: + raise ValueError("invalid dashboard URL host") + try: + port = parsed.port + except ValueError as exc: + raise ValueError("invalid dashboard URL port") from exc + if not isinstance(port, int) or not (1 <= port <= 65535): + raise ValueError("invalid dashboard URL port") + path = parsed.path or "" + if path not in ("", "/") or parsed.params or parsed.query or parsed.fragment: + raise ValueError("invalid dashboard URL path") + base = _base_url(normalized_host, port, parsed.scheme) + return normalized_host, port, parsed.scheme, base + + +def _looks_like_official_dashboard(payload: object) -> bool: + if not isinstance(payload, dict): + return False + version = payload.get("version") + if not isinstance(version, str) or not version.strip(): + return False + # Verified against current Hermes Agent `hermes_cli.web_server.get_status()`: + # /api/status returns version plus these Hermes-specific fields. Requiring at + # least one avoids treating any generic {version: ...} local service as the + # official dashboard. + return any(key in payload for key in ("release_date", "hermes_home", "config_path", "gateway_running")) + + +def probe_official_dashboard( + host: str, + port: int, + timeout: float = DEFAULT_DASHBOARD_TIMEOUT, + scheme: str = "http", +) -> dict: + """Best-effort check that `hermes dashboard` is running on host:port.""" + try: + normalized_host = str(host or "").strip().lower() + if normalized_host not in _LOOPBACK_HOSTS: + raise ValueError("dashboard probe host must be loopback") + port = int(port) + if not (1 <= port <= 65535): + raise ValueError("dashboard probe port out of range") + if scheme not in {"http", "https"}: + raise ValueError("dashboard probe scheme must be http or https") + base = _base_url(normalized_host, port, scheme) + request = urllib.request.Request( + f"{base}/api/status", + headers={"Accept": "application/json", "User-Agent": "hermes-webui-dashboard-probe"}, + ) + with urllib.request.urlopen(request, timeout=timeout) as response: + if getattr(response, "status", None) != 200: + return {"running": False} + payload = json.loads(response.read().decode("utf-8")) + if not _looks_like_official_dashboard(payload): + return {"running": False} + result = {"running": True, "host": normalized_host, "port": port, "url": base} + version = payload.get("version") + if isinstance(version, str) and version.strip(): + result["version"] = version.strip() + return result + except Exception: + logger.debug("official Hermes dashboard probe failed", exc_info=True) + return {"running": False} + + +def _dashboard_config(config_data: dict | None = None) -> dict: + if config_data is None: + try: + from api.config import get_config + + config_data = get_config() + except Exception: + config_data = {} + webui_cfg = config_data.get("webui", {}) if isinstance(config_data, dict) else {} + dashboard_cfg = webui_cfg.get("dashboard", {}) if isinstance(webui_cfg, dict) else {} + return dashboard_cfg if isinstance(dashboard_cfg, dict) else {} + + +def get_dashboard_config(config_data: dict | None = None) -> dict: + """Return normalized profile config for the Settings → System controls.""" + dashboard_cfg = _dashboard_config(config_data) + enabled = str(dashboard_cfg.get("enabled", "auto") or "auto").strip().lower() + if enabled not in _DASHBOARD_ENABLED_VALUES: + enabled = "auto" + raw_url = str(dashboard_cfg.get("url") or "").strip() + if raw_url: + # Normalize before echoing so the UI never displays unsafe/stale values. + _host, _port, _scheme, raw_url = normalize_dashboard_url(raw_url) + return {"enabled": enabled, "url": raw_url} + + +def save_dashboard_config(payload: dict) -> dict: + """Persist dashboard link settings under webui.dashboard in config.yaml.""" + enabled = str((payload or {}).get("enabled", "auto") or "auto").strip().lower() + if enabled not in _DASHBOARD_ENABLED_VALUES: + raise ValueError("invalid dashboard enabled mode") + raw_url = str((payload or {}).get("url", "") or "").strip() + normalized_url = "" + if raw_url: + _host, _port, _scheme, normalized_url = normalize_dashboard_url(raw_url) + + from api import config as webui_config + + config_path = webui_config._get_config_path() + config_data = webui_config._load_yaml_config_file(config_path) + webui_section = config_data.get("webui") + if not isinstance(webui_section, dict): + webui_section = {} + config_data["webui"] = webui_section + dashboard_section = webui_section.get("dashboard") + if not isinstance(dashboard_section, dict): + dashboard_section = {} + webui_section["dashboard"] = dashboard_section + dashboard_section["enabled"] = enabled + if normalized_url: + dashboard_section["url"] = normalized_url + else: + dashboard_section.pop("url", None) + webui_config._save_yaml_config_file(config_path, config_data) + webui_config.reload_config() + return {"enabled": enabled, "url": normalized_url} + + +def _webui_bind_host_allows_auto_probe() -> bool: + raw_host = str(os.environ.get("HERMES_WEBUI_HOST") or "127.0.0.1").strip().lower() + host = raw_host.replace("[", "").replace("]", "") + return host in _LOOPBACK_HOSTS + + +def get_dashboard_status(config_data: dict | None = None) -> dict: + """Return the safe status payload consumed by GET /api/dashboard/status.""" + dashboard_cfg = _dashboard_config(config_data) + enabled = str(dashboard_cfg.get("enabled", "auto") or "auto").strip().lower() + if enabled not in _DASHBOARD_ENABLED_VALUES: + enabled = "auto" + if enabled == "never": + return {"running": False, "enabled": "never"} + + raw_url = dashboard_cfg.get("url") or dashboard_cfg.get("target") or "" + try: + override = normalize_dashboard_url(raw_url) + except ValueError: + return {"running": False, "enabled": enabled, "error": "invalid dashboard url"} + + targets: list[tuple[str, int, str, str]] + if override: + targets = [override] + else: + targets = [(host, port, "http", _base_url(host, port)) for host, port in DEFAULT_DASHBOARD_TARGETS] + + if enabled == "always": + host, port, scheme, base = targets[0] + return {"running": True, "enabled": enabled, "host": host, "port": port, "url": base} + + if not _webui_bind_host_allows_auto_probe(): + return {"running": False, "enabled": enabled} + + for host, port, scheme, _base in targets: + result = probe_official_dashboard(host, port, timeout=DEFAULT_DASHBOARD_TIMEOUT, scheme=scheme) + if result.get("running"): + result["enabled"] = enabled + return result + return {"running": False, "enabled": enabled} diff --git a/api/routes.py b/api/routes.py index 8cb81380..0f28a15e 100644 --- a/api/routes.py +++ b/api/routes.py @@ -2161,6 +2161,21 @@ def handle_get(handler, parsed) -> bool: if parsed.path == "/api/models/live": return _handle_live_models(handler, parsed) + if parsed.path == "/api/dashboard/status": + from api import dashboard_probe + + j(handler, dashboard_probe.get_dashboard_status()) + return True + + if parsed.path == "/api/dashboard/config": + from api import dashboard_probe + + try: + j(handler, dashboard_probe.get_dashboard_config()) + except ValueError as exc: + bad(handler, str(exc), status=400) + return True + # ── Providers (GET) ── if parsed.path == "/api/providers": return j(handler, get_providers()) @@ -2880,6 +2895,17 @@ def handle_post(handler, parsed) -> bool: from api.kanban_bridge import handle_kanban_post return handle_kanban_post(handler, parsed, body) + if parsed.path == "/api/dashboard/config": + from api import dashboard_probe + + try: + j(handler, dashboard_probe.save_dashboard_config(body)) + except ValueError as exc: + bad(handler, str(exc), status=400) + except Exception as exc: + logger.exception("dashboard config save failed") + bad(handler, str(exc), status=500) + return True if parsed.path == "/api/session/new": try: diff --git a/docs/pr-media/1459/dashboard-nav-link.png b/docs/pr-media/1459/dashboard-nav-link.png new file mode 100644 index 00000000..85b68084 Binary files /dev/null and b/docs/pr-media/1459/dashboard-nav-link.png differ diff --git a/static/i18n.js b/static/i18n.js index 20dac616..5a522e23 100644 --- a/static/i18n.js +++ b/static/i18n.js @@ -530,6 +530,8 @@ const LOCALES = { kanban_status_archived: 'Archived', tab_todos: 'Todos', tab_insights: 'Insights', + tab_dashboard: 'Hermes Dashboard', + dashboard_loopback_warning: 'Dashboard is loopback-only on the server. Either browse from the server itself or restart it with --host 0.0.0.0 (insecure).', tab_settings: 'Settings', new_conversation: 'New conversation', filter_conversations: 'Filter conversations...', @@ -1521,6 +1523,8 @@ const LOCALES = { kanban_status_archived: 'Archived', tab_todos: 'ToDo', tab_insights: 'インサイト', + tab_dashboard: 'Hermes ダッシュボード', + dashboard_loopback_warning: 'ダッシュボードはサーバー上のループバック専用です。サーバー上で閲覧するか、--host 0.0.0.0(安全ではありません)で再起動してください。', tab_settings: '設定', new_conversation: '新しい会話', filter_conversations: '会話を絞り込み...', @@ -2351,6 +2355,8 @@ const LOCALES = { kanban_status_archived: 'Archived', tab_todos: 'Список дел', tab_insights: 'Аналитика', + tab_dashboard: 'Панель Hermes', + dashboard_loopback_warning: 'Панель доступна только через loopback на сервере. Откройте её с самого сервера или перезапустите с --host 0.0.0.0 (небезопасно).', tab_settings: 'Настройки', new_conversation: 'Новая беседа', filter_conversations: 'Фильтр бесед...', @@ -3277,6 +3283,8 @@ const LOCALES = { kanban_status_archived: 'Archived', tab_todos: 'Todos', tab_insights: 'Analíticas', + tab_dashboard: 'Panel de Hermes', + dashboard_loopback_warning: 'El panel solo usa loopback en el servidor. Navega desde el propio servidor o reinícialo con --host 0.0.0.0 (inseguro).', tab_settings: 'Ajustes', new_conversation: 'Nueva conversación', filter_conversations: 'Filtrar conversaciones...', @@ -4191,6 +4199,8 @@ const LOCALES = { kanban_status_archived: 'Archived', tab_todos: 'Todos', tab_insights: 'Statistiken', + tab_dashboard: 'Hermes-Dashboard', + dashboard_loopback_warning: 'Das Dashboard ist auf dem Server nur per Loopback erreichbar. Öffne es direkt auf dem Server oder starte es mit --host 0.0.0.0 neu (unsicher).', tab_settings: 'Einstellungen', new_conversation: 'Neuer Chat', filter_conversations: 'Chats filtern...', @@ -5126,6 +5136,8 @@ const LOCALES = { kanban_status_archived: 'Archived', tab_todos: '待办', tab_insights: '统计', + tab_dashboard: 'Hermes 仪表盘', + dashboard_loopback_warning: '仪表盘在服务器上仅限 loopback 访问。请从服务器本机浏览,或使用 --host 0.0.0.0 重启(不安全)。', tab_workspaces: '工作区', tab_profiles: '配置', tab_settings: '设置', @@ -6011,6 +6023,8 @@ const LOCALES = { tab_tasks: '\u4efb\u52d9', tab_todos: '待辦', tab_insights: '統計', + tab_dashboard: 'Hermes 儀表板', + dashboard_loopback_warning: '儀表板在伺服器上僅限 loopback 存取。請從伺服器本機瀏覽,或使用 --host 0.0.0.0 重新啟動(不安全)。', tab_workspaces: '\u5de5\u4f5c\u5340', new_conversation: '新對話', filter_conversations: '篩選對話', @@ -7078,6 +7092,8 @@ const LOCALES = { kanban_status_archived: 'Archived', tab_todos: 'Todos', tab_insights: 'Estatísticas', + tab_dashboard: 'Painel Hermes', + dashboard_loopback_warning: 'O painel é somente loopback no servidor. Navegue pelo próprio servidor ou reinicie com --host 0.0.0.0 (inseguro).', tab_settings: 'Configurações', new_conversation: 'Nova conversa', filter_conversations: 'Filtrar conversas...', @@ -7968,6 +7984,8 @@ const LOCALES = { kanban_status_archived: 'Archived', tab_todos: 'Todos', tab_insights: '통계', + tab_dashboard: 'Hermes 대시보드', + dashboard_loopback_warning: '대시보드는 서버에서 loopback 전용입니다. 서버 자체에서 접속하거나 --host 0.0.0.0(안전하지 않음)으로 다시 시작하세요.', tab_settings: '설정', new_conversation: '새 대화', filter_conversations: '대화 필터…', diff --git a/static/index.html b/static/index.html index 8c217a4e..ebef7368 100644 --- a/static/index.html +++ b/static/index.html @@ -90,6 +90,7 @@ +
@@ -105,6 +106,7 @@ + @@ -1016,6 +1018,18 @@ +
+ +
Show a nav-rail link when the official hermes dashboard is reachable. Overrides are restricted to loopback URLs.
+ + + +
+
diff --git a/static/panels.js b/static/panels.js index d3c45a1d..d92ed2bf 100644 --- a/static/panels.js +++ b/static/panels.js @@ -4388,6 +4388,7 @@ async function loadSettingsPanel(){ if(disableBtn) disableBtn.style.display='none'; } _syncHermesPanelSessionActions(); + if(typeof loadDashboardSettings==='function') loadDashboardSettings(); loadProvidersPanel(); // load provider cards in background loadPluginsPanel(); // load plugin/hook visibility in background switchSettingsSection(_settingsSection); diff --git a/static/style.css b/static/style.css index 0233c165..8cd53bd8 100644 --- a/static/style.css +++ b/static/style.css @@ -627,6 +627,11 @@ .rail .nav-tab{flex:0 0 auto;padding:0;font-size:inherit;border-bottom:none;overflow:visible;} .rail .nav-tab:hover::after{content:none;} .rail .nav-tab.active::before{content:'';position:absolute;left:-6px;top:50%;bottom:auto;transform:translateY(-50%);width:3px;height:16px;background:var(--accent);border-radius:0 2px 2px 0;} + .dashboard-link{position:relative;} + .dashboard-link-visible{display:flex!important;} + .dashboard-external-badge{position:absolute;right:5px;top:5px;width:9px;height:9px;border-radius:2px;border:1px solid var(--accent);background:var(--sidebar);box-shadow:0 0 0 2px var(--sidebar);opacity:.95;} + .dashboard-external-badge::after{content:'';position:absolute;right:-2px;top:-2px;width:5px;height:5px;border-top:1.5px solid var(--accent);border-right:1.5px solid var(--accent);} + .sidebar-nav .dashboard-external-badge{right:8px;top:7px;width:8px;height:8px;} @media(min-width:641px){.rail{display:flex;}.sidebar > .sidebar-nav{display:none;}} /* Sidebar navigation tabs */ .sidebar-nav{display:flex;border-bottom:1px solid var(--border);flex-shrink:0;padding:6px 8px 0;gap:2px;} diff --git a/static/ui.js b/static/ui.js index 319ec34f..3d7a7fcd 100644 --- a/static/ui.js +++ b/static/ui.js @@ -163,6 +163,96 @@ function _showEarlierRenderedMessages(){ _scrollPinned=false; } +const DASHBOARD_STATUS_TTL_MS=60000; +let _dashboardStatusCache=null; +let _dashboardStatusFetchedAt=0; + +function _dashboardIsBrowserLoopback(){ + const host=(window.location.hostname||'').replace(/^\[|\]$/g,'').toLowerCase(); + return host==='127.0.0.1'||host==='localhost'||host==='::1'; +} +function _dashboardBrowserUrl(status){ + if(!status||!status.running||!status.port) return ''; + let source; + try{source=new URL(status.url||('http://127.0.0.1:'+status.port));} + catch(_){source=new URL('http://127.0.0.1:'+status.port);} + const browserHost=window.location.hostname||source.hostname; + const displayHost=browserHost.includes(':')&&!browserHost.startsWith('[')?'['+browserHost+']':browserHost; + return source.protocol+'//'+displayHost+':'+status.port; +} +function _applyDashboardStatus(status){ + const running=!!(status&&status.running); + const url=running?_dashboardBrowserUrl(status):''; + const warning=running&&!_dashboardIsBrowserLoopback()?t('dashboard_loopback_warning'):''; + document.querySelectorAll('[data-dashboard-link]').forEach(btn=>{ + btn.classList.toggle('dashboard-link-visible',running); + btn.style.display=running?'':'none'; + btn.dataset.dashboardUrl=url; + btn.title=warning||t('tab_dashboard'); + btn.setAttribute('aria-label',warning||t('tab_dashboard')); + }); +} +async function refreshDashboardStatus(force=false){ + const now=Date.now(); + if(!force&&_dashboardStatusCache&&(now-_dashboardStatusFetchedAt)', INDEX_HTML, re.DOTALL).group(0) + assert rail.index('data-panel="insights"') < rail.index('id="dashboardRailBtn"') < rail.index('rail-spacer') + + +def test_dashboard_frontend_fetches_status_with_sixty_second_cache(): + assert "DASHBOARD_STATUS_TTL_MS=60000" in UI_JS + assert "function refreshDashboardStatus" in UI_JS + assert "api('/api/dashboard/status')" in UI_JS + assert "setInterval(refreshDashboardStatus,DASHBOARD_STATUS_TTL_MS)" in UI_JS + assert 'fetch("/api/dashboard/status"' not in UI_JS + assert "fetch('/api/dashboard/status'" not in UI_JS + + +def test_dashboard_probe_initializes_after_shared_api_helper_is_loaded(): + assert "function _initDashboardLinkProbe" in UI_JS + assert "document.addEventListener('DOMContentLoaded',_initDashboardLinkProbe,{once:true})" in UI_JS + assert "else _initDashboardLinkProbe();" not in UI_JS + + +def test_dashboard_frontend_opens_external_tab_safely_and_derives_browser_host_url(): + assert "function openHermesDashboard" in UI_JS + assert "window.open" in UI_JS + assert "noopener,noreferrer" in UI_JS + assert "window.location.hostname" in UI_JS + assert "_dashboardBrowserUrl" in UI_JS + assert 'id="dashboardRailBtn"' in INDEX_HTML + assert re.search(r'id="dashboardRailBtn"[^>]*onclick="openHermesDashboard\(event\)"', INDEX_HTML) + + +def test_dashboard_loopback_warning_and_external_badge_are_present(): + assert "dashboard_loopback_warning" in UI_JS + assert "dashboard-external-badge" in INDEX_HTML + assert ".dashboard-external-badge" in STYLE_CSS + assert "dashboard-link-visible" in STYLE_CSS + + +def test_dashboard_settings_controls_live_in_system_panel(): + assert 'id="settingsDashboardMode"' in INDEX_HTML + assert 'id="settingsDashboardUrl"' in INDEX_HTML + assert "function saveDashboardSettings" in UI_JS + assert "api('/api/dashboard/config'" in UI_JS diff --git a/tests/test_dashboard_probe.py b/tests/test_dashboard_probe.py new file mode 100644 index 00000000..d65b3f26 --- /dev/null +++ b/tests/test_dashboard_probe.py @@ -0,0 +1,206 @@ +import json +from urllib.parse import urlparse + + +class _FakeHandler: + def __init__(self): + self.status = None + self.sent_headers = [] + self.body = bytearray() + self.wfile = self + + def send_response(self, status): + self.status = status + + def send_header(self, name, value): + self.sent_headers.append((name, value)) + + def end_headers(self): + pass + + def write(self, data): + self.body.extend(data) + + def json_body(self): + return json.loads(bytes(self.body).decode("utf-8")) + + +class _FakeResponse: + def __init__(self, payload, status=200): + self.status = status + self._payload = payload + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc, tb): + return False + + def read(self): + return json.dumps(self._payload).encode("utf-8") + + +def test_probe_uses_official_dashboard_status_fingerprint(monkeypatch): + calls = [] + + def fake_urlopen(request, timeout): + calls.append((request.full_url, timeout)) + return _FakeResponse({"version": "0.12.0", "release_date": "2026-05-01", "hermes_home": "/tmp/hermes"}) + + from api import dashboard_probe + + monkeypatch.setattr(dashboard_probe.urllib.request, "urlopen", fake_urlopen) + result = dashboard_probe.probe_official_dashboard("127.0.0.1", 9119, timeout=0.25) + + assert result["running"] is True + assert result["host"] == "127.0.0.1" + assert result["port"] == 9119 + assert result["url"] == "http://127.0.0.1:9119" + assert result["version"] == "0.12.0" + assert calls == [("http://127.0.0.1:9119/api/status", 0.25)] + + +def test_probe_rejects_non_dashboard_json(monkeypatch): + def fake_urlopen(request, timeout): + return _FakeResponse({"version": "1.2.3"}) + + from api import dashboard_probe + + monkeypatch.setattr(dashboard_probe.urllib.request, "urlopen", fake_urlopen) + result = dashboard_probe.probe_official_dashboard("localhost", 9119, timeout=0.25) + + assert result == {"running": False} + + +def test_probe_failure_and_timeout_are_safe_false(monkeypatch): + def fake_urlopen(request, timeout): + raise TimeoutError("slow dashboard") + + from api import dashboard_probe + + monkeypatch.setattr(dashboard_probe.urllib.request, "urlopen", fake_urlopen) + result = dashboard_probe.probe_official_dashboard("127.0.0.1", 9119, timeout=0.01) + + assert result == {"running": False} + + +def test_dashboard_target_validation_allows_only_loopback_base_urls(): + from api.dashboard_probe import normalize_dashboard_url + + assert normalize_dashboard_url("") is None + assert normalize_dashboard_url("http://127.0.0.1:9120") == ("127.0.0.1", 9120, "http", "http://127.0.0.1:9120") + assert normalize_dashboard_url("https://localhost:9443") == ("localhost", 9443, "https", "https://localhost:9443") + assert normalize_dashboard_url("http://[::1]:9119") == ("::1", 9119, "http", "http://[::1]:9119") + + for bad in ( + "http://example.com:9119", + "http://169.254.169.254:80", + "http://127.0.0.1:9119/api/status", + "http://user:***@127.0.0.1:9119", + "file:///etc/passwd", + "http://127.0.0.1:99999", + ): + try: + normalize_dashboard_url(bad) + except ValueError: + pass + else: + raise AssertionError(f"unsafe dashboard override accepted: {bad}") + + +def test_status_tries_default_loopback_targets_until_dashboard_found(monkeypatch): + from api import dashboard_probe + + attempts = [] + + def fake_probe(host, port, timeout=0.5, scheme="http"): + attempts.append((host, port, timeout, scheme)) + if host == "localhost": + return {"running": True, "host": host, "port": port, "url": "http://localhost:9119", "version": "0.12.0"} + return {"running": False} + + monkeypatch.setattr(dashboard_probe, "probe_official_dashboard", fake_probe) + result = dashboard_probe.get_dashboard_status(config_data={}) + + assert result["running"] is True + assert result["host"] == "localhost" + assert attempts == [("127.0.0.1", 9119, 0.5, "http"), ("localhost", 9119, 0.5, "http")] + + +def test_status_honors_never_and_strict_override(monkeypatch): + from api import dashboard_probe + + def fail_probe(*args, **kwargs): + raise AssertionError("disabled dashboard must not probe") + + monkeypatch.setattr(dashboard_probe, "probe_official_dashboard", fail_probe) + assert dashboard_probe.get_dashboard_status(config_data={"webui": {"dashboard": {"enabled": "never"}}}) == { + "running": False, + "enabled": "never", + } + + result = dashboard_probe.get_dashboard_status(config_data={"webui": {"dashboard": {"url": "http://example.com:9119"}}}) + assert result["running"] is False + assert "invalid" in result["error"] + + + + +def test_status_skips_auto_probe_when_webui_bind_host_is_non_loopback(monkeypatch): + from api import dashboard_probe + + def fail_probe(*args, **kwargs): + raise AssertionError("auto mode must not probe dashboard when WebUI binds non-loopback") + + monkeypatch.setenv("HERMES_WEBUI_HOST", "0.0.0.0") + monkeypatch.setattr(dashboard_probe, "probe_official_dashboard", fail_probe) + + result = dashboard_probe.get_dashboard_status(config_data={}) + + assert result == {"running": False, "enabled": "auto"} + + +def test_dashboard_status_route_returns_safe_payload(monkeypatch): + from api import dashboard_probe + from api.routes import handle_get + + monkeypatch.setattr( + dashboard_probe, + "get_dashboard_status", + lambda: {"running": True, "host": "127.0.0.1", "port": 9119, "url": "http://127.0.0.1:9119", "version": "0.12.0"}, + ) + + handler = _FakeHandler() + parsed = urlparse("http://example.com/api/dashboard/status") + handled = handle_get(handler, parsed) + + assert handled is True + assert handler.status == 200 + assert handler.json_body() == { + "running": True, + "host": "127.0.0.1", + "port": 9119, + "url": "http://127.0.0.1:9119", + "version": "0.12.0", + } + + +def test_dashboard_config_roundtrip_writes_profile_config_yaml(tmp_path, monkeypatch): + monkeypatch.setenv("HERMES_CONFIG_PATH", str(tmp_path / "config.yaml")) + + from api.dashboard_probe import get_dashboard_config, save_dashboard_config + + assert get_dashboard_config() == {"enabled": "auto", "url": ""} + saved = save_dashboard_config({"enabled": "never", "url": ""}) + assert saved == {"enabled": "never", "url": ""} + + saved = save_dashboard_config({"enabled": "auto", "url": "http://127.0.0.1:19119"}) + assert saved == {"enabled": "auto", "url": "http://127.0.0.1:19119"} + assert "dashboard:" in (tmp_path / "config.yaml").read_text(encoding="utf-8") + + try: + save_dashboard_config({"enabled": "auto", "url": "http://example.com:9119"}) + except ValueError: + pass + else: + raise AssertionError("external dashboard URL override must be rejected")