From 739c948e74ad81d466f8a5e1288cc5e75ce8510e Mon Sep 17 00:00:00 2001 From: AJV20 <24819659+AJV20@users.noreply.github.com> Date: Mon, 18 May 2026 10:46:15 -0400 Subject: [PATCH 1/2] fix(system): allow browser-only dashboard links --- CHANGELOG.md | 3 ++ api/dashboard_probe.py | 57 ++++++++++++++++++++++++++++----- static/index.html | 2 +- static/ui.js | 11 +++++-- tests/test_dashboard_link_ui.py | 9 ++++++ tests/test_dashboard_probe.py | 31 +++++++++++------- 6 files changed, 90 insertions(+), 23 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 20fd6277..1f11f60f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,9 @@ - **PR #2605** by @LumenYoung (refs #2581) — Make the metadata-only `/api/session?messages=0&resolve_model=0` path return the persisted sidecar `message_count` from `Session._metadata_message_count` when no session-index entry exists, so the active-session external-refresh signal still trips on legacy sessions whose sidecar contains externally-appended content. Composed cleanly with #2604 (the legacy-fallback applies only when the reconciled merged count is zero). - **PR #2573** by @espokaos-ops (closes #2510) — Persist session-level approvals when a "Allow for this session" click lands while a stream is active and `_pending` is empty. The approval flow now peeks `_gateway_queues[sid]` to recover the queued `_ApprovalEntry`'s `pattern_keys` so `approve_session()` records the approval; the next dangerous command in the same session no longer asks again. Reduced scope to peek-only per prior review note; the `agent_session_key` round-trip plumbing was dropped (it was dead on the WebUI streaming path). + + +- Allow Settings → System to save public browser-only Official Hermes Dashboard links (for reverse-proxy URLs) without treating them as server-side probe targets. ### Added - **PR #2599** by @Michaelyklam (refs #1925) — Add the Slice 4b `RunnerRuntimeAdapter` facade — a protocol-translator client over a future runner/sidecar backend. The facade delegates `start_run`, `observe_run`, `get_run`, and control calls to an injected runner client, normalizes results into the existing `RunStartResult`/`RunEventStream`/`RunStatus`/`ControlResult` dataclasses, carries explicit `profile`/`workspace`/`model` payload fields, and returns bounded `unsupported` control results without owning `AIAgent`, stream lifecycle, cancel/approval/clarify queues, goal state, or cached-agent table. No route wiring, no default-on runner mode, no public response-shape change. diff --git a/api/dashboard_probe.py b/api/dashboard_probe.py index cc15ef91..95c27345 100644 --- a/api/dashboard_probe.py +++ b/api/dashboard_probe.py @@ -12,7 +12,7 @@ import json import logging import os import urllib.request -from urllib.parse import urlparse +from urllib.parse import urlparse, urlunparse logger = logging.getLogger(__name__) @@ -61,6 +61,41 @@ def normalize_dashboard_url(raw_url: str | None) -> tuple[str, int, str, str] | return normalized_host, port, parsed.scheme, base +def normalize_dashboard_browser_url(raw_url: str | None) -> str: + """Return a safe browser-only dashboard link URL. + + Unlike the server-side probe target, this value is only returned to the + browser for navigation. It may point at a public reverse-proxy hostname, but + it still rejects credentials, paths, query strings, fragments, and non-HTTP + schemes so it cannot hide secrets or script URLs in config. + """ + raw = str(raw_url or "").strip() + if not raw: + return "" + 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") + if not parsed.hostname: + raise ValueError("invalid dashboard URL host") + if parsed.params or parsed.query or parsed.fragment: + raise ValueError("invalid dashboard URL path") + path = parsed.path or "" + if path not in ("", "/"): + raise ValueError("invalid dashboard URL path") + try: + port = parsed.port + except ValueError as exc: + raise ValueError("invalid dashboard URL port") from exc + netloc = parsed.hostname.lower() + if port is not None: + if not (1 <= port <= 65535): + raise ValueError("invalid dashboard URL port") + netloc = f"{netloc}:{port}" + return urlunparse((parsed.scheme, netloc, "", "", "", "")) + + def _looks_like_official_dashboard(payload: object) -> bool: if not isinstance(payload, dict): return False @@ -132,8 +167,7 @@ def get_dashboard_config(config_data: dict | None = None) -> dict: 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) + raw_url = normalize_dashboard_browser_url(raw_url) return {"enabled": enabled, "url": raw_url} @@ -143,9 +177,7 @@ def save_dashboard_config(payload: dict) -> dict: 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) + normalized_url = normalize_dashboard_browser_url(raw_url) if raw_url else "" from api import config as webui_config @@ -186,9 +218,13 @@ def get_dashboard_status(config_data: dict | None = None) -> dict: raw_url = dashboard_cfg.get("url") or dashboard_cfg.get("target") or "" try: - override = normalize_dashboard_url(raw_url) + browser_url = normalize_dashboard_browser_url(raw_url) if raw_url else "" except ValueError: return {"running": False, "enabled": enabled, "error": "invalid dashboard url"} + try: + override = normalize_dashboard_url(raw_url) + except ValueError: + override = None targets: list[tuple[str, int, str, str]] if override: @@ -197,8 +233,10 @@ def get_dashboard_status(config_data: dict | None = None) -> dict: targets = [(host, port, "http", _base_url(host, port)) for host, port in DEFAULT_DASHBOARD_TARGETS] if enabled == "always": + if browser_url and not override: + return {"running": True, "enabled": enabled, "url": browser_url, "browser_url": browser_url} host, port, scheme, base = targets[0] - return {"running": True, "enabled": enabled, "host": host, "port": port, "url": base} + return {"running": True, "enabled": enabled, "host": host, "port": port, "url": browser_url or base, "browser_url": browser_url or base} if not _webui_bind_host_allows_auto_probe(): return {"running": False, "enabled": enabled} @@ -207,5 +245,8 @@ def get_dashboard_status(config_data: dict | None = None) -> dict: result = probe_official_dashboard(host, port, timeout=DEFAULT_DASHBOARD_TIMEOUT, scheme=scheme) if result.get("running"): result["enabled"] = enabled + if browser_url: + result["browser_url"] = browser_url + result["url"] = browser_url return result return {"running": False, "enabled": enabled} diff --git a/static/index.html b/static/index.html index 5f1cca5a..d6906e5f 100644 --- a/static/index.html +++ b/static/index.html @@ -1222,7 +1222,7 @@
-
Show a nav-rail link when the official hermes dashboard is reachable. Overrides are restricted to loopback URLs.
+
Show a nav-rail link when the official hermes dashboard is reachable. Public reverse-proxy URLs are stored as browser-only links and are never server-probed.