From e9bd3b6eebf27a9c0f8b7049650b971ad205cba7 Mon Sep 17 00:00:00 2001 From: pierrenode <298902573+pierrenode@users.noreply.github.com> Date: Mon, 6 Jul 2026 20:54:43 +0300 Subject: [PATCH] fix(gateway): honor nested gateway.* form for 9 more top-level keys MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit load_gateway_config() already accepted both the top-level key and the nested gateway. form (written by `hermes config set gateway. ...`) for multiplex_profiles, max_concurrent_sessions, streaming, and write_sessions_json — each fixed one at a time as users hit it (most recently #59320 for multiplex_profiles). Nine sibling top-level keys never got the same nested fallback: session_reset, quick_commands, stt, stt_echo_transcripts, group_sessions_per_user, thread_sessions_per_user, reset_triggers, always_log_local, and unauthorized_dm_behavior. `hermes config set gateway. ...` builds exactly this nested shape (hermes_cli/config.py's _set_nested has no schema, so it accepts any dotted path), so a user following the same pattern that legitimately works for gateway.multiplex_profiles/gateway.streaming gets a silent no-op for these nine keys instead. Fix: read `gateway: {...}` into a single `gateway_section` variable once (consolidating three separate `yaml_cfg.get("gateway")` calls already in the function) and add the same top-level-wins/nested-fallback check for each of the nine keys, mirroring the existing write_sessions_json precedent exactly. Note: because every fallback here is guarded by `isinstance(gateway_section, dict)`, this also makes the streaming fallback tolerate a scalar `gateway:` block (e.g. `gateway: disabled`) without crashing — the same crash #40837 (open) targets specifically for streaming. This change doesn't set out to fix that PR's issue, but the consolidated guard covers it as a side effect; flagging it for the reviewer rather than leaving it to be found in review. --- gateway/config.py | 53 ++++++++--- tests/gateway/test_config.py | 165 +++++++++++++++++++++++++++++++++++ 2 files changed, 204 insertions(+), 14 deletions(-) diff --git a/gateway/config.py b/gateway/config.py index bc2c92387632..4a753c6773b8 100644 --- a/gateway/config.py +++ b/gateway/config.py @@ -1178,13 +1178,25 @@ def load_gateway_config() -> GatewayConfig: from hermes_cli import managed_scope yaml_cfg = managed_scope.apply_managed_overlay(yaml_cfg) + # Shared nested-fallback source: settings meant to be top-level + # keys are also accepted when a user nests them under `gateway:` + # (e.g. via `hermes config set gateway. ...`, which naturally + # produces that shape). Every key below mirrors the precedent + # already established for gateway.multiplex_profiles/streaming/ + # write_sessions_json: top-level wins, nested gateway.* falls back. + gateway_section = yaml_cfg.get("gateway") + # Map config.yaml keys → GatewayConfig.from_dict() schema. # Each key overwrites whatever gateway.json may have set. sr = yaml_cfg.get("session_reset") + if not (sr and isinstance(sr, dict)) and isinstance(gateway_section, dict): + sr = gateway_section.get("session_reset") if sr and isinstance(sr, dict): gw_data["default_reset_policy"] = sr qc = yaml_cfg.get("quick_commands") + if qc is None and isinstance(gateway_section, dict): + qc = gateway_section.get("quick_commands") if qc is not None: if isinstance(qc, dict): gw_data["quick_commands"] = qc @@ -1196,18 +1208,26 @@ def load_gateway_config() -> GatewayConfig: ) stt_cfg = yaml_cfg.get("stt") + if not isinstance(stt_cfg, dict) and isinstance(gateway_section, dict): + stt_cfg = gateway_section.get("stt") if isinstance(stt_cfg, dict): gw_data["stt"] = stt_cfg if "stt_echo_transcripts" in yaml_cfg: gw_data["stt_echo_transcripts"] = yaml_cfg["stt_echo_transcripts"] + elif isinstance(gateway_section, dict) and "stt_echo_transcripts" in gateway_section: + gw_data["stt_echo_transcripts"] = gateway_section["stt_echo_transcripts"] gateway_cfg = yaml_cfg.get("gateway") if "group_sessions_per_user" in yaml_cfg: gw_data["group_sessions_per_user"] = yaml_cfg["group_sessions_per_user"] + elif isinstance(gateway_section, dict) and "group_sessions_per_user" in gateway_section: + gw_data["group_sessions_per_user"] = gateway_section["group_sessions_per_user"] if "thread_sessions_per_user" in yaml_cfg: gw_data["thread_sessions_per_user"] = yaml_cfg["thread_sessions_per_user"] + elif isinstance(gateway_section, dict) and "thread_sessions_per_user" in gateway_section: + gw_data["thread_sessions_per_user"] = gateway_section["thread_sessions_per_user"] # Multiplexing flag: accept both the top-level key and the nested # gateway.multiplex_profiles form (written by @@ -1219,14 +1239,11 @@ def load_gateway_config() -> GatewayConfig: # ``profile_routes`` or the nested ``gateway.profile_routes`` form # (matching the multiplex_profiles parity above). _pr = yaml_cfg.get("profile_routes") - if _pr is None: - _gw_section = yaml_cfg.get("gateway") - if isinstance(_gw_section, dict): - _pr = _gw_section.get("profile_routes") + if _pr is None and isinstance(gateway_section, dict): + _pr = gateway_section.get("profile_routes") if isinstance(_pr, list): gw_data["profile_routes"] = _pr - gateway_section = yaml_cfg.get("gateway") if isinstance(gateway_section, dict): if "multiplex_profiles" in gateway_section and "multiplex_profiles" not in gw_data: # gateway.multiplex_profiles written by `hermes config set gateway.multiplex_profiles true` @@ -1242,41 +1259,49 @@ def load_gateway_config() -> GatewayConfig: gw_data["max_concurrent_sessions"] = yaml_cfg["max_concurrent_sessions"] streaming_cfg = yaml_cfg.get("streaming") - if not isinstance(streaming_cfg, dict): + if not isinstance(streaming_cfg, dict) and isinstance(gateway_section, dict): # Fall back to nested gateway.streaming written by # ``hermes config set gateway.streaming.*`` - streaming_cfg = ( - gateway_cfg.get("streaming") - if isinstance(gateway_cfg, dict) - else None - ) + streaming_cfg = gateway_section.get("streaming") if isinstance(streaming_cfg, dict): gw_data["streaming"] = streaming_cfg if "reset_triggers" in yaml_cfg: gw_data["reset_triggers"] = yaml_cfg["reset_triggers"] + elif isinstance(gateway_section, dict) and "reset_triggers" in gateway_section: + gw_data["reset_triggers"] = gateway_section["reset_triggers"] if "always_log_local" in yaml_cfg: gw_data["always_log_local"] = yaml_cfg["always_log_local"] + elif isinstance(gateway_section, dict) and "always_log_local" in gateway_section: + gw_data["always_log_local"] = gateway_section["always_log_local"] # write_sessions_json: top-level wins; nested gateway.* fallback # (matches the gateway.streaming precedence pattern). - _gw_section = yaml_cfg.get("gateway") if "write_sessions_json" in yaml_cfg: gw_data["write_sessions_json"] = yaml_cfg["write_sessions_json"] - elif isinstance(_gw_section, dict) and "write_sessions_json" in _gw_section: - gw_data["write_sessions_json"] = _gw_section["write_sessions_json"] + elif isinstance(gateway_section, dict) and "write_sessions_json" in gateway_section: + gw_data["write_sessions_json"] = gateway_section["write_sessions_json"] if "filter_silence_narration" in yaml_cfg: gw_data["filter_silence_narration"] = yaml_cfg[ "filter_silence_narration" ] + elif isinstance(gateway_section, dict) and "filter_silence_narration" in gateway_section: + gw_data["filter_silence_narration"] = gateway_section[ + "filter_silence_narration" + ] if "unauthorized_dm_behavior" in yaml_cfg: gw_data["unauthorized_dm_behavior"] = _normalize_unauthorized_dm_behavior( yaml_cfg.get("unauthorized_dm_behavior"), "pair", ) + elif isinstance(gateway_section, dict) and "unauthorized_dm_behavior" in gateway_section: + gw_data["unauthorized_dm_behavior"] = _normalize_unauthorized_dm_behavior( + gateway_section.get("unauthorized_dm_behavior"), + "pair", + ) # Merge platform config into gw_data so runtime-only settings under # ``gateway.platforms`` are loaded the same way as top-level diff --git a/tests/gateway/test_config.py b/tests/gateway/test_config.py index 4ef4def58714..562b2a3e77cf 100644 --- a/tests/gateway/test_config.py +++ b/tests/gateway/test_config.py @@ -662,6 +662,171 @@ class TestLoadGatewayConfig: assert extra["websocket_heartbeat_ack_max_age_seconds"] == 75 assert extra["websocket_max_latency_seconds"] == 30 + def test_session_reset_from_nested_gateway_section(self, tmp_path, monkeypatch): + """``gateway.session_reset`` (nested form) must reach default_reset_policy, + mirroring the gateway.multiplex_profiles precedent.""" + hermes_home = tmp_path / ".hermes" + hermes_home.mkdir() + config_path = hermes_home / "config.yaml" + config_path.write_text( + "gateway:\n session_reset:\n mode: idle\n idle_minutes: 30\n", + encoding="utf-8", + ) + monkeypatch.setenv("HERMES_HOME", str(hermes_home)) + + config = load_gateway_config() + + assert config.default_reset_policy.mode == "idle" + assert config.default_reset_policy.idle_minutes == 30 + + def test_quick_commands_from_nested_gateway_section(self, tmp_path, monkeypatch): + hermes_home = tmp_path / ".hermes" + hermes_home.mkdir() + config_path = hermes_home / "config.yaml" + config_path.write_text( + "gateway:\n quick_commands:\n limits:\n type: exec\n command: echo ok\n", + encoding="utf-8", + ) + monkeypatch.setenv("HERMES_HOME", str(hermes_home)) + + config = load_gateway_config() + + assert config.quick_commands == {"limits": {"type": "exec", "command": "echo ok"}} + + def test_stt_from_nested_gateway_section(self, tmp_path, monkeypatch): + """Asserts False (not the True default) so the test fails if the + nested gateway.stt value never reaches from_dict() and silently + falls back to the class default instead.""" + hermes_home = tmp_path / ".hermes" + hermes_home.mkdir() + config_path = hermes_home / "config.yaml" + config_path.write_text( + "gateway:\n stt:\n enabled: false\n", + encoding="utf-8", + ) + monkeypatch.setenv("HERMES_HOME", str(hermes_home)) + + config = load_gateway_config() + + assert config.stt_enabled is False + + def test_stt_echo_transcripts_from_nested_gateway_section(self, tmp_path, monkeypatch): + hermes_home = tmp_path / ".hermes" + hermes_home.mkdir() + config_path = hermes_home / "config.yaml" + config_path.write_text( + "gateway:\n stt_echo_transcripts: false\n", + encoding="utf-8", + ) + monkeypatch.setenv("HERMES_HOME", str(hermes_home)) + + config = load_gateway_config() + + assert config.stt_echo_transcripts is False + + def test_group_sessions_per_user_from_nested_gateway_section(self, tmp_path, monkeypatch): + hermes_home = tmp_path / ".hermes" + hermes_home.mkdir() + config_path = hermes_home / "config.yaml" + config_path.write_text( + "gateway:\n group_sessions_per_user: false\n", + encoding="utf-8", + ) + monkeypatch.setenv("HERMES_HOME", str(hermes_home)) + + config = load_gateway_config() + + assert config.group_sessions_per_user is False + + def test_thread_sessions_per_user_from_nested_gateway_section(self, tmp_path, monkeypatch): + hermes_home = tmp_path / ".hermes" + hermes_home.mkdir() + config_path = hermes_home / "config.yaml" + config_path.write_text( + "gateway:\n thread_sessions_per_user: true\n", + encoding="utf-8", + ) + monkeypatch.setenv("HERMES_HOME", str(hermes_home)) + + config = load_gateway_config() + + assert config.thread_sessions_per_user is True + + def test_reset_triggers_from_nested_gateway_section(self, tmp_path, monkeypatch): + hermes_home = tmp_path / ".hermes" + hermes_home.mkdir() + config_path = hermes_home / "config.yaml" + config_path.write_text( + "gateway:\n reset_triggers:\n - /new\n - /clear\n", + encoding="utf-8", + ) + monkeypatch.setenv("HERMES_HOME", str(hermes_home)) + + config = load_gateway_config() + + assert config.reset_triggers == ["/new", "/clear"] + + def test_always_log_local_from_nested_gateway_section(self, tmp_path, monkeypatch): + hermes_home = tmp_path / ".hermes" + hermes_home.mkdir() + config_path = hermes_home / "config.yaml" + config_path.write_text( + "gateway:\n always_log_local: false\n", + encoding="utf-8", + ) + monkeypatch.setenv("HERMES_HOME", str(hermes_home)) + + config = load_gateway_config() + + assert config.always_log_local is False + + def test_filter_silence_narration_from_nested_gateway_section(self, tmp_path, monkeypatch): + hermes_home = tmp_path / ".hermes" + hermes_home.mkdir() + config_path = hermes_home / "config.yaml" + config_path.write_text( + "gateway:\n filter_silence_narration: false\n", + encoding="utf-8", + ) + monkeypatch.setenv("HERMES_HOME", str(hermes_home)) + + config = load_gateway_config() + + assert config.filter_silence_narration is False + + def test_unauthorized_dm_behavior_from_nested_gateway_section(self, tmp_path, monkeypatch): + hermes_home = tmp_path / ".hermes" + hermes_home.mkdir() + config_path = hermes_home / "config.yaml" + config_path.write_text( + "gateway:\n unauthorized_dm_behavior: ignore\n", + encoding="utf-8", + ) + monkeypatch.setenv("HERMES_HOME", str(hermes_home)) + + config = load_gateway_config() + + assert config.unauthorized_dm_behavior == "ignore" + + def test_top_level_still_wins_over_nested_gateway_section(self, tmp_path, monkeypatch): + """Top-level keys keep precedence over the nested gateway.* fallback + for every key this fix touches (matches the existing + gateway.streaming/write_sessions_json precedence contract).""" + hermes_home = tmp_path / ".hermes" + hermes_home.mkdir() + config_path = hermes_home / "config.yaml" + config_path.write_text( + "always_log_local: true\n" + "gateway:\n" + " always_log_local: false\n", + encoding="utf-8", + ) + monkeypatch.setenv("HERMES_HOME", str(hermes_home)) + + config = load_gateway_config() + + assert config.always_log_local is True + def test_relay_platform_enabled_from_env_url(self, tmp_path, monkeypatch): """GATEWAY_RELAY_URL must enable Platform.RELAY in config.platforms so start_gateway()'s connect loop actually dials the connector. Registering