diff --git a/gateway/config.py b/gateway/config.py index 63b321d9b65..8c467196e3b 100644 --- a/gateway/config.py +++ b/gateway/config.py @@ -37,6 +37,43 @@ def _coerce_bool(value: Any, default: bool = True) -> bool: return is_truthy_value(value, default=default) +# Recognized truthy / falsy tokens for the GATEWAY_MULTIPLEX_PROFILES operator +# override. Anything not in either set — and a blank/whitespace value — is +# treated as "unset" so it falls through to config.yaml rather than silently +# forcing the flag off. +_MULTIPLEX_TRUTHY_STRINGS = frozenset({"1", "true", "yes", "on"}) +_MULTIPLEX_FALSY_STRINGS = frozenset({"0", "false", "no", "off"}) + + +def _env_multiplex_profiles_override() -> "bool | None": + """Resolve the GATEWAY_MULTIPLEX_PROFILES operator override. + + Returns ``True``/``False`` when the env var is set to a recognized truthy/ + falsy token, or ``None`` when it is unset, blank, or unrecognized — in which + case the caller keeps the config.yaml value (env > config > default). Blank + is deliberately ``None``, not ``False``: a provisioned-but-unpopulated Fly + secret arrives as ``""`` and must NOT shadow a config.yaml opt-in. + """ + raw = os.getenv("GATEWAY_MULTIPLEX_PROFILES") + if raw is None: + return None + token = raw.strip().lower() + if not token: + return None + if token in _MULTIPLEX_TRUTHY_STRINGS: + return True + if token in _MULTIPLEX_FALSY_STRINGS: + return False + logger.warning( + "Ignoring unrecognized GATEWAY_MULTIPLEX_PROFILES=%r " + "(expected one of %s or %s); falling back to config.yaml.", + raw, + sorted(_MULTIPLEX_TRUTHY_STRINGS), + sorted(_MULTIPLEX_FALSY_STRINGS), + ) + return None + + def _coerce_float(value: Any, default: float) -> float: """Coerce numeric config values, falling back on malformed input.""" if value is None: @@ -837,6 +874,18 @@ class GatewayConfig: # Also honor gateway.multiplex_profiles written by # ``hermes config set gateway.multiplex_profiles true``. multiplex_profiles = nested_gateway.get("multiplex_profiles") + # Operator override: GATEWAY_MULTIPLEX_PROFILES wins over config.yaml when + # set to a recognized value. Hosted deployments (Nous Portal / Fly) stamp + # it on the container so the single multiplexed gateway — which the + # connector now depends on for per-profile relay routing — is forced on at + # every boot regardless of the image's config.yaml, while self-hosted + # users keep setting gateway.multiplex_profiles in config.yaml. A blank or + # unrecognized env value falls through to config (the empty-secret trap: + # a provisioned-but-unpopulated Fly secret must not shadow config), so + # this is a genuine 3-tier chain: env > config.yaml > default False. + env_multiplex = _env_multiplex_profiles_override() + if env_multiplex is not None: + multiplex_profiles = env_multiplex if "max_concurrent_sessions" in data: max_concurrent_raw = data.get("max_concurrent_sessions") max_concurrent_key = "max_concurrent_sessions" diff --git a/hermes_cli/gateway.py b/hermes_cli/gateway.py index b04652de8fb..ab743b281f1 100644 --- a/hermes_cli/gateway.py +++ b/hermes_cli/gateway.py @@ -4488,16 +4488,29 @@ def _guard_named_profile_under_multiplexer(force: bool = False) -> None: if not pid or not _pid_exists(pid): return - # (c) default config has multiplexing on - cfg_path = default_root / "config.yaml" - if not cfg_path.exists(): - return - with open(cfg_path, encoding="utf-8") as f: - cfg = _yaml.safe_load(f) or {} - multiplex = bool( - cfg.get("multiplex_profiles") - or (cfg.get("gateway", {}) or {}).get("multiplex_profiles") - ) + # (c) multiplexing is on for the default gateway. Precedence mirrors + # gateway.config: the GATEWAY_MULTIPLEX_PROFILES env override wins over + # config.yaml when set to a recognized value, so a hosted gateway that + # forces multiplex on via env (with no multiplex_profiles in config.yaml) + # still trips this guard. A blank/unrecognized env value falls through + # to config.yaml. + from gateway.config import _env_multiplex_profiles_override + + env_multiplex = _env_multiplex_profiles_override() + if env_multiplex is False: + return # explicitly forced OFF by the operator env override + if env_multiplex is True: + multiplex = True + else: + cfg_path = default_root / "config.yaml" + if not cfg_path.exists(): + return + with open(cfg_path, encoding="utf-8") as f: + cfg = _yaml.safe_load(f) or {} + multiplex = bool( + cfg.get("multiplex_profiles") + or (cfg.get("gateway", {}) or {}).get("multiplex_profiles") + ) if not multiplex: return except Exception: diff --git a/tests/gateway/test_config.py b/tests/gateway/test_config.py index b9d4993b5f0..6dc246e83db 100644 --- a/tests/gateway/test_config.py +++ b/tests/gateway/test_config.py @@ -1315,3 +1315,96 @@ class TestHomeChannelEnvOverrides: home = config.platforms[platform].home_channel assert home is not None, f"{platform.value}: home_channel should not be None" assert (home.chat_id, home.name) == expected, platform.value + + +class TestMultiplexProfilesEnvOverride: + """GATEWAY_MULTIPLEX_PROFILES env override — the 3-tier precedence chain. + + env (recognized token) > config.yaml (top-level or nested gateway.*) > + default False. A blank / unrecognized env value is treated as UNSET and + falls through to config (the empty-secret trap: a provisioned-but-empty Fly + secret arrives as "" and must not shadow a config.yaml opt-in). + """ + + def _load(self, tmp_path, monkeypatch, config_text=None): + hermes_home = tmp_path / ".hermes" + hermes_home.mkdir(exist_ok=True) + if config_text is not None: + (hermes_home / "config.yaml").write_text(config_text, encoding="utf-8") + monkeypatch.setenv("HERMES_HOME", str(hermes_home)) + return load_gateway_config() + + # ── Tier 1: env wins ────────────────────────────────────────────────── + def test_env_true_forces_on_with_no_config(self, tmp_path, monkeypatch): + monkeypatch.setenv("GATEWAY_MULTIPLEX_PROFILES", "true") + config = self._load(tmp_path, monkeypatch, config_text=None) + assert config.multiplex_profiles is True + + def test_env_true_overrides_config_false(self, tmp_path, monkeypatch): + # THE discriminating test: env-set wins over an explicit config value. + monkeypatch.setenv("GATEWAY_MULTIPLEX_PROFILES", "1") + config = self._load( + tmp_path, monkeypatch, config_text="multiplex_profiles: false\n" + ) + assert config.multiplex_profiles is True + + def test_env_false_overrides_config_true(self, tmp_path, monkeypatch): + monkeypatch.setenv("GATEWAY_MULTIPLEX_PROFILES", "off") + config = self._load( + tmp_path, monkeypatch, config_text="multiplex_profiles: true\n" + ) + assert config.multiplex_profiles is False + + # ── Tier 2: config.yaml when env unset ──────────────────────────────── + def test_config_true_when_env_unset(self, tmp_path, monkeypatch): + monkeypatch.delenv("GATEWAY_MULTIPLEX_PROFILES", raising=False) + config = self._load( + tmp_path, monkeypatch, config_text="multiplex_profiles: true\n" + ) + assert config.multiplex_profiles is True + + # ── The empty / unrecognized env trap: fall through, don't force off ── + def test_empty_env_does_not_shadow_config_true(self, tmp_path, monkeypatch): + # Provisioned-but-unpopulated Fly secret arrives as "". It must NOT + # turn OFF a config.yaml opt-in. + monkeypatch.setenv("GATEWAY_MULTIPLEX_PROFILES", "") + config = self._load( + tmp_path, monkeypatch, config_text="multiplex_profiles: true\n" + ) + assert config.multiplex_profiles is True + + def test_whitespace_env_does_not_shadow_config_true(self, tmp_path, monkeypatch): + monkeypatch.setenv("GATEWAY_MULTIPLEX_PROFILES", " ") + config = self._load( + tmp_path, monkeypatch, config_text="multiplex_profiles: true\n" + ) + assert config.multiplex_profiles is True + + def test_unrecognized_env_falls_through_to_config(self, tmp_path, monkeypatch): + monkeypatch.setenv("GATEWAY_MULTIPLEX_PROFILES", "maybe") + config = self._load( + tmp_path, monkeypatch, config_text="multiplex_profiles: true\n" + ) + assert config.multiplex_profiles is True + + # ── Tier 3: default False ───────────────────────────────────────────── + def test_default_false_when_neither_set(self, tmp_path, monkeypatch): + monkeypatch.delenv("GATEWAY_MULTIPLEX_PROFILES", raising=False) + config = self._load(tmp_path, monkeypatch, config_text=None) + assert config.multiplex_profiles is False + + # ── The resolver in isolation ───────────────────────────────────────── + def test_resolver_tristate(self, monkeypatch): + from gateway.config import _env_multiplex_profiles_override + + monkeypatch.delenv("GATEWAY_MULTIPLEX_PROFILES", raising=False) + assert _env_multiplex_profiles_override() is None + for truthy in ("1", "true", "TRUE", "yes", "on"): + monkeypatch.setenv("GATEWAY_MULTIPLEX_PROFILES", truthy) + assert _env_multiplex_profiles_override() is True, truthy + for falsy in ("0", "false", "FALSE", "no", "off"): + monkeypatch.setenv("GATEWAY_MULTIPLEX_PROFILES", falsy) + assert _env_multiplex_profiles_override() is False, falsy + for noise in ("", " ", "maybe", "2"): + monkeypatch.setenv("GATEWAY_MULTIPLEX_PROFILES", noise) + assert _env_multiplex_profiles_override() is None, repr(noise) diff --git a/tests/gateway/test_multiplex_lifecycle.py b/tests/gateway/test_multiplex_lifecycle.py index 6b5da5d9c38..cb4b9763e5c 100644 --- a/tests/gateway/test_multiplex_lifecycle.py +++ b/tests/gateway/test_multiplex_lifecycle.py @@ -53,3 +53,52 @@ class TestNamedProfileMultiplexerGuard: ) # No gateway.pid in tmp_path => no running default gateway => no raise. gw._guard_named_profile_under_multiplexer(force=False) + + def _fake_running_default_gateway(self, monkeypatch, tmp_path): + """Make the guard believe a live default gateway exists at tmp_path.""" + from hermes_cli import gateway as gw + import gateway.status as status + + monkeypatch.setattr(gw, "_profile_suffix", lambda: "coder") + monkeypatch.setattr( + "hermes_constants.get_default_hermes_root", lambda: tmp_path + ) + (tmp_path / "gateway.pid").write_text("12345", encoding="utf-8") + monkeypatch.setattr(status, "_read_pid_record", lambda p: {"pid": 12345}) + monkeypatch.setattr(status, "_pid_from_record", lambda rec: 12345) + monkeypatch.setattr(status, "_pid_exists", lambda pid: True) + + def test_env_forces_guard_even_without_config(self, monkeypatch, tmp_path): + """GATEWAY_MULTIPLEX_PROFILES=true must trip the guard even when the + default profile's config.yaml has no multiplex_profiles key — the hosted + case where multiplex is forced purely by the env stamp.""" + from hermes_cli import gateway as gw + self._fake_running_default_gateway(monkeypatch, tmp_path) + # No config.yaml written → the only signal is the env override. + monkeypatch.setenv("GATEWAY_MULTIPLEX_PROFILES", "true") + with pytest.raises(SystemExit): + gw._guard_named_profile_under_multiplexer(force=False) + + def test_env_false_disables_guard_over_config_true(self, monkeypatch, tmp_path): + """GATEWAY_MULTIPLEX_PROFILES=false wins over a config.yaml opt-in, so + the guard stays inert (symmetric with the config precedence).""" + from hermes_cli import gateway as gw + self._fake_running_default_gateway(monkeypatch, tmp_path) + (tmp_path / "config.yaml").write_text( + "multiplex_profiles: true\n", encoding="utf-8" + ) + monkeypatch.setenv("GATEWAY_MULTIPLEX_PROFILES", "false") + # Env forces OFF → guard must NOT raise. + gw._guard_named_profile_under_multiplexer(force=False) + + def test_blank_env_falls_through_to_config_and_raises(self, monkeypatch, tmp_path): + """A blank env value must not shadow a config.yaml opt-in: the guard + still trips on the config value.""" + from hermes_cli import gateway as gw + self._fake_running_default_gateway(monkeypatch, tmp_path) + (tmp_path / "config.yaml").write_text( + "multiplex_profiles: true\n", encoding="utf-8" + ) + monkeypatch.setenv("GATEWAY_MULTIPLEX_PROFILES", "") + with pytest.raises(SystemExit): + gw._guard_named_profile_under_multiplexer(force=False)