Fix dashboard gateway profile scoping

This commit is contained in:
Shannon Sands 2026-06-17 10:43:57 +10:00 committed by Teknium
parent f80381c456
commit 674e8b098a
7 changed files with 525 additions and 173 deletions

View File

@ -555,6 +555,41 @@ def read_runtime_status() -> Optional[dict[str, Any]]:
return _read_json_file(_get_runtime_status_path())
def get_runtime_status_running_pid(
runtime: Optional[dict[str, Any]] = None,
) -> Optional[int]:
"""Return a live gateway PID from the runtime status record, if valid.
``get_running_pid()`` is the primary liveness source because it verifies the
runtime lock and PID file. Launch-service managers can still leave us with
a live process and a fresh ``gateway_state.json`` but no ``gateway.pid``; use
this as a conservative fallback by checking both the persisted state and the
OS process identity.
"""
payload = runtime if runtime is not None else read_runtime_status()
if not isinstance(payload, dict):
return None
if payload.get("gateway_state") in {None, "stopped", "startup_failed"}:
return None
pid = _pid_from_record(payload)
if pid is None or not _pid_exists(pid):
return None
recorded_start = payload.get("start_time")
current_start = _get_process_start_time(pid)
if (
recorded_start is not None
and current_start is not None
and current_start != recorded_start
):
return None
if _looks_like_gateway_process(pid) or _record_looks_like_gateway(payload):
return pid
return None
def remove_pid_file() -> None:
"""Remove the gateway PID file, but only if it belongs to this process.

View File

@ -62,7 +62,11 @@ from hermes_cli.config import (
recommended_update_command_for_method,
redact_key,
)
from gateway.status import get_running_pid, read_runtime_status
from gateway.status import (
get_running_pid,
get_runtime_status_running_pid,
read_runtime_status,
)
from utils import env_var_enabled
try:
@ -678,6 +682,7 @@ class TelegramOnboardingStart(BaseModel):
class TelegramOnboardingApply(BaseModel):
allowed_user_ids: List[str]
profile: Optional[str] = None
class AudioTranscriptionRequest(BaseModel):
@ -1609,145 +1614,161 @@ async def fs_default_cwd():
@app.get("/api/status")
async def get_status():
current_ver, latest_ver = check_config_version()
async def get_status(profile: Optional[str] = None):
status_scope = None
requested_profile = (profile or "").strip()
# Plain /api/status stays the machine-level public liveness probe. The
# dashboard adds ?profile= when its management switcher targets another
# profile, so its gateway badge reflects the selected profile.
if requested_profile and requested_profile.lower() != "current":
status_scope = _profile_scope(requested_profile)
status_scope.__enter__()
# --- Gateway liveness detection ---
# Try local PID check first (same-host). If that fails and a remote
# GATEWAY_HEALTH_URL is configured, probe the gateway over HTTP so the
# dashboard works when the gateway runs in a separate container.
gateway_pid = get_running_pid()
gateway_running = gateway_pid is not None
remote_health_body: dict | None = None
if not gateway_running and _GATEWAY_HEALTH_URL:
loop = asyncio.get_running_loop()
alive, remote_health_body = await loop.run_in_executor(
None, _probe_gateway_health
)
if alive:
gateway_running = True
# PID from the remote container (display only — not locally valid)
if remote_health_body:
gateway_pid = remote_health_body.get("pid")
gateway_state = None
gateway_platforms: dict = {}
gateway_exit_reason = None
gateway_updated_at = None
configured_gateway_platforms: set[str] | None = None
try:
from gateway.config import load_gateway_config
current_ver, latest_ver = check_config_version()
# --- Gateway liveness detection ---
# Try local PID check first (same-host). If that fails and a remote
# GATEWAY_HEALTH_URL is configured, probe the gateway over HTTP so the
# dashboard works when the gateway runs in a separate container.
gateway_pid = get_running_pid()
gateway_running = gateway_pid is not None
remote_health_body: dict | None = None
gateway_config = load_gateway_config()
configured_gateway_platforms = {
platform.value for platform in gateway_config.get_connected_platforms()
}
except Exception:
configured_gateway_platforms = None
# Prefer the detailed health endpoint response (has full state) when the
# local runtime status file is absent or stale (cross-container).
runtime = read_runtime_status()
if runtime is None and remote_health_body and remote_health_body.get("gateway_state"):
runtime = remote_health_body
if runtime:
gateway_state = runtime.get("gateway_state")
gateway_platforms = runtime.get("platforms") or {}
if configured_gateway_platforms is not None:
gateway_platforms = {
key: value
for key, value in gateway_platforms.items()
if key in configured_gateway_platforms
}
gateway_exit_reason = runtime.get("exit_reason")
gateway_updated_at = runtime.get("updated_at")
if not gateway_running:
gateway_state = gateway_state if gateway_state in {"stopped", "startup_failed"} else "stopped"
gateway_platforms = {}
elif gateway_running and remote_health_body is not None:
# The health probe confirmed the gateway is alive, but the local
# runtime status file may be stale (cross-container). Override
# stopped/None state so the dashboard shows the correct badge.
if gateway_state in {None, "stopped"}:
gateway_state = "running"
# If there was no runtime info at all but the health probe confirmed alive,
# ensure we still report the gateway as running (no shared volume scenario).
if gateway_running and gateway_state is None and remote_health_body is not None:
gateway_state = "running"
active_sessions = 0
try:
from hermes_state import SessionDB
db = SessionDB()
try:
sessions = db.list_sessions_rich(limit=50)
now = time.time()
active_sessions = sum(
1 for s in sessions
if s.get("ended_at") is None
and (now - s.get("last_active", s.get("started_at", 0))) < 300
if not gateway_running and _GATEWAY_HEALTH_URL:
loop = asyncio.get_running_loop()
alive, remote_health_body = await loop.run_in_executor(
None, _probe_gateway_health
)
finally:
db.close()
except Exception:
pass
if alive:
gateway_running = True
# PID from the remote container (display only — not locally valid)
if remote_health_body:
gateway_pid = remote_health_body.get("pid")
# Dashboard auth gate (Phase 7): surface whether the gate is engaged
# and which providers are registered so ``hermes status`` and the
# SPA's StatusPage can show "OAuth gate ON via Nous Research" or
# "loopback only — no auth gate" with no extra round trips.
auth_required = bool(getattr(app.state, "auth_required", False))
auth_providers: list[str] = []
try:
from hermes_cli.dashboard_auth import list_providers as _list_providers
auth_providers = [p.name for p in _list_providers()]
except Exception:
# Module not importable yet (early startup) — leave as [].
pass
gateway_state = None
gateway_platforms: dict = {}
gateway_exit_reason = None
gateway_updated_at = None
configured_gateway_platforms: set[str] | None = None
try:
from gateway.config import load_gateway_config
# Always-public liveness + auth-gate shape. Safe for external uptime
# probes (NAS's wildcard-subdomain liveness probe), the SPA's pre-login
# bootstrap, and anyone who can curl the host — i.e. exactly the audience
# ``PUBLIC_API_PATHS`` documents this endpoint as serving.
status = {
"version": __version__,
"release_date": __release_date__,
"config_version": current_ver,
"latest_config_version": latest_ver,
"can_update_hermes": not _dashboard_local_update_managed_externally(),
"gateway_running": gateway_running,
"gateway_state": gateway_state,
"gateway_platforms": gateway_platforms,
"gateway_exit_reason": gateway_exit_reason,
"gateway_updated_at": gateway_updated_at,
"active_sessions": active_sessions,
"auth_required": auth_required,
"auth_providers": auth_providers,
}
gateway_config = load_gateway_config()
configured_gateway_platforms = {
platform.value for platform in gateway_config.get_connected_platforms()
}
except Exception:
configured_gateway_platforms = None
# 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.
if not auth_required:
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,
})
# Prefer the detailed health endpoint response (has full state) when the
# local runtime status file is absent or stale (cross-container).
runtime = read_runtime_status()
if runtime is None and remote_health_body and remote_health_body.get("gateway_state"):
runtime = remote_health_body
runtime_pid = get_runtime_status_running_pid(runtime)
if not gateway_running and runtime_pid is not None:
gateway_running = True
gateway_pid = runtime_pid
return status
if runtime:
gateway_state = runtime.get("gateway_state")
gateway_platforms = runtime.get("platforms") or {}
if configured_gateway_platforms is not None:
gateway_platforms = {
key: value
for key, value in gateway_platforms.items()
if key in configured_gateway_platforms
}
gateway_exit_reason = runtime.get("exit_reason")
gateway_updated_at = runtime.get("updated_at")
if not gateway_running:
gateway_state = gateway_state if gateway_state in {"stopped", "startup_failed"} else "stopped"
gateway_platforms = {}
elif gateway_running and remote_health_body is not None:
# The health probe confirmed the gateway is alive, but the local
# runtime status file may be stale (cross-container). Override
# stopped/None state so the dashboard shows the correct badge.
if gateway_state in {None, "stopped"}:
gateway_state = "running"
# If there was no runtime info at all but the health probe confirmed alive,
# ensure we still report the gateway as running (no shared volume scenario).
if gateway_running and gateway_state is None and remote_health_body is not None:
gateway_state = "running"
active_sessions = 0
try:
from hermes_state import SessionDB
db = SessionDB()
try:
sessions = db.list_sessions_rich(limit=50)
now = time.time()
active_sessions = sum(
1 for s in sessions
if s.get("ended_at") is None
and (now - s.get("last_active", s.get("started_at", 0))) < 300
)
finally:
db.close()
except Exception:
pass
# Dashboard auth gate (Phase 7): surface whether the gate is engaged
# and which providers are registered so ``hermes status`` and the
# SPA's StatusPage can show "OAuth gate ON via Nous Research" or
# "loopback only — no auth gate" with no extra round trips.
auth_required = bool(getattr(app.state, "auth_required", False))
auth_providers: list[str] = []
try:
from hermes_cli.dashboard_auth import list_providers as _list_providers
auth_providers = [p.name for p in _list_providers()]
except Exception:
# Module not importable yet (early startup) — leave as [].
pass
# Always-public liveness + auth-gate shape. Safe for external uptime
# probes (NAS's wildcard-subdomain liveness probe), the SPA's pre-login
# bootstrap, and anyone who can curl the host — i.e. exactly the audience
# ``PUBLIC_API_PATHS`` documents this endpoint as serving.
status = {
"version": __version__,
"release_date": __release_date__,
"config_version": current_ver,
"latest_config_version": latest_ver,
"can_update_hermes": not _dashboard_local_update_managed_externally(),
"gateway_running": gateway_running,
"gateway_state": gateway_state,
"gateway_platforms": gateway_platforms,
"gateway_exit_reason": gateway_exit_reason,
"gateway_updated_at": gateway_updated_at,
"active_sessions": active_sessions,
"auth_required": auth_required,
"auth_providers": auth_providers,
}
# 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.
if not auth_required:
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,
})
return status
finally:
if status_scope is not None:
status_scope.__exit__(*sys.exc_info())
_WINDOWS_11_MIN_BUILD = 22000
@ -2095,6 +2116,7 @@ _ACTION_LOG_FILES: Dict[str, str] = {
# ``name`` → most recently spawned Popen handle. Used so ``status`` can
# report liveness and exit code without shelling out to ``ps``.
_ACTION_PROCS: Dict[str, subprocess.Popen] = {}
_ACTION_COMMANDS: Dict[str, Tuple[str, ...]] = {}
# ``name`` → completed synthetic action result for actions the server handled
# without spawning a subprocess (for example, unsupported Docker updates).
@ -2114,6 +2136,7 @@ def _record_completed_action(name: str, message: str, exit_code: int = 1) -> Non
if not message.endswith("\n"):
log_file.write(b"\n")
_ACTION_PROCS.pop(name, None)
_ACTION_COMMANDS.pop(name, None)
_ACTION_RESULTS[name] = {"exit_code": exit_code, "pid": None}
@ -2154,6 +2177,7 @@ def _spawn_hermes_action(subcommand: List[str], name: str) -> subprocess.Popen:
# fd per spawned action.
log_file.close()
_ACTION_RESULTS.pop(name, None)
_ACTION_COMMANDS[name] = tuple(subcommand)
_ACTION_PROCS[name] = proc
return proc
@ -2172,7 +2196,15 @@ def _tail_lines(path: Path, n: int) -> List[str]:
return lines[-n:] if n > 0 else lines
def _spawn_gateway_restart() -> Tuple[subprocess.Popen, bool]:
def _gateway_subcommand(profile: Optional[str], verb: str) -> List[str]:
return _profile_cli_args(profile) + ["gateway", verb]
def _gateway_display_command(profile: Optional[str], verb: str) -> str:
return " ".join(["hermes", *_gateway_subcommand(profile, verb)])
def _spawn_gateway_restart(profile: Optional[str] = None) -> Tuple[subprocess.Popen, bool]:
"""Spawn ``hermes gateway restart``, reusing an in-flight restart.
Multiple dashboard paths can request a restart in quick succession
@ -2183,16 +2215,20 @@ def _spawn_gateway_restart() -> Tuple[subprocess.Popen, bool]:
Returns ``(proc, reused)``.
"""
subcommand = _gateway_subcommand(profile, "restart")
existing = _ACTION_PROCS.get("gateway-restart")
if existing is not None and existing.poll() is None:
return existing, True
return _spawn_hermes_action(["gateway", "restart"], "gateway-restart"), False
existing_command = _ACTION_COMMANDS.get("gateway-restart")
if existing_command is None or existing_command == tuple(subcommand):
return existing, True
raise RuntimeError("gateway restart already in progress for another profile")
return _spawn_hermes_action(subcommand, "gateway-restart"), False
def _restart_gateway_after_webhook_enable() -> dict[str, Any]:
def _restart_gateway_after_webhook_enable(profile: Optional[str] = None) -> dict[str, Any]:
"""Best-effort gateway restart after enabling the webhook platform."""
try:
proc, reused = _spawn_gateway_restart()
proc, reused = _spawn_gateway_restart(profile)
except Exception as exc:
_log.exception("Failed to auto-restart gateway after enabling webhooks")
return {
@ -2212,10 +2248,12 @@ def _restart_gateway_after_webhook_enable() -> dict[str, Any]:
@app.post("/api/gateway/restart")
async def restart_gateway():
async def restart_gateway(profile: Optional[str] = None):
"""Kick off a ``hermes gateway restart`` in the background."""
try:
proc, _reused = _spawn_gateway_restart()
proc, _reused = _spawn_gateway_restart(profile)
except HTTPException:
raise
except Exception as exc:
_log.exception("Failed to spawn gateway restart")
raise HTTPException(status_code=500, detail=f"Failed to restart gateway: {exc}")
@ -2635,6 +2673,7 @@ async def get_action_status(name: str, lines: int = 200):
pass
_ACTION_RESULTS[name] = {"exit_code": exit_code, "pid": pid}
_ACTION_PROCS.pop(name, None)
_ACTION_COMMANDS.pop(name, None)
return {
"name": name,
@ -4346,13 +4385,16 @@ def _messaging_platform_payload(
scoped: bool = False,
) -> dict[str, Any]:
platform_id = entry["id"]
gateway_running = get_running_pid() is not None
runtime_platforms = runtime.get("platforms") if runtime else {}
runtime_platform = (
runtime_platforms.get(platform_id, {})
if isinstance(runtime_platforms, dict)
else {}
)
gateway_running = (
get_running_pid() is not None
or get_runtime_status_running_pid(runtime) is not None
)
env_vars = []
for key in entry["env_vars"]:
@ -4415,15 +4457,37 @@ def _messaging_platform_payload(
state = (
runtime_platform.get("state") if isinstance(runtime_platform, dict) else None
)
runtime_gateway_state = runtime.get("gateway_state") if isinstance(runtime, dict) else None
runtime_gateway_error = runtime.get("exit_reason") if isinstance(runtime, dict) else None
if not enabled:
state = "disabled"
elif not configured:
state = "not_configured"
elif gateway_running and not state:
state = "pending_restart"
elif (
not gateway_running
and not state
and runtime_gateway_state == "startup_failed"
):
state = "startup_failed"
elif not gateway_running and not state:
state = "gateway_stopped"
error_code = (
runtime_platform.get("error_code")
if isinstance(runtime_platform, dict)
else None
)
error_message = (
runtime_platform.get("error_message")
if isinstance(runtime_platform, dict)
else None
)
if state == "startup_failed":
error_code = error_code or "startup_failed"
error_message = error_message or runtime_gateway_error
return {
"id": platform_id,
"name": entry["name"],
@ -4433,16 +4497,8 @@ def _messaging_platform_payload(
"configured": configured,
"gateway_running": gateway_running,
"state": state,
"error_code": (
runtime_platform.get("error_code")
if isinstance(runtime_platform, dict)
else None
),
"error_message": (
runtime_platform.get("error_message")
if isinstance(runtime_platform, dict)
else None
),
"error_code": error_code,
"error_message": error_message,
"updated_at": (
runtime_platform.get("updated_at")
if isinstance(runtime_platform, dict)
@ -4732,7 +4788,7 @@ async def get_telegram_onboarding_status(pairing_id: str):
)
def _restart_gateway_after_telegram_onboarding() -> dict[str, Any]:
def _restart_gateway_after_telegram_onboarding(profile: Optional[str] = None) -> dict[str, Any]:
"""Best-effort gateway restart after saving Telegram QR onboarding.
The QR flow naturally pulls users into Telegram on another device. If the
@ -4741,7 +4797,7 @@ def _restart_gateway_after_telegram_onboarding() -> dict[str, Any]:
restart failures so the UI can fall back to the existing manual banner.
"""
try:
proc, reused = _spawn_gateway_restart()
proc, reused = _spawn_gateway_restart(profile)
except Exception as exc:
_log.exception("Failed to auto-restart gateway after Telegram onboarding")
return {
@ -4762,7 +4818,7 @@ def _restart_gateway_after_telegram_onboarding() -> dict[str, Any]:
@app.post("/api/messaging/telegram/onboarding/{pairing_id}/apply")
async def apply_telegram_onboarding(
pairing_id: str, body: TelegramOnboardingApply
pairing_id: str, body: TelegramOnboardingApply, profile: Optional[str] = None
):
allowed_user_ids = []
seen = set()
@ -4798,10 +4854,14 @@ async def apply_telegram_onboarding(
detail="Telegram setup is not ready yet.",
)
effective_profile = body.profile or profile
try:
save_env_value("TELEGRAM_BOT_TOKEN", bot_token)
save_env_value("TELEGRAM_ALLOWED_USERS", ",".join(allowed_user_ids))
_write_platform_enabled("telegram", True)
with _profile_scope(effective_profile):
save_env_value("TELEGRAM_BOT_TOKEN", bot_token)
save_env_value("TELEGRAM_ALLOWED_USERS", ",".join(allowed_user_ids))
_write_platform_enabled("telegram", True)
except HTTPException:
raise
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
except Exception as exc:
@ -4814,7 +4874,7 @@ async def apply_telegram_onboarding(
with _telegram_onboarding_lock:
_telegram_onboarding_pairings.pop(pairing_id, None)
restart_result = _restart_gateway_after_telegram_onboarding()
restart_result = _restart_gateway_after_telegram_onboarding(effective_profile)
return {
"ok": True,
@ -4842,6 +4902,8 @@ async def get_messaging_platforms(profile: Optional[str] = None):
env_on_disk = load_env()
runtime = read_runtime_status()
return {
"env_path": str(get_env_path()),
"gateway_start_command": _gateway_display_command(profile, "start"),
"platforms": [
_messaging_platform_payload(
entry, env_on_disk, runtime, scoped=scoped_dir is not None
@ -7802,9 +7864,11 @@ async def set_webhook_enabled(name: str, body: WebhookEnabledToggle):
@app.post("/api/gateway/start")
async def start_gateway():
async def start_gateway(profile: Optional[str] = None):
try:
proc = _spawn_hermes_action(["gateway", "start"], "gateway-start")
proc = _spawn_hermes_action(_gateway_subcommand(profile, "start"), "gateway-start")
except HTTPException:
raise
except Exception as exc:
_log.exception("Failed to spawn gateway start")
raise HTTPException(status_code=500, detail=f"Failed to start gateway: {exc}")
@ -7812,9 +7876,11 @@ async def start_gateway():
@app.post("/api/gateway/stop")
async def stop_gateway():
async def stop_gateway(profile: Optional[str] = None):
try:
proc = _spawn_hermes_action(["gateway", "stop"], "gateway-stop")
proc = _spawn_hermes_action(_gateway_subcommand(profile, "stop"), "gateway-stop")
except HTTPException:
raise
except Exception as exc:
_log.exception("Failed to spawn gateway stop")
raise HTTPException(status_code=500, detail=f"Failed to stop gateway: {exc}")
@ -8346,7 +8412,7 @@ def _profile_cli_args(profile: Optional[str]) -> List[str]:
profile (no args, legacy behavior).
"""
requested = (profile or "").strip()
if not requested or requested.lower() == "current":
if not requested or requested.lower() in {"current", "default"}:
return []
from hermes_cli import profiles as profiles_mod
_resolve_profile_dir(requested)

