mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-08 13:12:08 +00:00
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:
parent
5633fa19b8
commit
8d66e78844
2 changed files with 58 additions and 31 deletions
|
|
@ -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"],
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue