Files
hermes-agent/hermes_cli/gateway_multiplex_served.py
T
Teknium 37dcc0a6e8 fix(gateway): a secondary API_SERVER_KEY no longer skips the profile; start/install/status honour the live multiplexer
Under gateway.multiplex_profiles the default gateway serves every profile, yet
four startup/status paths still reasoned from the wrong source:

* A secondary profile's API_SERVER_KEY (which the docs REQUIRE for /p/<profile>/
  auth) auto-enabled api_server in that profile's config, so
  _load_secondary_profile_config raised SecondaryPortBindingConfigError and the
  whole profile was skipped. gateway/config_env.py::_enable_from_env now leaves
  `enabled` alone for port-binding platforms while a multiplexer loads a
  NON-default profile (home override + multiplex flag, the same signal
  gateway.config uses for scoped reads); the credential still lands in extra so
  the shared listener can authenticate the prefix. Default profile unchanged.

* "Is this profile served?" was re-derived from the default config.yaml plus
  GATEWAY_MULTIPLEX_PROFILES as seen by the CLI process. `hermes -p coder ...`
  loads coder's .env, so an env-only opt-in on the default profile was invisible
  (guard never fired, status said stopped) and an allowlist edit flipped the
  answer before the restart. named_profile_served_by_running_multiplexer now
  reads the pid-verified default gateway_state.json served_profiles (written by
  _record_served_profiles) first and falls back to config derivation only when
  the key is absent. The record helpers live in hermes_cli/gateway_multiplex_served.py.

* The served-profile guard ran only inside `gateway run`. `hermes -p X gateway
  start|install|restart` reached the service manager, whose unit then exited 78
  forever (systemd parks it while the CLI prints "started"; launchd KeepAlive
  respawns every 30 s). The service verbs now run the same guard up front
  (exit 78, same message) and accept --force; the Desktop /api/gateway/start
  route returns 409 for a served profile instead of spawning a doomed child.

* Status surfaces disagreed: `hermes -p coder status` said stopped, `hermes -p
  coder cron status` said "cron jobs will NOT fire" while `cron list` said fine,
  and the default `hermes status` never listed served profiles. Both now route
  through the probe / the recorded served set. The -p/--profile matcher in
  _scan_gateway_pids and gateway.status._command_line_belongs_to_profile compares
  the flag token for equality (`-p ops` no longer claims -- or lets `gateway
  stop` SIGTERM -- an `-p ops-2` gateway).

Docs: multi-profile-gateways.md now describes the start/install refusal, the
--force flags, the API_SERVER_KEY behaviour and the single default-home
gateway_state.json (the per-profile runtime_status.json claim was wrong).

Fixes #100397
Addresses #89726 #97360 #71344

(cherry picked from commit d002c1864a7b6a22c53758b16b7b0cc79aea2edf)
2026-09-11 15:27:23 -07:00

44 lines
2.2 KiB
Python

"""Which profiles does the LIVE default multiplexer serve? One answer for every CLI/dashboard surface.
``gateway/run_adapters.py::_record_served_profiles`` writes ``served_profiles`` into the default
home's ``gateway_state.json`` at startup. That record is the truth about the running process; the
default ``config.yaml`` plus ``GATEWAY_MULTIPLEX_PROFILES`` as seen by the *CLI* process is only a
guess (``hermes -p coder ...`` loads coder's ``.env``, so an env-only opt-in on the default profile
is invisible to it, and an allowlist edited after start flips the guess before the restart).
"""
from __future__ import annotations
import logging
from pathlib import Path
from typing import Optional
logger = logging.getLogger(__name__)
def live_default_gateway_pid() -> Optional[int]:
"""PID of the default profile's gateway when its pid record names a live process, else None."""
from hermes_constants import get_default_hermes_root
from gateway.status import _pid_exists, _pid_from_record, _read_pid_record
rec = _read_pid_record(get_default_hermes_root() / "gateway.pid")
pid = _pid_from_record(rec) if rec else None
return pid if pid and _pid_exists(pid) else None
def recorded_served_profiles(default_root: Optional[Path] = None) -> Optional[list[str]]:
"""``served_profiles`` the live default gateway recorded, or None when the key is absent (a record
from before the multiplexer recorded it, or a stopped/absent gateway). Callers fall back to config
derivation only on None: an empty list is an authoritative "serves nobody else"."""
from hermes_constants import get_default_hermes_root
from gateway.status import read_runtime_status
if live_default_gateway_pid() is None:
return None
runtime = read_runtime_status((default_root or get_default_hermes_root()) / "gateway_state.json")
served = (runtime or {}).get("served_profiles")
return [str(p) for p in served] if isinstance(served, list) else None
def multiplexer_served_secondaries() -> list[str]:
"""Named profiles the live default multiplexer serves (excludes ``default``); empty when none."""
return [p for p in (recorded_served_profiles() or []) if p and p != "default"]