View File

@ -20,3 +20,34 @@ def test_runtime_health_lines_include_fatal_platform_and_startup_reason(monkeypa
assert "⚠ telegram: another poller is active" in lines
assert "⚠ Last startup issue: telegram conflict" in lines
def test_runtime_status_running_pid_validates_live_gateway_record(monkeypatch):
from gateway import status as status_mod
runtime = {
"pid": 12345,
"kind": "hermes-gateway",
"argv": ["/opt/hermes/hermes_cli/main.py", "gateway", "run", "--replace"],
"start_time": None,
"gateway_state": "running",
}
monkeypatch.setattr(status_mod, "_pid_exists", lambda pid: pid == 12345)
monkeypatch.setattr(status_mod, "_get_process_start_time", lambda pid: None)
monkeypatch.setattr(status_mod, "_looks_like_gateway_process", lambda pid: False)
assert status_mod.get_runtime_status_running_pid(runtime) == 12345
def test_runtime_status_running_pid_rejects_stopped_record(monkeypatch):
from gateway import status as status_mod
runtime = {
"pid": 12345,
"kind": "hermes-gateway",
"argv": ["/opt/hermes/hermes_cli/main.py", "gateway", "run", "--replace"],
"gateway_state": "stopped",
}
monkeypatch.setattr(status_mod, "_pid_exists", lambda pid: True)
assert status_mod.get_runtime_status_running_pid(runtime) is None

View File

@ -91,6 +91,47 @@ class TestProfileScopedMessagingReads:
)
assert resp.status_code == 404
def test_scoped_read_returns_profile_path_command_and_startup_failure(
self, client, isolated_profiles, monkeypatch
):
import hermes_cli.web_server as web_server
worker_home = isolated_profiles["worker_alpha"]
(worker_home / ".env").write_text(
"TELEGRAM_BOT_TOKEN=worker-token\n", encoding="utf-8"
)
(worker_home / "config.yaml").write_text(
yaml.safe_dump({"platforms": {"telegram": {"enabled": True}}}),
encoding="utf-8",
)
monkeypatch.setattr(web_server, "get_running_pid", lambda: None)
monkeypatch.setattr(
web_server,
"read_runtime_status",
lambda: {
"gateway_state": "startup_failed",
"exit_reason": "all configured messaging platforms failed to connect",
"platforms": {},
},
)
resp = client.get(
"/api/messaging/platforms", params={"profile": "worker_alpha"}
)
assert resp.status_code == 200
payload = resp.json()
assert payload["env_path"] == str(worker_home / ".env")
assert payload["gateway_start_command"] == (
"hermes -p worker_alpha gateway start"
)
telegram = _telegram(payload)
assert telegram["state"] == "startup_failed"
assert telegram["error_code"] == "startup_failed"
assert telegram["error_message"] == (
"all configured messaging platforms failed to connect"
)
class TestProfileScopedMessagingWrites:
def test_scoped_write_lands_in_target_profile_env(

View File

@ -353,6 +353,167 @@ class TestProfileScopedPostSetup:
assert calls == [["tools", "post-setup", "agent_browser"]]
class TestProfileScopedGateway:
def test_lifecycle_spawns_with_profile_flag(
self, client, isolated_profiles, monkeypatch
):
import hermes_cli.web_server as web_server
calls = []
class _FakeProc:
pid = 888
monkeypatch.setattr(
web_server,
"_spawn_hermes_action",
lambda subcommand, name: calls.append((list(subcommand), name)) or _FakeProc(),
)
web_server._ACTION_PROCS.pop("gateway-restart", None)
web_server._ACTION_COMMANDS.pop("gateway-restart", None)
for verb in ("start", "stop", "restart"):
resp = client.post(f"/api/gateway/{verb}", params={"profile": "worker_beta"})
assert resp.status_code == 200
assert calls == [
(["-p", "worker_beta", "gateway", "start"], "gateway-start"),
(["-p", "worker_beta", "gateway", "stop"], "gateway-stop"),
(["-p", "worker_beta", "gateway", "restart"], "gateway-restart"),
]
def test_status_reads_requested_profile_home(
self, client, isolated_profiles, monkeypatch
):
import hermes_cli.web_server as web_server
from hermes_constants import get_hermes_home
seen_homes = []
def fake_get_running_pid():
seen_homes.append(str(get_hermes_home()))
return None
monkeypatch.setattr(web_server, "check_config_version", lambda: (1, 1))
monkeypatch.setattr(web_server, "get_running_pid", fake_get_running_pid)
monkeypatch.setattr(
web_server,
"read_runtime_status",
lambda: {"gateway_state": "startup_failed", "platforms": {}},
)
monkeypatch.setattr(web_server, "_GATEWAY_HEALTH_URL", None)
resp = client.get("/api/status", params={"profile": "worker_beta"})
assert resp.status_code == 200
assert seen_homes[0] == str(isolated_profiles["worker_beta"])
assert resp.json()["hermes_home"] == str(isolated_profiles["worker_beta"])
def test_status_uses_runtime_pid_when_profile_pid_file_is_missing(
self, client, isolated_profiles, monkeypatch
):
import hermes_cli.web_server as web_server
worker_home = isolated_profiles["worker_beta"]
(worker_home / ".env").write_text(
"TELEGRAM_BOT_TOKEN=worker-token\n", encoding="utf-8"
)
(worker_home / "config.yaml").write_text(
yaml.safe_dump({"platforms": {"telegram": {"enabled": True}}}),
encoding="utf-8",
)
runtime = {
"pid": 4242,
"gateway_state": "running",
"platforms": {"telegram": {"state": "connected"}},
"exit_reason": None,
"updated_at": "2026-06-17T00:00:00+00:00",
}
monkeypatch.setattr(web_server, "check_config_version", lambda: (1, 1))
monkeypatch.setattr(web_server, "get_running_pid", lambda: None)
monkeypatch.setattr(web_server, "read_runtime_status", lambda: runtime)
monkeypatch.setattr(
web_server, "get_runtime_status_running_pid", lambda payload: 4242
)
monkeypatch.setattr(web_server, "_GATEWAY_HEALTH_URL", None)
from gateway.config import Platform
class _FakeGatewayConfig:
def get_connected_platforms(self):
return [Platform.TELEGRAM]
monkeypatch.setattr(
"gateway.config.load_gateway_config", lambda: _FakeGatewayConfig()
)
resp = client.get("/api/status", params={"profile": "worker_beta"})
assert resp.status_code == 200
data = resp.json()
assert data["gateway_running"] is True
assert data["gateway_pid"] == 4242
assert data["gateway_state"] == "running"
assert data["gateway_platforms"] == {"telegram": {"state": "connected"}}
class TestProfileScopedTelegramOnboarding:
def test_apply_writes_target_profile_and_restarts_target(
self, client, isolated_profiles, monkeypatch
):
import time
import hermes_cli.web_server as web_server
with web_server._telegram_onboarding_lock:
web_server._telegram_onboarding_pairings.clear()
web_server._telegram_onboarding_pairings["pair-worker"] = (
web_server._TelegramOnboardingPairing(
poll_token="poll-secret",
expires_at="2027-05-18T00:00:00.000Z",
expires_at_ts=time.time() + 600,
bot_token="123456:SECRET",
bot_username="worker_bot",
owner_user_id="123456789",
)
)
calls = []
class _FakeProc:
pid = 889
monkeypatch.setattr(
web_server,
"_spawn_hermes_action",
lambda subcommand, name: calls.append((list(subcommand), name)) or _FakeProc(),
)
web_server._ACTION_PROCS.pop("gateway-restart", None)
web_server._ACTION_COMMANDS.pop("gateway-restart", None)
resp = client.post(
"/api/messaging/telegram/onboarding/pair-worker/apply",
params={"profile": "worker_beta"},
json={"allowed_user_ids": ["123456789"]},
)
assert resp.status_code == 200
assert resp.json()["restart_started"] is True
assert calls == [
(["-p", "worker_beta", "gateway", "restart"], "gateway-restart")
]
worker_env = (isolated_profiles["worker_beta"] / ".env").read_text()
assert "TELEGRAM_BOT_TOKEN=123456:SECRET" in worker_env
assert "TELEGRAM_ALLOWED_USERS=123456789" in worker_env
default_env_path = isolated_profiles["default"] / ".env"
if default_env_path.exists():
assert "TELEGRAM_BOT_TOKEN" not in default_env_path.read_text()
worker_cfg = _cfg(isolated_profiles["worker_beta"])
default_cfg = _cfg(isolated_profiles["default"])
assert worker_cfg["platforms"]["telegram"]["enabled"] is True
assert default_cfg.get("platforms", {}).get("telegram", {}).get("enabled") is not True
class TestProfileScopedChatPty:
def test_chat_argv_scopes_hermes_home(self, isolated_profiles, monkeypatch):
import hermes_cli.web_server as web_server

View File

@ -60,10 +60,11 @@ export function getManagementProfile(): string {
// Endpoint families that honor ?profile= on the backend (web_server.py
// _profile_scope or explicit per-profile DB opens). Anything else — ops,
// pairing, telegram onboarding, cron (which has its own per-job profile
// params), profiles themselves — is machine-global or self-scoped and must
// NOT be rewritten.
// pairing, cron (which has its own per-job profile params), profiles
// themselves — is machine-global or self-scoped and must NOT be rewritten.
const PROFILE_SCOPED_PREFIXES = [
"/api/status",
"/api/gateway",
"/api/analytics",
"/api/skills",
"/api/tools/toolsets",
@ -71,6 +72,7 @@ const PROFILE_SCOPED_PREFIXES = [
"/api/env",
"/api/mcp",
"/api/messaging/platforms",
"/api/messaging/telegram/onboarding",
"/api/model/info",
"/api/model/set",
"/api/model/auxiliary",
@ -771,7 +773,7 @@ export const api = {
// Messaging platforms (gateway channels)
getMessagingPlatforms: () =>
fetchJSON<{ platforms: MessagingPlatform[] }>("/api/messaging/platforms"),
fetchJSON<MessagingPlatformsResponse>("/api/messaging/platforms"),
updateMessagingPlatform: (id: string, body: MessagingPlatformUpdate) =>
fetchJSON<{ ok: boolean; platform: string }>(
`/api/messaging/platforms/${encodeURIComponent(id)}`,
@ -801,7 +803,7 @@ export const api = {
),
applyTelegramOnboarding: (
pairingId: string,
body: { allowed_user_ids: string[] },
body: { allowed_user_ids: string[]; profile?: string },
) =>
fetchJSON<TelegramOnboardingApplyResponse>(
`/api/messaging/telegram/onboarding/${encodeURIComponent(pairingId)}/apply`,
@ -1339,7 +1341,7 @@ export interface MessagingPlatform {
gateway_running: boolean;
/**
* "connected" | "disabled" | "not_configured" | "pending_restart" |
* "gateway_stopped" | "disconnected" | "fatal" | string
* "gateway_stopped" | "startup_failed" | "disconnected" | "fatal" | string
*/
state: string;
error_code: string | null;
@ -1349,6 +1351,12 @@ export interface MessagingPlatform {
env_vars: MessagingPlatformEnvVar[];
}
export interface MessagingPlatformsResponse {
env_path: string;
gateway_start_command: string;
platforms: MessagingPlatform[];
}
export interface MessagingPlatformUpdate {
enabled?: boolean;
env?: Record<string, string>;

View File

@ -43,6 +43,7 @@ const STATE_BADGE: Record<
connected: { tone: "success", label: "Connected" },
pending_restart: { tone: "warning", label: "Restart to apply" },
gateway_stopped: { tone: "warning", label: "Gateway stopped" },
startup_failed: { tone: "destructive", label: "Start failed" },
disconnected: { tone: "warning", label: "Disconnected" },
not_configured: { tone: "outline", label: "Not configured" },
disabled: { tone: "secondary", label: "Disabled" },
@ -71,6 +72,10 @@ function isTerminalTelegramOnboardingError(error: unknown): boolean {
export default function ChannelsPage() {
const [platforms, setPlatforms] = useState<MessagingPlatform[]>([]);
const [envPath, setEnvPath] = useState("~/.hermes/.env");
const [gatewayStartCommand, setGatewayStartCommand] = useState(
"hermes gateway start",
);
const [loading, setLoading] = useState(true);
const { toast, showToast } = useToast();
const { setEnd } = usePageHeader();
@ -93,7 +98,11 @@ export default function ChannelsPage() {
const load = useCallback(() => {
return api
.getMessagingPlatforms()
.then((res) => setPlatforms(res.platforms))
.then((res) => {
setPlatforms(res.platforms);
setEnvPath(res.env_path || "~/.hermes/.env");
setGatewayStartCommand(res.gateway_start_command || "hermes gateway start");
})
.catch((e) => showToast(`Error: ${e}`, "error"));
}, [showToast]);
@ -253,7 +262,7 @@ export default function ChannelsPage() {
<WifiOff className="h-4 w-4 shrink-0" />
<span>
The gateway is not running. Configure channels here, then start the
gateway with <code className="font-courier">hermes gateway start</code>{" "}
gateway with <code className="font-courier">{gatewayStartCommand}</code>{" "}
(or the Restart button above).
</span>
</CardContent>
@ -262,7 +271,7 @@ export default function ChannelsPage() {
<p className="text-xs text-muted-foreground">
{configured} of {platforms.length} channels configured. Credentials are
written to <code className="font-courier">~/.hermes/.env</code>; the
written to <code className="font-courier">{envPath}</code>; the
gateway connects each enabled channel on its next restart.
</p>
@ -369,7 +378,7 @@ export default function ChannelsPage() {
const StateIcon =
platform.state === "connected"
? CheckCircle2
: platform.state === "fatal"
: platform.state === "fatal" || platform.state === "startup_failed"
? AlertTriangle
: Radio;
return (
@ -382,7 +391,8 @@ export default function ChannelsPage() {
"h-5 w-5 shrink-0 mt-0.5",
platform.state === "connected"
? "text-success"
: platform.state === "fatal"
: platform.state === "fatal" ||
platform.state === "startup_failed"
? "text-destructive"
: "text-muted-foreground",
)}