feat(dashboard): expose profile names + gateway_mode on gated /api/status (#60585)

The profile+gateway topology added in #60537 sits entirely behind the
loopback/--insecure auth gate. But a hosted agent (Hermes Cloud) binds
non-loopback with OAuth, so should_require_auth is True, and NAS reads
/api/status over the network (fly-provider.ts getInstanceRuntimeStatus)
with no session token. On that gated path the whole topology block was
omitted, so the Portal could never render the profile list.

Split the topology readout by sensitivity:
- profile NAMES (profiles) + gateway_mode are low-sensitivity product
  surface and now ride the always-public status body, surviving the auth
  gate so NAS/the Portal can enumerate profiles.
- the per-gateway detail (gateways[], carrying host ports) is deployment
  recon and stays gated alongside hermes_home / config_path / env_path /
  gateway_pid / gateway_health_url.

The collector now runs unconditionally (still in the executor, off the
event loop). No new fields; only the gate placement changes.
This commit is contained in:
Ben Barclay 2026-07-08 10:20:13 +10:00 committed by GitHub
parent 5633fa19b8
commit 8d66e78844
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 58 additions and 31 deletions

View file

@ -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"],
})

View file

@ -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
)