Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 13 additions & 7 deletions gateway/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -548,9 +548,14 @@ class GatewayConfig:
group_sessions_per_user: bool = True # Isolate group sessions per participant when user IDs exist
thread_sessions_per_user: bool = False # False = threads shared across participants
max_concurrent_sessions: Optional[int] = None # Positive int caps simultaneous active sessions
# Opt-in: the default profile's gateway serves every profile on the host (profiles stamped into
# session keys, per-profile adapters/credentials).
multiplex_profiles: bool = False
# The default profile's gateway serves every profile on the host (profiles stamped into session
# keys, per-profile adapters/credentials). On by default (DEFAULT_CONFIG), but UNSET here is
# ``None``: a request the gateway settles at boot, not a verdict. ``hermes_cli.gateway_multiplex_mode
# .resolve_multiplex_mode`` runs the migration preflight (default profile, >= 2 profiles, no
# secondary running its own gateway, no blocker, migratable host) and only then writes True/False.
# An explicit value (config.yaml, GATEWAY_MULTIPLEX_PROFILES, a constructor argument) is honoured
# verbatim. Every reader tests truthiness, so an unresolved ``None`` never multiplexes by accident.
multiplex_profiles: Optional[bool] = None
# Public HTTPS endpoint for scoped RoomLink calls (an API key alone must never advertise a
# route); HERMES_ROOM_LINK_URL overrides.
room_link_url: Optional[str] = None
Expand Down Expand Up @@ -693,9 +698,10 @@ def bounded_float(key: str, default: float, lo: float, hi: float) -> float:
systemd_watchdog_seconds = coerce_systemd_watchdog_seconds(
pick("systemd_watchdog_seconds"), key_label("systemd_watchdog_seconds")
)
# env > config.yaml > False: a recognized GATEWAY_MULTIPLEX_PROFILES wins (hosted deployments
# stamp it on the container); blank/unrecognized falls through to the top-level VALUE when
# not None, else ``gateway.multiplex_profiles``.
# env > config.yaml > unset: a recognized GATEWAY_MULTIPLEX_PROFILES wins (hosted deployments
# stamp it on the container); blank/unrecognized falls through to the top-level VALUE when not
# None, else ``gateway.multiplex_profiles``. Nothing set stays ``None`` so the boot-time guard
# (``resolve_multiplex_mode``) can tell "the operator chose" from "the default applies".
multiplex_profiles = data.get("multiplex_profiles")
if multiplex_profiles is None:
multiplex_profiles = nested_gateway.get("multiplex_profiles")
Expand All @@ -721,7 +727,7 @@ def bounded_float(key: str, default: float, lo: float, hi: float) -> float:
**{name: _coerce_bool(data.get(name), default) for name, default in _TOPLEVEL_BOOL_DEFAULTS.items()},
stt_enabled=_coerce_bool(stt_setting("stt_enabled", "enabled"), True),
stt_echo_transcripts=_coerce_bool(stt_setting("stt_echo_transcripts", "echo_transcripts"), True),
multiplex_profiles=_coerce_bool(multiplex_profiles, False),
multiplex_profiles=None if multiplex_profiles is None else _coerce_bool(multiplex_profiles, True),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This fallback flip (False → True) makes any unrecognized string an explicit opt-in: 'flase', 'ture', 'enabled', '' all parse as True and skip the boot guard entirely. Pre-PR they parsed False. The env-var path handles garbage correctly (warn + unset) — the config path should match. All three reviewers hit this independently; reproduced at head. Suggested: unrecognized → warn + None so the guard decides.

room_link_url=room_link_url if isinstance(room_link_url, str) else None,
systemd_watchdog_seconds=systemd_watchdog_seconds,
loop_watchdog=_coerce_bool(pick("loop_watchdog"), True),
Expand Down
21 changes: 16 additions & 5 deletions gateway/run.py
Original file line number Diff line number Diff line change
Expand Up @@ -1775,26 +1775,32 @@ async def _async_profile_runtime_scope(profile_home: "Path"):


