diff --git a/contributors/emails/Agi-Asi@users.noreply.github.com b/contributors/emails/Agi-Asi@users.noreply.github.com new file mode 100644 index 0000000000..12ceeca9dd --- /dev/null +++ b/contributors/emails/Agi-Asi@users.noreply.github.com @@ -0,0 +1 @@ +Agi-Asi diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index 050e0f3815..52a8f05b0d 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -836,6 +836,43 @@ def should_require_dashboard_auth( ) +def _desktop_loopback_auth_exempt( + host: str, + ssh_session_token: Optional[str] = None, + ssh_owner_nonce: Optional[str] = None, +) -> bool: + """True for a Desktop-owned loopback backend (#96490). + + A non-loopback ``dashboard.public_url`` engages the ticket-only auth gate + for EVERY ``hermes serve`` on the machine — including the private loopback + backends the Desktop app spawns for itself. Those backends authenticate + with the per-spawn session token (injected via + ``HERMES_DASHBOARD_SESSION_TOKEN`` for local spawns, ``--ssh-session-token + -file``/``--ssh-owner-nonce`` for Desktop SSH), which the gate's WS path + refuses outright — Desktop could not boot with a ``public_url`` configured. + + The public_url describes a DIFFERENT deployment: the actual public + dashboard is a separate process on a non-loopback bind, whose own startup + computes ``should_require_dashboard_auth`` from its host and stays gated. + Exempting this process therefore never opens the public surface. + + Exemption requires ALL of: loopback bind, ``HERMES_DESKTOP=1`` (set by + every Desktop spawn path — local and SSH), and an operator-minted + credential (env token, SSH session token, or owner nonce). A plain + ``hermes serve`` with ``HERMES_DESKTOP=1`` exported but no credential is + NOT exempt. + """ + if host not in _LOOPBACK_HOST_VALUES: + return False + if os.environ.get("HERMES_DESKTOP") != "1": + return False + return bool( + os.environ.get("HERMES_DASHBOARD_SESSION_TOKEN") + or ssh_session_token + or ssh_owner_nonce + ) + + def _host_header_hostname(host_header: str) -> str: """Return a normalized hostname from a valid HTTP Host authority. @@ -19566,9 +19603,22 @@ def start_server( # Stash the auth-gate flag on app.state so middleware / SPA-token injection / # WS-auth paths can branch on it consistently. It also decides whether to # refuse startup, log the gate-on banner, and enable uvicorn proxy_headers. - app.state.auth_required = should_require_dashboard_auth( - host, app.state.trusted_public_hosts - ) + if _desktop_loopback_auth_exempt(host, ssh_session_token, ssh_owner_nonce): + # A configured dashboard.public_url describes the operator's PUBLIC + # deployment, not this private Desktop-owned loopback backend (#96490). + # Desktop authenticates with the per-spawn session token; forcing the + # ticket-only gate here broke every Desktop boot while the actual + # public dashboard — a separate non-loopback process — stayed gated. + app.state.auth_required = should_require_auth(host) + _log.info( + "Desktop-owned loopback backend: dashboard.public_url does not " + "engage the ticket gate for this process; the public deployment " + "keeps its own gate.", + ) + else: + app.state.auth_required = should_require_dashboard_auth( + host, app.state.trusted_public_hosts + ) # ``--insecure`` no longer disables the auth gate (June 2026 hardening: # the hermes-0day MCP-persistence campaign abused unauthenticated public diff --git a/tests/hermes_cli/test_web_server.py b/tests/hermes_cli/test_web_server.py index a4a135dbe2..e00fbd4031 100644 --- a/tests/hermes_cli/test_web_server.py +++ b/tests/hermes_cli/test_web_server.py @@ -3044,6 +3044,69 @@ class TestNewEndpoints: mock_generate.assert_not_called() assert any(tool["tool"] == "read_file" for tool in resp.json()["tools"]) +# --------------------------------------------------------------------------- +# Desktop-owned loopback backends are not gated by dashboard.public_url (#96490) +# --------------------------------------------------------------------------- + + +class TestDesktopLoopbackAuthExemption: + """``_desktop_loopback_auth_exempt`` decides the #96490 exemption.""" + + def test_exempt_with_desktop_env_and_session_token_on_loopback(self, monkeypatch): + import hermes_cli.web_server as web_server + + monkeypatch.setenv("HERMES_DESKTOP", "1") + monkeypatch.setenv("HERMES_DASHBOARD_SESSION_TOKEN", "desktop-minted") + assert web_server._desktop_loopback_auth_exempt("127.0.0.1") is True + assert web_server._desktop_loopback_auth_exempt("::1") is True + + def test_exempt_via_ssh_spawn_credentials_without_env_token(self, monkeypatch): + import hermes_cli.web_server as web_server + + monkeypatch.setenv("HERMES_DESKTOP", "1") + monkeypatch.delenv("HERMES_DASHBOARD_SESSION_TOKEN", raising=False) + assert web_server._desktop_loopback_auth_exempt( + "127.0.0.1", ssh_session_token="tok" + ) + assert web_server._desktop_loopback_auth_exempt( + "127.0.0.1", ssh_owner_nonce="nonce" + ) + + def test_not_exempt_without_desktop_env(self, monkeypatch): + import hermes_cli.web_server as web_server + + monkeypatch.delenv("HERMES_DESKTOP", raising=False) + monkeypatch.setenv("HERMES_DASHBOARD_SESSION_TOKEN", "tok") + assert web_server._desktop_loopback_auth_exempt("127.0.0.1") is False + + def test_not_exempt_without_any_credential(self, monkeypatch): + import hermes_cli.web_server as web_server + + # HERMES_DESKTOP=1 alone is not enough: a plain serve with the env var + # exported must stay gated. + monkeypatch.setenv("HERMES_DESKTOP", "1") + monkeypatch.delenv("HERMES_DASHBOARD_SESSION_TOKEN", raising=False) + assert web_server._desktop_loopback_auth_exempt("127.0.0.1") is False + + def test_not_exempt_on_non_loopback_bind(self, monkeypatch): + import hermes_cli.web_server as web_server + + monkeypatch.setenv("HERMES_DESKTOP", "1") + monkeypatch.setenv("HERMES_DASHBOARD_SESSION_TOKEN", "tok") + assert web_server._desktop_loopback_auth_exempt("0.0.0.0") is False + assert web_server._desktop_loopback_auth_exempt("192.168.1.10") is False + + def test_public_url_engages_gate_for_non_desktop_loopback(self, monkeypatch): + import hermes_cli.web_server as web_server + + # Sanity: the base behaviour is untouched — a non-Desktop loopback + # serve with a public_url configured stays ticket-gated. + monkeypatch.delenv("HERMES_DESKTOP", raising=False) + assert web_server.should_require_dashboard_auth( + "127.0.0.1", frozenset({"dash.example.com"}) + ) is True + + # --------------------------------------------------------------------------- # Model context length: normalize/denormalize + /api/model/info # ---------------------------------------------------------------------------