fix(gateway): honor nested gateway.* form for 9 more top-level keys

load_gateway_config() already accepted both the top-level key and the
nested gateway.<key> form (written by `hermes config set gateway.<key>
...`) 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.<any-of-these> ...` 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.
This commit is contained in:
pierrenode 2026-07-06 20:54:43 +03:00 • committed by Teknium
parent c8027d5e60
commit e9bd3b6eeb
2 changed files with 204 additions and 14 deletions

View file

@ -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.<key> ...`, 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

View file

@ -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