def load_gateway_config_for_runner() -> "GatewayConfig":
"""Load gateway config for the process-level GatewayRunner. Multiplexed: reload under the default
profile's ``_profile_runtime_scope`` so platform tokens in its ``.env`` resolve via the secret
scope; unscoped ``_getenv`` falls to ``os.environ``, which often lacks a token living only under
"""Load gateway config for the process-level GatewayRunner. An UNSET ``multiplex_profiles`` is
settled first by ``resolve_multiplex_mode`` (the default is on; the boot guard keeps a fleet that
still runs per-profile gateways standalone). Multiplexed: reload under the default profile's
``_profile_runtime_scope`` so platform tokens in its ``.env`` resolve via the secret scope;
unscoped ``_getenv`` falls to ``os.environ``, which often lacks a token living only under
``profiles/<name>/.env``. Off -> identical to ``load_gateway_config()``.

See #64674.
"""
from hermes_cli.gateway_multiplex_mode import log_multiplex_decision, resolve_multiplex_mode
cfg = load_gateway_config()
if not getattr(cfg, "multiplex_profiles", False):
log_multiplex_decision(resolve_multiplex_mode(cfg))
if not cfg.multiplex_profiles:
return cfg
try:
home = get_hermes_home()
except Exception:
return cfg
try:
with _profile_runtime_scope(Path(home)):
return load_gateway_config()
scoped = load_gateway_config()
except Exception:
logger.debug("multiplex default-scope config reload failed; using unscoped load", exc_info=True)
return cfg
scoped.multiplex_profiles = cfg.multiplex_profiles # the verdict above, not a second unset flag
return scoped


async def _discover_gateway_mcp_tools(config: object) -> None:
Expand Down Expand Up @@ -3401,6 +3407,8 @@ def __init__(self, config: Optional[GatewayConfig] = None):
# With multiplex_profiles on, load under the default profile secret scope so bot tokens in its
# .env resolve as secondary profiles' do; explicit config= injection (tests) is left untouched.
# See #64674.
# An injected config (tests, ``gateway run --config``) is taken verbatim: an unset flag there
# stays None (= standalone); only the loaded path runs the boot-time default-on guard.
self.config = config if config is not None else load_gateway_config_for_runner()
# Multiplexer flag flips agent.secret_scope.get_secret() to fail-closed on unscoped credential
# reads, so a missed migration crashes loudly instead of leaking a cross-profile value.
Expand Down Expand Up @@ -5459,6 +5467,9 @@ def _utf8_stdio() -> None:
import yaml
with open(args.config, encoding="utf-8") as f:
config = GatewayConfig.from_dict(yaml.safe_load(f) or {})
# Same boot-time verdict the loaded config gets when the file leaves the flag unset.
from hermes_cli.gateway_multiplex_mode import log_multiplex_decision, resolve_multiplex_mode
log_multiplex_decision(resolve_multiplex_mode(config))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Two issues on the --config path: (1) resolve_multiplex_mode(config) settles the injected config's flag, but implicit_multiplex_blocker() → build_migration_plan() re-reads each home's normal config, not this launch config — a duplicate-credential alt config passes the guard, then gets parked at startup. (2) This write runs before the duplicate-instance guard at run.py:5273 and the PID claim at run.py:5339, so a refused second start re-stamps the live gateway's status record. Fix both together: pass the launch config into preflight, and persist the decision only after the PID claim.


# start_gateway() completes teardown before returning/raising SystemExit; force-exit after so a
# wedged non-daemon worker can't block Py_FinalizeEx's join. SystemExit caught so EVERY path exits.
Expand Down
3 changes: 3 additions & 0 deletions gateway/status.py
Original file line number Diff line number Diff line change
Expand Up @@ -806,6 +806,7 @@ def write_runtime_status(
active_agents: Any = _UNSET, active_work: Any = _UNSET, platform: Any = _UNSET, platform_state: Any = _UNSET,
error_code: Any = _UNSET, error_message: Any = _UNSET, needs_attention: Any = _UNSET,
retrying_since: Any = _UNSET, served_profiles: Any = _UNSET, session_store: Any = _UNSET,
multiplex_standalone_reason: Any = _UNSET,
ingress_url: Any = _UNSET, listener_base: Any = _UNSET, clear_profile_platforms: bool = False,
drop_profile_platforms: Optional[str] = None,
) -> None:
Expand Down Expand Up @@ -838,6 +839,8 @@ def write_runtime_status(
("active_work", active_work, lambda v: list(v) if v else None),
# Multiplexed profiles; absent/empty for a single-profile gateway.
("served_profiles", served_profiles, lambda v: list(v or [])),
# Why an unset-default (multiplex on) gateway is serving one profile; None clears it.
("multiplex_standalone_reason", multiplex_standalone_reason, lambda v: str(v) if v else None),
("session_store", session_store, _coerce_session_store),
))
if platform is not _UNSET:
Expand Down
6 changes: 6 additions & 0 deletions hermes_cli/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,12 @@ Enumeration is a pure read: never `mkdir` a profile home from a served path (`Se
cron all go through `mkdir_under_hermes_home` / `_ensure_cron_dir`, which refuse a deleted or
missing named profile, #94590). Process-global per-profile slots (MCP discovery in `mcp_startup.py`,
tool registry overlays) key on `hermes_constants.hermes_home_key()`, never a single flag.
`gateway.multiplex_profiles` defaults to **on**, but `GatewayConfig` keeps an unset flag `None` and
`gateway_multiplex_mode.resolve_multiplex_mode` settles it once per boot (called from
`load_gateway_config_for_runner`): default profile, >= 2 profiles, no standalone secondary gateway,
no preflight blocker, migratable host → `True`; else `False` + a logged reason. Explicit values pass
through. CLI/dashboard readers use `default_gateway_multiplexes` (live `served_profiles` record, then
the explicit flag) — never the merged default, which would guess a verdict only the gateway makes.
Migration from per-profile gateways: `hermes_cli/gateway_migrate.py` (`hermes gateway migrate
--multiplex|--standalone`, table-driven `_PREFLIGHT_CHECKS`, manifest `<default>/gateway_migration.json`);
`update_cmd_fleet._verify_fleet_after_update` calls `maybe_auto_migrate_after_update` on the success
Expand Down
17 changes: 10 additions & 7 deletions hermes_cli/config_defaults.py
Original file line number Diff line number Diff line change
Expand Up @@ -1979,13 +1979,16 @@ def _aux(timeout, *, reasoning_effort=True, **extra):
"write_sessions_json": True,
# One gateway for every profile on this host: the DEFAULT profile's gateway also connects
# each named profile's bots (their own .env / config.yaml, per-profile secret scope) and
# stamps the profile into session keys. Flip with `hermes gateway migrate --multiplex`
# (records a rollback manifest; `--standalone` undoes it) or `hermes config set
# gateway.multiplex_profiles true` + `hermes gateway restart`. GATEWAY_MULTIPLEX_PROFILES
# in the environment overrides. Two profiles configuring the same bot token cannot be
# served together — the duplicate adapter is parked; `hermes profile create --clone`
# therefore leaves messaging channels behind unless --clone-channels is passed.
"multiplex_profiles": False,
# stamps the profile into session keys. On by default. An UNSET key is a request, not a
# verdict: at boot the default gateway runs the migration preflight and stays standalone
# (logging why) when a secondary still runs its own gateway or a blocker exists — an
# explicit `true` (config or GATEWAY_MULTIPLEX_PROFILES) is honoured as before, an explicit
# `false` keeps per-profile gateways for good. `hermes gateway migrate --multiplex` folds a
# per-profile fleet (records a rollback manifest; `--standalone` undoes it and pins false).
# Two profiles configuring the same bot token cannot be served together — the duplicate
# adapter is parked; `hermes profile create --clone` therefore leaves messaging channels
# behind unless --clone-channels is passed.
"multiplex_profiles": True,
# May `hermes update` fold this install onto a multiplexed default gateway by itself?
# True (the default) keeps today's behaviour: a multi-profile install whose secondaries run
# their own gateways is migrated automatically after an update when nothing blocks it.
Expand Down
4 changes: 3 additions & 1 deletion hermes_cli/container_boot.py
Original file line number Diff line number Diff line change
Expand Up @@ -79,10 +79,12 @@ def reconcile_profile_gateways(
actions: list[ReconcileAction] = []
# Under a multiplexing root gateway named slots are still registered but must not boot from
# their persisted run intent, or they would become additional multiplex owners.
# Explicit opt-in only: the unset default (on) is refused on s6 hosts by the gateway's own boot
# guard (per-profile gateways are s6 slots the preflight cannot fold), so the slots keep booting.
from gateway.config import load_gateway_config
from utils import is_truthy_value
try:
multiplex_profiles = load_gateway_config().multiplex_profiles
multiplex_profiles = load_gateway_config().multiplex_profiles is True
except Exception:
log.warning("Unable to load gateway configuration during container boot; using the "
"GATEWAY_MULTIPLEX_PROFILES override if set.", exc_info=True)
Expand Down
36 changes: 19 additions & 17 deletions hermes_cli/gateway.py
Original file line number Diff line number Diff line change
Expand Up @@ -1482,6 +1482,19 @@ def _print_gateway_process_mismatch(snapshot: GatewayRuntimeSnapshot) -> None:
print(" can refuse to start another copy until this process stops.")


def _print_multiplex_standalone_reason() -> None:
"""The boot guard kept an unset-default gateway standalone: say so in status, with the remedy."""
try:
from gateway.status import read_runtime_status
reason = (read_runtime_status() or {}).get("multiplex_standalone_reason")
except Exception:
return
if reason:
print(f"⚠ Serving the default profile only (gateway.multiplex_profiles unset): {reason}")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

On a named-profile gateway this prints "⚠ Serving the default profile only … this is profile 'coder's own gateway" — self-contradictory — and the remedy line lacks the -p <name> qualifier a secondary needs. The named-profile refusal is designed topology but logs at WARNING every boot; INFO (like SINGLE_PROFILE_REASON) + a profile-aware headline would read better.

print(" Fold every profile onto this gateway: hermes gateway migrate --multiplex")
print(" Keep per-profile gateways: hermes config set gateway.multiplex_profiles false")


def _print_served_ingress_urls(profile: str | None = None) -> None:
"""Callback URLs of inbound-port platforms the live multiplexer serves for secondary profiles
(the value to paste into the Twilio / LINE / Teams / BlueBubbles console)."""
Expand Down Expand Up @@ -4451,23 +4464,10 @@ def named_profile_served_by_running_multiplexer(profile_name: str | None = None)
if recorded is not None:
return normalize_profile_name(suffix) in {normalize_profile_name(p) for p in recorded}

from gateway.config import _env_multiplex_profiles_override
cfg_path = default_root / "config.yaml"
cfg = {}
if cfg_path.exists():
from hermes_cli.config import read_user_config_raw
cfg = read_user_config_raw(cfg_path)

env_multiplex = _env_multiplex_profiles_override()
if env_multiplex is False:
return False
if env_multiplex is not True:
if not cfg_path.exists():
return False
if not (cfg.get("multiplex_profiles") or (cfg.get("gateway", {}) or {}).get("multiplex_profiles")):
return False

return True # a multiplexing default gateway serves every named profile
# No record (older gateway): only an EXPLICIT opt-in counts. The unset default is settled by
# the gateway at boot (it may have stayed standalone); a CLI process must not guess it on.
from hermes_cli.gateway_multiplex_mode import explicit_multiplex_flag
return explicit_multiplex_flag(default_root) is True # a multiplexer serves every named profile
except Exception:
logger.debug("Multiplexer-serving probe failed", exc_info=True)
return False
Expand Down Expand Up @@ -6434,13 +6434,15 @@ def _cmd_status(args):
else:
_gw_windows().status(deep=deep)
_print_gateway_process_mismatch(snapshot)
_print_multiplex_standalone_reason()
_print_served_ingress_urls()
else:
pids = list(snapshot.gateway_pids)
if pids:
print(f"✓ Gateway is running (PID: {', '.join(map(str, pids))})")
print(" (Running manually, not as a system service)")
_print_runtime_health()
_print_multiplex_standalone_reason()
_print_served_ingress_urls()
print()
_print_lines(*_STATUS_RUNNING_HINTS[_status_host_kind()])
Expand Down
23 changes: 6 additions & 17 deletions hermes_cli/gateway_enroll.py
Original file line number Diff line number Diff line change
Expand Up @@ -224,24 +224,13 @@ def _warn_if_secondary_multiplex_profile() -> bool:
except ValueError:
return False # default profile or custom layout — not a secondary

# Multiplex precedence mirrors gateway.config: recognized env override wins, else a RAW read
# of the DEFAULT root's config.yaml (the active profile's load_gateway_config() is the wrong
# owner and runs the full enablement pass, whose log output has no place in enroll output).
from gateway.config import _env_multiplex_profiles_override
env_multiplex = _env_multiplex_profiles_override()
if env_multiplex is False:
# The LIVE default gateway's served record, else the operator's explicit flag (env override
# wins, then a RAW read of the DEFAULT root's config.yaml — the active profile's
# load_gateway_config() is the wrong owner and runs the full enablement pass, whose log output
# has no place in enroll output). An unset flag is settled by the gateway at boot, not here.
from hermes_cli.gateway_multiplex_mode import default_gateway_multiplexes
if not default_gateway_multiplexes(default_root):
return False
if env_multiplex is not True:
cfg_path = default_root / "config.yaml"
if not cfg_path.exists():
return False
from hermes_cli.config import read_user_config_raw
cfg = read_user_config_raw(cfg_path) or {}
if not bool(
cfg.get("multiplex_profiles")
or (cfg.get("gateway", {}) or {}).get("multiplex_profiles")
):
return False

print(
" ⚠ This profile is a SECONDARY profile of a multiplexed gateway.\n"
Expand Down
16 changes: 5 additions & 11 deletions hermes_cli/gateway_migrate.py
Original file line number Diff line number Diff line change
Expand Up @@ -250,17 +250,11 @@ def _spawn_detached_gateway(home: Path) -> bool:


def _read_multiplex_flag(default_home: Path) -> bool:
from gateway.config import _env_multiplex_profiles_override
env = _env_multiplex_profiles_override()
if env is not None:
return env
cfg_path = default_home / "config.yaml"
if not cfg_path.exists():
return False
from hermes_cli.config import read_user_config_raw
cfg = read_user_config_raw(cfg_path) or {}
gateway_section = cfg.get("gateway") if isinstance(cfg.get("gateway"), dict) else {}
return bool(cfg.get("multiplex_profiles") or gateway_section.get("multiplex_profiles"))
"""The operator's EXPLICIT opt-in only. The unset default (on) is settled by the default gateway at
boot and refused while a secondary runs its own gateway — exactly the fleet this command folds —
so the plan reads it as "not yet multiplexed" and the migration proceeds."""
from hermes_cli.gateway_multiplex_mode import explicit_multiplex_flag
return explicit_multiplex_flag(default_home) is True


def _write_multiplex_flag(default_home: Path, value: bool) -> None:
Expand Down
Loading
Loading