diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index 3a854033195..74f97ec9c2c 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -2575,33 +2575,42 @@ async def get_status(profile: Optional[str] = None): "nous_session_valid": nous_session_valid, } - # Absolute host paths, the gateway PID, and the internal gateway health - # URL are deployment recon a liveness probe never needs. ``/api/status`` - # is in ``PUBLIC_API_PATHS`` so it bypasses dashboard auth; on a - # network-exposed (gated) bind that means *any* unauthenticated caller - # reaches it, and leaking host metadata there contradicts the allowlist's - # own contract ("version, gateway state, active session count, and the - # dashboard auth-gate shape. No bodies, no session content, no secrets"). - # Surface this detail only on a loopback / ``--insecure`` bind, where the - # dashboard is local-only and the caller is already inside the trust - # envelope — the same loopback/gated split ``should_require_auth`` draws. + # Profile + gateway topology: which profiles exist, whether one + # multiplexed gateway or several per-profile gateways serve them, and + # (gated) which host ports the live gateways' port-binding platforms + # listen on. Enumerating profiles walks the filesystem and probes the + # process table, so keep it off the event loop. + # + # Split by sensitivity: profile NAMES (``profiles``) and the gateway + # ``gateway_mode`` are low-sensitivity PRODUCT surface — Hermes Cloud + # renders the profile list in the Portal, which reads this endpoint over + # the network (a gated bind), so they must survive the auth gate. The + # per-gateway ``gateways[]`` detail carries host ports (deployment + # recon), so it stays gated with the host paths / PID below. + topology = await asyncio.get_running_loop().run_in_executor( + None, _collect_profile_gateway_topology + ) + status["profiles"] = topology["profiles"] + status["gateway_mode"] = topology["gateway_mode"] + + # Absolute host paths, the gateway PID, the internal gateway health + # URL, and per-gateway ports are deployment recon a liveness probe never + # needs. ``/api/status`` is in ``PUBLIC_API_PATHS`` so it bypasses + # dashboard auth; on a network-exposed (gated) bind that means *any* + # unauthenticated caller reaches it, and leaking host metadata there + # contradicts the allowlist's own contract ("version, gateway state, + # active session count, and the dashboard auth-gate shape. No bodies, no + # session content, no secrets"). Surface this detail only on a loopback + # / ``--insecure`` bind, where the dashboard is local-only and the + # caller is already inside the trust envelope — the same loopback/gated + # split ``should_require_auth`` draws. if not auth_required: - # Profile + gateway topology: which profiles exist, whether one - # multiplexed gateway or several per-profile gateways serve them, - # and which host ports the live gateways' port-binding platforms - # listen on. Enumerating profiles walks the filesystem and probes - # the process table, so keep it off the event loop. - topology = await asyncio.get_running_loop().run_in_executor( - None, _collect_profile_gateway_topology - ) status.update({ "hermes_home": str(get_hermes_home()), "config_path": str(get_config_path()), "env_path": str(get_env_path()), "gateway_pid": gateway_pid, "gateway_health_url": _GATEWAY_HEALTH_URL, - "profiles": topology["profiles"], - "gateway_mode": topology["gateway_mode"], "gateways": topology["gateways"], }) diff --git a/tests/hermes_cli/test_web_server_gateway_topology.py b/tests/hermes_cli/test_web_server_gateway_topology.py index 18de4aac72a..457f6a16d89 100644 --- a/tests/hermes_cli/test_web_server_gateway_topology.py +++ b/tests/hermes_cli/test_web_server_gateway_topology.py @@ -191,7 +191,7 @@ class TestStatusEndpointTopology: self.client = TestClient(app) self.client.headers[_SESSION_HEADER_NAME] = _SESSION_TOKEN - def test_status_includes_topology_on_loopback(self, monkeypatch): + def test_status_includes_full_topology_on_loopback(self, monkeypatch): monkeypatch.setattr( web_server, "_collect_profile_gateway_topology", lambda: { @@ -205,16 +205,34 @@ class TestStatusEndpointTopology: data = resp.json() assert data["profiles"] == ["default", "coder"] assert data["gateway_mode"] == "single" + # The per-gateway detail (host ports) is loopback-only recon. assert data["gateways"] == [{"profile": "default", "ports": {}}] - def test_status_omits_topology_when_auth_gated(self, monkeypatch): + def test_profile_names_and_mode_public_when_auth_gated(self, monkeypatch): + # Profile NAMES + gateway_mode are low-sensitivity product surface: the + # Hermes Cloud Portal reads /api/status over the network (a gated bind) + # to render the profile list, so they must survive the auth gate. + monkeypatch.setattr( + web_server, "_collect_profile_gateway_topology", + lambda: { + "profiles": ["default", "coder"], + "gateway_mode": "multiplex", + "gateways": [{"profile": "default", "ports": {"webhook": 8644}}], + }, + ) monkeypatch.setattr(web_server.app.state, "auth_required", True, raising=False) - resp = self.client.get("/api/status") - assert resp.status_code == 200 - data = resp.json() - # Topology is deployment recon — hidden on gated binds, like - # hermes_home / gateway_pid. - assert "profiles" not in data - assert "gateway_mode" not in data - assert "gateways" not in data - monkeypatch.setattr(web_server.app.state, "auth_required", False, raising=False) + try: + resp = self.client.get("/api/status") + assert resp.status_code == 200 + data = resp.json() + assert data["profiles"] == ["default", "coder"] + assert data["gateway_mode"] == "multiplex" + # But the per-gateway detail (host ports = recon) stays gated, + # alongside hermes_home / gateway_pid. + assert "gateways" not in data + assert "hermes_home" not in data + assert "gateway_pid" not in data + finally: + monkeypatch.setattr( + web_server.app.state, "auth_required", False, raising=False + )