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()) 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: def remove_pid_file() -> None:
"""Remove the gateway PID file, but only if it belongs to this process. """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, recommended_update_command_for_method,
redact_key, 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 from utils import env_var_enabled
try: try:
@ -678,6 +682,7 @@ class TelegramOnboardingStart(BaseModel):
class TelegramOnboardingApply(BaseModel): class TelegramOnboardingApply(BaseModel):
allowed_user_ids: List[str] allowed_user_ids: List[str]
profile: Optional[str] = None
class AudioTranscriptionRequest(BaseModel): class AudioTranscriptionRequest(BaseModel):
@ -1609,145 +1614,161 @@ async def fs_default_cwd():
@app.get("/api/status") @app.get("/api/status")
async def get_status(): async def get_status(profile: Optional[str] = None):
current_ver, latest_ver = check_config_version() 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: 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() if not gateway_running and _GATEWAY_HEALTH_URL:
configured_gateway_platforms = { loop = asyncio.get_running_loop()
platform.value for platform in gateway_config.get_connected_platforms() alive, remote_health_body = await loop.run_in_executor(
} None, _probe_gateway_health
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
) )
finally: if alive:
db.close() gateway_running = True
except Exception: # PID from the remote container (display only — not locally valid)
pass if remote_health_body:
gateway_pid = remote_health_body.get("pid")
# Dashboard auth gate (Phase 7): surface whether the gate is engaged gateway_state = None
# and which providers are registered so ``hermes status`` and the gateway_platforms: dict = {}
# SPA's StatusPage can show "OAuth gate ON via Nous Research" or gateway_exit_reason = None
# "loopback only — no auth gate" with no extra round trips. gateway_updated_at = None
auth_required = bool(getattr(app.state, "auth_required", False)) configured_gateway_platforms: set[str] | None = None
auth_providers: list[str] = [] try:
try: from gateway.config import load_gateway_config
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 gateway_config = load_gateway_config()
# probes (NAS's wildcard-subdomain liveness probe), the SPA's pre-login configured_gateway_platforms = {
# bootstrap, and anyone who can curl the host — i.e. exactly the audience platform.value for platform in gateway_config.get_connected_platforms()
# ``PUBLIC_API_PATHS`` documents this endpoint as serving. }
status = { except Exception:
"version": __version__, configured_gateway_platforms = None
"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 # Prefer the detailed health endpoint response (has full state) when the
# URL are deployment recon a liveness probe never needs. ``/api/status`` # local runtime status file is absent or stale (cross-container).
# is in ``PUBLIC_API_PATHS`` so it bypasses dashboard auth; on a runtime = read_runtime_status()
# network-exposed (gated) bind that means *any* unauthenticated caller if runtime is None and remote_health_body and remote_health_body.get("gateway_state"):
# reaches it, and leaking host metadata there contradicts the allowlist's runtime = remote_health_body
# own contract ("version, gateway state, active session count, and the runtime_pid = get_runtime_status_running_pid(runtime)
# dashboard auth-gate shape. No bodies, no session content, no secrets"). if not gateway_running and runtime_pid is not None:
# Surface this detail only on a loopback / ``--insecure`` bind, where the gateway_running = True
# dashboard is local-only and the caller is already inside the trust gateway_pid = runtime_pid
# 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 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 _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 # ``name`` → most recently spawned Popen handle. Used so ``status`` can
# report liveness and exit code without shelling out to ``ps``. # report liveness and exit code without shelling out to ``ps``.
_ACTION_PROCS: Dict[str, subprocess.Popen] = {} _ACTION_PROCS: Dict[str, subprocess.Popen] = {}
_ACTION_COMMANDS: Dict[str, Tuple[str, ...]] = {}
# ``name`` → completed synthetic action result for actions the server handled # ``name`` → completed synthetic action result for actions the server handled
# without spawning a subprocess (for example, unsupported Docker updates). # 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"): if not message.endswith("\n"):
log_file.write(b"\n") log_file.write(b"\n")
_ACTION_PROCS.pop(name, None) _ACTION_PROCS.pop(name, None)
_ACTION_COMMANDS.pop(name, None)
_ACTION_RESULTS[name] = {"exit_code": exit_code, "pid": 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. # fd per spawned action.
log_file.close() log_file.close()
_ACTION_RESULTS.pop(name, None) _ACTION_RESULTS.pop(name, None)
_ACTION_COMMANDS[name] = tuple(subcommand)
_ACTION_PROCS[name] = proc _ACTION_PROCS[name] = proc
return proc return proc
@ -2172,7 +2196,15 @@ def _tail_lines(path: Path, n: int) -> List[str]:
return lines[-n:] if n > 0 else lines 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. """Spawn ``hermes gateway restart``, reusing an in-flight restart.
Multiple dashboard paths can request a restart in quick succession 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)``. Returns ``(proc, reused)``.
""" """
subcommand = _gateway_subcommand(profile, "restart")
existing = _ACTION_PROCS.get("gateway-restart") existing = _ACTION_PROCS.get("gateway-restart")
if existing is not None and existing.poll() is None: if existing is not None and existing.poll() is None:
return existing, True existing_command = _ACTION_COMMANDS.get("gateway-restart")
return _spawn_hermes_action(["gateway", "restart"], "gateway-restart"), False 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.""" """Best-effort gateway restart after enabling the webhook platform."""
try: try:
proc, reused = _spawn_gateway_restart() proc, reused = _spawn_gateway_restart(profile)
except Exception as exc: except Exception as exc:
_log.exception("Failed to auto-restart gateway after enabling webhooks") _log.exception("Failed to auto-restart gateway after enabling webhooks")
return { return {
@ -2212,10 +2248,12 @@ def _restart_gateway_after_webhook_enable() -> dict[str, Any]:
@app.post("/api/gateway/restart") @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.""" """Kick off a ``hermes gateway restart`` in the background."""
try: try:
proc, _reused = _spawn_gateway_restart() proc, _reused = _spawn_gateway_restart(profile)
except HTTPException:
raise
except Exception as exc: except Exception as exc:
_log.exception("Failed to spawn gateway restart") _log.exception("Failed to spawn gateway restart")
raise HTTPException(status_code=500, detail=f"Failed to restart gateway: {exc}") 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 pass
_ACTION_RESULTS[name] = {"exit_code": exit_code, "pid": pid} _ACTION_RESULTS[name] = {"exit_code": exit_code, "pid": pid}
_ACTION_PROCS.pop(name, None) _ACTION_PROCS.pop(name, None)
_ACTION_COMMANDS.pop(name, None)
return { return {
"name": name, "name": name,
@ -4346,13 +4385,16 @@ def _messaging_platform_payload(
scoped: bool = False, scoped: bool = False,
) -> dict[str, Any]: ) -> dict[str, Any]:
platform_id = entry["id"] platform_id = entry["id"]
gateway_running = get_running_pid() is not None
runtime_platforms = runtime.get("platforms") if runtime else {} runtime_platforms = runtime.get("platforms") if runtime else {}
runtime_platform = ( runtime_platform = (
runtime_platforms.get(platform_id, {}) runtime_platforms.get(platform_id, {})
if isinstance(runtime_platforms, dict) if isinstance(runtime_platforms, dict)
else {} else {}
) )
gateway_running = (
get_running_pid() is not None
or get_runtime_status_running_pid(runtime) is not None
)
env_vars = [] env_vars = []
for key in entry["env_vars"]: for key in entry["env_vars"]:
@ -4415,15 +4457,37 @@ def _messaging_platform_payload(
state = ( state = (
runtime_platform.get("state") if isinstance(runtime_platform, dict) else None 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: if not enabled:
state = "disabled" state = "disabled"
elif not configured: elif not configured:
state = "not_configured" state = "not_configured"
elif gateway_running and not state: elif gateway_running and not state:
state = "pending_restart" 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: elif not gateway_running and not state:
state = "gateway_stopped" 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 { return {
"id": platform_id, "id": platform_id,
"name": entry["name"], "name": entry["name"],
@ -4433,16 +4497,8 @@ def _messaging_platform_payload(
"configured": configured, "configured": configured,
"gateway_running": gateway_running, "gateway_running": gateway_running,
"state": state, "state": state,
"error_code": ( "error_code": error_code,
runtime_platform.get("error_code") "error_message": error_message,
if isinstance(runtime_platform, dict)
else None
),
"error_message": (
runtime_platform.get("error_message")
if isinstance(runtime_platform, dict)
else None
),
"updated_at": ( "updated_at": (
runtime_platform.get("updated_at") runtime_platform.get("updated_at")
if isinstance(runtime_platform, dict) 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. """Best-effort gateway restart after saving Telegram QR onboarding.
The QR flow naturally pulls users into Telegram on another device. If the 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. restart failures so the UI can fall back to the existing manual banner.
""" """
try: try:
proc, reused = _spawn_gateway_restart() proc, reused = _spawn_gateway_restart(profile)
except Exception as exc: except Exception as exc:
_log.exception("Failed to auto-restart gateway after Telegram onboarding") _log.exception("Failed to auto-restart gateway after Telegram onboarding")
return { return {
@ -4762,7 +4818,7 @@ def _restart_gateway_after_telegram_onboarding() -> dict[str, Any]:
@app.post("/api/messaging/telegram/onboarding/{pairing_id}/apply") @app.post("/api/messaging/telegram/onboarding/{pairing_id}/apply")
async def apply_telegram_onboarding( async def apply_telegram_onboarding(
pairing_id: str, body: TelegramOnboardingApply pairing_id: str, body: TelegramOnboardingApply, profile: Optional[str] = None
): ):
allowed_user_ids = [] allowed_user_ids = []
seen = set() seen = set()
@ -4798,10 +4854,14 @@ async def apply_telegram_onboarding(
detail="Telegram setup is not ready yet.", detail="Telegram setup is not ready yet.",
) )
effective_profile = body.profile or profile
try: try:
save_env_value("TELEGRAM_BOT_TOKEN", bot_token) with _profile_scope(effective_profile):
save_env_value("TELEGRAM_ALLOWED_USERS", ",".join(allowed_user_ids)) save_env_value("TELEGRAM_BOT_TOKEN", bot_token)
_write_platform_enabled("telegram", True) save_env_value("TELEGRAM_ALLOWED_USERS", ",".join(allowed_user_ids))
_write_platform_enabled("telegram", True)
except HTTPException:
raise
except ValueError as exc: except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc raise HTTPException(status_code=400, detail=str(exc)) from exc
except Exception as exc: except Exception as exc:
@ -4814,7 +4874,7 @@ async def apply_telegram_onboarding(
with _telegram_onboarding_lock: with _telegram_onboarding_lock:
_telegram_onboarding_pairings.pop(pairing_id, None) _telegram_onboarding_pairings.pop(pairing_id, None)
restart_result = _restart_gateway_after_telegram_onboarding() restart_result = _restart_gateway_after_telegram_onboarding(effective_profile)
return { return {
"ok": True, "ok": True,
@ -4842,6 +4902,8 @@ async def get_messaging_platforms(profile: Optional[str] = None):
env_on_disk = load_env() env_on_disk = load_env()
runtime = read_runtime_status() runtime = read_runtime_status()
return { return {
"env_path": str(get_env_path()),
"gateway_start_command": _gateway_display_command(profile, "start"),
"platforms": [ "platforms": [
_messaging_platform_payload( _messaging_platform_payload(
entry, env_on_disk, runtime, scoped=scoped_dir is not None 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") @app.post("/api/gateway/start")
async def start_gateway(): async def start_gateway(profile: Optional[str] = None):
try: 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: except Exception as exc:
_log.exception("Failed to spawn gateway start") _log.exception("Failed to spawn gateway start")
raise HTTPException(status_code=500, detail=f"Failed to start gateway: {exc}") 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") @app.post("/api/gateway/stop")
async def stop_gateway(): async def stop_gateway(profile: Optional[str] = None):
try: 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: except Exception as exc:
_log.exception("Failed to spawn gateway stop") _log.exception("Failed to spawn gateway stop")
raise HTTPException(status_code=500, detail=f"Failed to stop gateway: {exc}") 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). profile (no args, legacy behavior).
""" """
requested = (profile or "").strip() requested = (profile or "").strip()
if not requested or requested.lower() == "current": if not requested or requested.lower() in {"current", "default"}:
return [] return []
from hermes_cli import profiles as profiles_mod from hermes_cli import profiles as profiles_mod
_resolve_profile_dir(requested) _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 "⚠ telegram: another poller is active" in lines
assert "⚠ Last startup issue: telegram conflict" 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 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: class TestProfileScopedMessagingWrites:
def test_scoped_write_lands_in_target_profile_env( def test_scoped_write_lands_in_target_profile_env(

View File

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

View File

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