"""Tests for gateway configuration management.""" import logging import os from pathlib import Path from unittest.mock import patch import pytest from agent.secret_scope import ( reset_secret_scope, set_multiplex_active, set_secret_scope, ) from hermes_constants import reset_hermes_home_override, set_hermes_home_override from gateway.config import ( ChannelOverride, GatewayConfig, HomeChannel, Platform, PlatformConfig, SessionResetPolicy, StreamingConfig, _apply_env_overrides, load_gateway_config, persist_home_channel, ) class TestHomeChannelRoundtrip: def test_to_dict_from_dict(self): hc = HomeChannel( platform=Platform.DISCORD, chat_id="999", name="general", user_id="user-123", scope_id="guild-456", ) d = hc.to_dict() restored = HomeChannel.from_dict(d) assert restored.platform == Platform.DISCORD assert restored.chat_id == "999" assert restored.name == "general" assert restored.user_id == "user-123" assert restored.scope_id == "guild-456" class TestPlatformConfigRoundtrip: def test_to_dict_from_dict(self): pc = PlatformConfig( enabled=True, token="tok_123", home_channel=HomeChannel( platform=Platform.TELEGRAM, chat_id="555", name="Home", ), extra={"foo": "bar"}, ) d = pc.to_dict() restored = PlatformConfig.from_dict(d) assert restored.enabled is True assert restored.token == "tok_123" assert restored.home_channel.chat_id == "555" assert restored.extra == {"foo": "bar"} def test_disabled_no_token(self): pc = PlatformConfig() d = pc.to_dict() restored = PlatformConfig.from_dict(d) assert restored.enabled is False assert restored.token is None def test_from_dict_coerces_quoted_false_enabled(self): restored = PlatformConfig.from_dict({"enabled": "false"}) assert restored.enabled is False def test_gateway_restart_notification_roundtrip_false(self): pc = PlatformConfig(enabled=True, gateway_restart_notification=False) restored = PlatformConfig.from_dict(pc.to_dict()) assert restored.gateway_restart_notification is False def test_typing_status_text_resolved_from_extra(self): # Same bridge route as typing_indicator: the shared-key loop copies a # nested platforms. value into extra. restored = PlatformConfig.from_dict( {"extra": {"typing_status_text": "chasing yarn…"}} ) assert restored.typing_status_text == "chasing yarn…" def test_channel_overrides_roundtrip(self): pc = PlatformConfig( enabled=True, channel_overrides={ "1234567890": ChannelOverride( model="openrouter/healer-alpha", provider="openrouter", system_prompt="You are a daily news summarizer.", ), "9876543210": ChannelOverride( model="anthropic/claude-opus-4.6", provider="anthropic", system_prompt="You are a coding assistant.", ), }, ) d = pc.to_dict() assert "channel_overrides" in d assert d["channel_overrides"]["1234567890"]["model"] == "openrouter/healer-alpha" assert d["channel_overrides"]["9876543210"]["system_prompt"] == "You are a coding assistant." restored = PlatformConfig.from_dict(d) assert restored.channel_overrides["1234567890"].model == "openrouter/healer-alpha" assert restored.channel_overrides["9876543210"].provider == "anthropic" class TestChannelOverride: def test_from_dict_empty(self): assert ChannelOverride.from_dict({}).model is None assert ChannelOverride.from_dict(None).model is None class TestPlatformConfigMalformedSections: def test_from_dict_ignores_malformed_nested_sections(self): restored = PlatformConfig.from_dict( { "enabled": True, "home_channel": "telegram:123", "extra": "oops", } ) assert restored.enabled is True assert restored.home_channel is None assert restored.extra == {} class TestGetConnectedPlatforms: def test_returns_enabled_with_token(self): config = GatewayConfig( platforms={ Platform.TELEGRAM: PlatformConfig(enabled=True, token="t"), Platform.DISCORD: PlatformConfig(enabled=False, token="d"), Platform.SLACK: PlatformConfig(enabled=True), # no token }, ) connected = config.get_connected_platforms() assert Platform.TELEGRAM in connected assert Platform.DISCORD not in connected assert Platform.SLACK not in connected def test_dingtalk_recognised_via_env_vars(self, monkeypatch): """DingTalk configured via env vars (no extras) should still be recognised as connected — covers the case where _apply_env_overrides hasn't populated extras yet.""" monkeypatch.setenv("DINGTALK_CLIENT_ID", "env_cid") monkeypatch.setenv("DINGTALK_CLIENT_SECRET", "env_sec") config = GatewayConfig( platforms={ Platform.DINGTALK: PlatformConfig(enabled=True, extra={}), }, ) assert Platform.DINGTALK in config.get_connected_platforms() class TestSessionResetPolicy: def test_roundtrip(self): policy = SessionResetPolicy(mode="idle", at_hour=6, idle_minutes=120, bg_process_max_age_hours=48) d = policy.to_dict() restored = SessionResetPolicy.from_dict(d) assert restored.mode == "idle" assert restored.at_hour == 6 assert restored.idle_minutes == 120 assert restored.bg_process_max_age_hours == 48 def test_from_dict_treats_null_values_as_defaults(self): restored = SessionResetPolicy.from_dict( {"mode": None, "at_hour": None, "idle_minutes": None, "bg_process_max_age_hours": None} ) assert restored.mode == "none" assert restored.at_hour == 4 assert restored.idle_minutes == 1440 assert restored.bg_process_max_age_hours == 24 class TestStreamingConfig: def test_from_dict_malformed_numeric_values_fall_back_to_defaults(self): restored = StreamingConfig.from_dict( { "edit_interval": "oops", "buffer_threshold": "oops", "fresh_final_after_seconds": "oops", } ) assert restored.edit_interval == 0.8 assert restored.buffer_threshold == 24 assert restored.fresh_final_after_seconds == 0.0 class TestGatewayConfigRoundtrip: def test_systemd_watchdog_from_dict_disables_invalid_values(self): invalid_values = [ None, 0, -1, True, 1.5, float("nan"), float("inf"), "120.0", "1e3", "bad", 2_147_483_648, ] for raw in invalid_values: config = GatewayConfig.from_dict({"systemd_watchdog_seconds": raw}) assert config.systemd_watchdog_seconds == 0 def test_max_concurrent_sessions_from_dict_ignores_invalid_values(self, caplog): caplog.set_level(logging.WARNING, logger="gateway.config") config = GatewayConfig.from_dict({"max_concurrent_sessions": "many"}) assert config.max_concurrent_sessions is None assert any( "Ignoring invalid max_concurrent_sessions='many'" in record.message for record in caplog.records ) def test_roundtrip_preserves_unauthorized_dm_behavior(self): config = GatewayConfig( unauthorized_dm_behavior="ignore", platforms={ Platform.WHATSAPP: PlatformConfig( enabled=True, extra={"unauthorized_dm_behavior": "pair"}, ), }, ) restored = GatewayConfig.from_dict(config.to_dict()) assert restored.unauthorized_dm_behavior == "ignore" assert restored.platforms[Platform.WHATSAPP].extra["unauthorized_dm_behavior"] == "pair" def test_email_defaults_to_ignore_for_unauthorized_dm_behavior(self): config = GatewayConfig( platforms={Platform.EMAIL: PlatformConfig(enabled=True)}, ) assert config.get_unauthorized_dm_behavior(Platform.EMAIL) == "ignore" def test_email_can_opt_into_pairing_for_unauthorized_dm_behavior(self): config = GatewayConfig( platforms={ Platform.EMAIL: PlatformConfig( enabled=True, extra={"unauthorized_dm_behavior": "pair"}, ), }, ) assert config.get_unauthorized_dm_behavior(Platform.EMAIL) == "pair" class TestLoadGatewayConfig: def test_shipped_template_does_not_enable_auto_reset(self, tmp_path, monkeypatch): """A fresh install seeded from cli-config.yaml.example must not auto-reset sessions. Installers (scripts/install.sh, scripts/install.ps1, docker/stage2-hook.sh, hermes doctor) copy the template verbatim to ~/.hermes/config.yaml, so whatever ``session_reset.mode`` the template ships becomes an EXPLICIT user setting that overrides the code default. After #60194 flipped the default to "none", the template still said "both" — every new install kept 24h-idle resets on (Luciano's report, July 2026). This pins the invariant: template seed == no auto-reset. """ template = ( Path(__file__).resolve().parents[2] / "cli-config.yaml.example" ) hermes_home = tmp_path / ".hermes" hermes_home.mkdir() (hermes_home / "config.yaml").write_text( template.read_text(encoding="utf-8"), encoding="utf-8" ) monkeypatch.setenv("HERMES_HOME", str(hermes_home)) config = load_gateway_config() assert config.default_reset_policy.mode == "none" def test_no_config_yaml_means_no_auto_reset(self, tmp_path, monkeypatch): """With no config.yaml at all, sessions must never auto-reset.""" hermes_home = tmp_path / ".hermes" hermes_home.mkdir() monkeypatch.setenv("HERMES_HOME", str(hermes_home)) config = load_gateway_config() assert config.default_reset_policy.mode == "none" def test_explicit_session_reset_opt_in_is_honored(self, tmp_path, monkeypatch): """Users who explicitly opt in to auto-reset keep their policy.""" hermes_home = tmp_path / ".hermes" hermes_home.mkdir() (hermes_home / "config.yaml").write_text( "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_slack_ignored_channels_config_sets_env_bridge(self, tmp_path, monkeypatch): hermes_home = tmp_path / ".hermes" hermes_home.mkdir() (hermes_home / "config.yaml").write_text( "slack:\n" " ignored_channels:\n" " - C0123456789\n" " - C0987654321\n", encoding="utf-8", ) monkeypatch.setenv("HERMES_HOME", str(hermes_home)) monkeypatch.delenv("SLACK_IGNORED_CHANNELS", raising=False) load_gateway_config() assert os.getenv("SLACK_IGNORED_CHANNELS") == "C0123456789,C0987654321" def test_typing_status_text_from_nested_platforms_block(self, tmp_path, monkeypatch): """``platforms.slack.typing_status_text`` reaches PlatformConfig via _merge_platform_map + the from_dict top-level read.""" hermes_home = tmp_path / ".hermes" hermes_home.mkdir() (hermes_home / "config.yaml").write_text( "platforms:\n" " slack:\n" " enabled: true\n" ' typing_status_text: "chasing yarn…"\n', encoding="utf-8", ) monkeypatch.setenv("HERMES_HOME", str(hermes_home)) config = load_gateway_config() assert ( config.platforms[Platform.SLACK].typing_status_text == "chasing yarn…" ) def test_multiplex_profiles_from_nested_gateway_section(self, tmp_path, monkeypatch): """``gateway.multiplex_profiles: true`` (the nested form written by ``hermes config set gateway.multiplex_profiles true``) must enable multiplexing when loaded via load_gateway_config(). Regression: load_gateway_config() only surfaced the *top-level* ``multiplex_profiles`` key into gw_data, so a config.yaml that pinned the flag under the nested ``gateway:`` section silently loaded with multiplex_profiles=False. (from_dict honors the nested fallback, but load_gateway_config builds gw_data from the top-level keys before calling from_dict, so the nested value never reached it.) """ hermes_home = tmp_path / ".hermes" hermes_home.mkdir() config_path = hermes_home / "config.yaml" config_path.write_text( "gateway:\n multiplex_profiles: true\n", encoding="utf-8", ) monkeypatch.setenv("HERMES_HOME", str(hermes_home)) config = load_gateway_config() assert config.multiplex_profiles is True def test_discord_websocket_health_settings_seed_platform_extra(self, tmp_path, monkeypatch): hermes_home = tmp_path / ".hermes" hermes_home.mkdir() (hermes_home / "config.yaml").write_text( "discord:\n" " websocket_liveness_interval_seconds: 17\n" " websocket_liveness_failure_threshold: 4\n" " websocket_heartbeat_ack_max_age_seconds: 75\n" " websocket_max_latency_seconds: 30\n", encoding="utf-8", ) monkeypatch.setenv("HERMES_HOME", str(hermes_home)) for key in ( "HERMES_DISCORD_LIVENESS_INTERVAL_SECONDS", "HERMES_DISCORD_LIVENESS_FAILURE_THRESHOLD", ): monkeypatch.delenv(key, raising=False) config = load_gateway_config() extra = config.platforms[Platform.DISCORD].extra assert extra["websocket_liveness_interval_seconds"] == 17 assert extra["websocket_liveness_failure_threshold"] == 4 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 @staticmethod def _clear_api_server_env(monkeypatch): """Keep _apply_env_overrides from masking the YAML path under test.""" for key in ( "API_SERVER_ENABLED", "API_SERVER_KEY", "API_SERVER_PORT", "API_SERVER_HOST", "API_SERVER_CORS_ORIGINS", "API_SERVER_MODEL_NAME", ): monkeypatch.delenv(key, raising=False) def test_api_server_from_nested_gateway_section(self, tmp_path, monkeypatch): """``gateway.api_server:`` (nested YAML form) must be discovered and enable the platform. Regression for #66630: load_gateway_config handled gateway.streaming and gateway.platforms.* but silently dropped nested gateway. blocks, so ``gateway.api_server.enabled: true`` never started the API server unless API_SERVER_* env vars were also set. """ self._clear_api_server_env(monkeypatch) hermes_home = tmp_path / ".hermes" hermes_home.mkdir() (hermes_home / "config.yaml").write_text( "gateway:\n api_server:\n enabled: true\n", encoding="utf-8", ) monkeypatch.setenv("HERMES_HOME", str(hermes_home)) config = load_gateway_config() assert Platform.API_SERVER in config.platforms assert config.platforms[Platform.API_SERVER].enabled is True def test_api_server_port_bridged_into_extra(self, tmp_path, monkeypatch): """``gateway.api_server.port`` must land in PlatformConfig.extra — the adapter reads port/key/host/cors_origins/model_name from extra (gateway/platforms/api_server.py), and from_dict discards unknown top-level keys, so without the bridge the port is silently lost.""" self._clear_api_server_env(monkeypatch) hermes_home = tmp_path / ".hermes" hermes_home.mkdir() (hermes_home / "config.yaml").write_text( "gateway:\n" " api_server:\n" " enabled: true\n" " port: 8642\n" " host: 0.0.0.0\n" " key: sekrit\n" " model_name: my-hermes\n", encoding="utf-8", ) monkeypatch.setenv("HERMES_HOME", str(hermes_home)) config = load_gateway_config() extra = config.platforms[Platform.API_SERVER].extra assert extra["port"] == 8642 assert extra["host"] == "0.0.0.0" assert extra["key"] == "sekrit" assert extra["model_name"] == "my-hermes" def test_non_platform_gateway_keys_not_misparsed_as_platforms(self, tmp_path, monkeypatch): """Nested-platform discovery must only pick up keys matching the Platform enum: ``gateway.streaming`` / ``gateway.timeout`` must not be turned into phantom platform entries or break loading.""" self._clear_api_server_env(monkeypatch) hermes_home = tmp_path / ".hermes" hermes_home.mkdir() (hermes_home / "config.yaml").write_text( "gateway:\n" " streaming:\n" " enabled: false\n" " timeout: 120\n" " api_server:\n" " enabled: true\n", encoding="utf-8", ) monkeypatch.setenv("HERMES_HOME", str(hermes_home)) config = load_gateway_config() # streaming still parsed as the streaming subsection, not a platform assert config.streaming.enabled is False # only real platforms present; nothing named streaming/timeout leaked in assert all(isinstance(p, Platform) for p in config.platforms) assert config.platforms[Platform.API_SERVER].enabled is True 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_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_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_present_empty_top_level_session_reset_blocks_nested_fallback(self, tmp_path, monkeypatch): """Key-presence precedence: a present (even empty) top-level session_reset must NOT be replaced by gateway.session_reset — the fallback fires only when the top-level key is absent.""" hermes_home = tmp_path / ".hermes" hermes_home.mkdir() config_path = hermes_home / "config.yaml" config_path.write_text( "session_reset: {}\n" "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() # The nested value must not leak through the present top-level key. assert config.default_reset_policy.mode != "idle" 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 the adapter in the platform_registry is NOT enough — the connect loop iterates config.platforms, so an un-enabled RELAY never connects (the 'relay registered but no inbound' bug).""" hermes_home = tmp_path / ".hermes" hermes_home.mkdir() monkeypatch.setenv("HERMES_HOME", str(hermes_home)) monkeypatch.setenv("GATEWAY_RELAY_URL", "https://connector.example/relay/") config = load_gateway_config() assert Platform.RELAY in config.platforms relay = config.platforms[Platform.RELAY] assert relay.enabled is True # Trailing slash stripped; mirrored into extra for the connected-checker. assert relay.extra.get("relay_url") == "https://connector.example/relay" assert Platform.RELAY in config.get_connected_platforms() def test_thread_require_mention_yaml_does_not_overwrite_env(self, tmp_path, monkeypatch): """Explicit env var should win over config.yaml (env > yaml precedence).""" hermes_home = tmp_path / ".hermes" hermes_home.mkdir() config_path = hermes_home / "config.yaml" config_path.write_text( "discord:\n" " thread_require_mention: false\n", encoding="utf-8", ) monkeypatch.setenv("HERMES_HOME", str(hermes_home)) monkeypatch.setenv("DISCORD_THREAD_REQUIRE_MENTION", "true") # user override load_gateway_config() # Env value preserved, not clobbered by yaml. assert os.environ.get("DISCORD_THREAD_REQUIRE_MENTION") == "true" def test_bridges_nested_gateway_platforms_dingtalk_allowed_users_to_env(self, tmp_path, monkeypatch): """gateway.platforms.dingtalk.extra.allowed_users must reach DINGTALK_ALLOWED_USERS — it's the documented config.yaml alternative to the env var (website/docs/user-guide/messaging/dingtalk.md), the adapter reads it from PlatformConfig.extra, but gateway auth (_is_user_authorized) only consults the env var. """ hermes_home = tmp_path / ".hermes" hermes_home.mkdir() config_path = hermes_home / "config.yaml" config_path.write_text( "gateway:\n" " platforms:\n" " dingtalk:\n" " enabled: true\n" " extra:\n" " allowed_users:\n" " - user-id-1\n" " - user-id-2\n", encoding="utf-8", ) monkeypatch.setenv("HERMES_HOME", str(hermes_home)) monkeypatch.delenv("DINGTALK_ALLOWED_USERS", raising=False) config = load_gateway_config() assert config.platforms[Platform.DINGTALK].extra["allowed_users"] == [ "user-id-1", "user-id-2", ] assert os.environ.get("DINGTALK_ALLOWED_USERS") == "user-id-1,user-id-2" def test_top_level_platforms_override_nested_gateway_platforms(self, tmp_path, monkeypatch): hermes_home = tmp_path / ".hermes" hermes_home.mkdir() config_path = hermes_home / "config.yaml" config_path.write_text( "gateway:\n" " platforms:\n" " telegram:\n" " enabled: false\n" " token: nested-token\n" " extra:\n" " reply_prefix: nested\n" "platforms:\n" " telegram:\n" " enabled: true\n" " token: top-token\n" " extra:\n" " reply_prefix: top\n", encoding="utf-8", ) monkeypatch.setenv("HERMES_HOME", str(hermes_home)) config = load_gateway_config() telegram = config.platforms[Platform.TELEGRAM] assert telegram.enabled is True assert telegram.token == "top-token" assert telegram.extra["reply_prefix"] == "top" def test_shared_key_loop_bridges_allow_from_from_nested_platforms(self, tmp_path, monkeypatch): """Regression: shared-key loop must bridge allow_from / require_mention into PlatformConfig.extra even when the platform is configured only under ``platforms:`` (no top-level ``telegram:`` block). Before the fix, ``platform_cfg = yaml_cfg.get('telegram')`` returned None for nested-only configs, so the loop skipped the platform entirely and allow_from was silently ignored. The apply_yaml_config_fn dispatch received the same fix in #44f3e51; the shared-key loop now mirrors it. """ hermes_home = tmp_path / ".hermes" hermes_home.mkdir() config_path = hermes_home / "config.yaml" config_path.write_text( "platforms:\n" " telegram:\n" " allow_from:\n" " - \"111222333\"\n" " - \"444555666\"\n" " require_mention: true\n", encoding="utf-8", ) monkeypatch.setenv("HERMES_HOME", str(hermes_home)) config = load_gateway_config() telegram = config.platforms[Platform.TELEGRAM] assert telegram.extra.get("allow_from") == ["111222333", "444555666"], ( "allow_from configured under platforms.telegram must be bridged " "into PlatformConfig.extra by the shared-key loop" ) assert telegram.extra.get("require_mention") is True, ( "require_mention configured under platforms.telegram must be " "bridged into PlatformConfig.extra by the shared-key loop" ) def test_bridges_unauthorized_dm_behavior_from_config_yaml(self, tmp_path, monkeypatch): hermes_home = tmp_path / ".hermes" hermes_home.mkdir() config_path = hermes_home / "config.yaml" config_path.write_text( "unauthorized_dm_behavior: ignore\n" "whatsapp:\n" " unauthorized_dm_behavior: pair\n", encoding="utf-8", ) monkeypatch.setenv("HERMES_HOME", str(hermes_home)) config = load_gateway_config() assert config.unauthorized_dm_behavior == "ignore" assert config.platforms[Platform.WHATSAPP].extra["unauthorized_dm_behavior"] == "pair" def test_loads_telegram_rich_messages_from_gateway_platform_extra(self, tmp_path, monkeypatch): hermes_home = tmp_path / ".hermes" hermes_home.mkdir() config_path = hermes_home / "config.yaml" config_path.write_text( "gateway:\n" " platforms:\n" " telegram:\n" " extra:\n" " rich_messages: false\n", encoding="utf-8", ) monkeypatch.setenv("HERMES_HOME", str(hermes_home)) config = load_gateway_config() assert config.platforms[Platform.TELEGRAM].extra["rich_messages"] is False def test_telegram_proxy_env_takes_precedence_over_config(self, tmp_path, monkeypatch): hermes_home = tmp_path / ".hermes" hermes_home.mkdir() config_path = hermes_home / "config.yaml" config_path.write_text( "telegram:\n" " proxy_url: http://from-config:8080\n", encoding="utf-8", ) monkeypatch.setenv("HERMES_HOME", str(hermes_home)) monkeypatch.setenv("TELEGRAM_PROXY", "socks5://from-env:1080") load_gateway_config() import os assert os.environ.get("TELEGRAM_PROXY") == "socks5://from-env:1080" def test_profile_scoped_env_overrides_do_not_fall_back_to_default_profile_env( self, tmp_path, monkeypatch, ): default_home = tmp_path / "default-home" default_home.mkdir() default_config = default_home / "config.yaml" default_config.write_text( "multiplex_profiles: true\n", encoding="utf-8", ) secondary_home = tmp_path / "secondary-home" secondary_home.mkdir() secondary_config = secondary_home / "config.yaml" secondary_config.write_text( "multiplex_profiles: true\n", encoding="utf-8", ) monkeypatch.setenv("HERMES_HOME", str(default_home)) monkeypatch.setenv("API_SERVER_ENABLED", "true") monkeypatch.setenv("DISCORD_BOT_TOKEN", "default-token") # Model the real multiplexed gateway: run.py flips the runtime flag at # startup (set_multiplex_active) BEFORE any secondary-profile config # loads. Without the flag, a scope miss legitimately falls through to # os.environ (single-profile overlay semantics, #67827) and this test # would no longer exercise the cross-profile isolation it's about. set_multiplex_active(True) home_token = set_hermes_home_override(str(secondary_home)) secret_token = set_secret_scope({"DISCORD_BOT_TOKEN": "worker-token"}) try: config = load_gateway_config() finally: reset_secret_scope(secret_token) reset_hermes_home_override(home_token) set_multiplex_active(False) assert config.multiplex_profiles is True assert config.platforms[Platform.DISCORD].token == "worker-token" assert Platform.API_SERVER not in config.platforms class TestWebhookPortBridging: """Top-level port/host in the YAML platform section must be bridged into PlatformConfig.extra so the webhook/api_server adapters can read them. The adapters (WebhookAdapter, ApiServerAdapter) read port/host from ``config.extra``, but users naturally write them at the top level of the platform section in config.yaml: platforms: webhook: enabled: true host: 0.0.0.0 port: 8649 Without bridging, the port silently falls back to DEFAULT_PORT (8644), causing port conflicts between profiles that configure different ports.""" def test_webhook_port_bridged_from_toplevel(self, tmp_path, monkeypatch): hermes_home = tmp_path / ".hermes" hermes_home.mkdir() config_path = hermes_home / "config.yaml" config_path.write_text( "platforms:\n" " webhook:\n" " enabled: true\n" " host: 0.0.0.0\n" " port: 8649\n", encoding="utf-8", ) monkeypatch.setenv("HERMES_HOME", str(hermes_home)) monkeypatch.delenv("WEBHOOK_ENABLED", raising=False) monkeypatch.delenv("WEBHOOK_PORT", raising=False) config = load_gateway_config() assert Platform.WEBHOOK in config.platforms wh = config.platforms[Platform.WEBHOOK] assert wh.enabled is True assert wh.extra.get("port") == 8649 assert wh.extra.get("host") == "0.0.0.0" def test_msgraph_webhook_port_host_secret_bridged_from_toplevel(self, tmp_path, monkeypatch): """msgraph_webhook top-level port/host/secret must be bridged into extra, with an explicit extra: value still winning over the top-level one.""" hermes_home = tmp_path / ".hermes" hermes_home.mkdir() config_path = hermes_home / "config.yaml" config_path.write_text( "platforms:\n" " msgraph_webhook:\n" " enabled: true\n" " host: 0.0.0.0\n" " port: 8651\n" " secret: toplevel-secret\n" " extra:\n" " client_state: my-client-state\n" " secret: extra-secret\n", encoding="utf-8", ) monkeypatch.setenv("HERMES_HOME", str(hermes_home)) monkeypatch.delenv("MSGRAPH_WEBHOOK_ENABLED", raising=False) monkeypatch.delenv("MSGRAPH_WEBHOOK_PORT", raising=False) monkeypatch.delenv("MSGRAPH_WEBHOOK_CLIENT_STATE", raising=False) config = load_gateway_config() assert Platform.MSGRAPH_WEBHOOK in config.platforms ms = config.platforms[Platform.MSGRAPH_WEBHOOK] assert ms.enabled is True assert ms.extra.get("port") == 8651 assert ms.extra.get("host") == "0.0.0.0" # explicit extra: wins over top-level assert ms.extra.get("secret") == "extra-secret" assert ms.extra.get("client_state") == "my-client-state" class TestHomeChannelEnvOverrides: """Home channel env vars should apply even when the platform was already configured via config.yaml (not just when credential env vars create it).""" def test_existing_platform_configs_accept_home_channel_env_overrides(self): cases = [ ( Platform.SLACK, PlatformConfig(enabled=True, token="xoxb-from-config"), {"SLACK_HOME_CHANNEL": "C123", "SLACK_HOME_CHANNEL_NAME": "Ops"}, ("C123", "Ops"), ), ( Platform.WHATSAPP, PlatformConfig(enabled=True), { "WHATSAPP_HOME_CHANNEL": "1234567890@lid", "WHATSAPP_HOME_CHANNEL_NAME": "Owner DM", }, ("1234567890@lid", "Owner DM"), ), ( Platform.SIGNAL, PlatformConfig( enabled=True, extra={"http_url": "http://localhost:9090", "account": "+15551234567"}, ), {"SIGNAL_HOME_CHANNEL": "+1555000", "SIGNAL_HOME_CHANNEL_NAME": "Phone"}, ("+1555000", "Phone"), ), ( Platform.MATTERMOST, PlatformConfig( enabled=True, token="mm-token", extra={"url": "https://mm.example.com"}, ), {"MATTERMOST_HOME_CHANNEL": "ch_abc123", "MATTERMOST_HOME_CHANNEL_NAME": "General"}, ("ch_abc123", "General"), ), ( Platform.MATRIX, PlatformConfig( enabled=True, token="syt_abc123", extra={"homeserver": "https://matrix.example.org"}, ), {"MATRIX_HOME_ROOM": "!room123:example.org", "MATRIX_HOME_ROOM_NAME": "Bot Room"}, ("!room123:example.org", "Bot Room"), ), ( Platform.EMAIL, PlatformConfig( enabled=True, extra={ "address": "hermes@test.com", "imap_host": "imap.test.com", "smtp_host": "smtp.test.com", }, ), {"EMAIL_HOME_ADDRESS": "user@test.com", "EMAIL_HOME_ADDRESS_NAME": "Inbox"}, ("user@test.com", "Inbox"), ), ( Platform.SMS, PlatformConfig(enabled=True, api_key="token_abc"), {"SMS_HOME_CHANNEL": "+15559876543", "SMS_HOME_CHANNEL_NAME": "My Phone"}, ("+15559876543", "My Phone"), ), ] for platform, platform_config, env, expected in cases: config = GatewayConfig(platforms={platform: platform_config}) with patch.dict(os.environ, env, clear=True): _apply_env_overrides(config) 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_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 # ── 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 # ── Tier 3: default False ───────────────────────────────────────────── # ── The resolver in isolation ───────────────────────────────────────── class TestMultiplexProfilesConfig: """Tests for parsing multiplex_profiles (top-level and nested forms).""" def test_multiplex_profiles_nested_under_gateway(self, tmp_path, monkeypatch): """gateway.multiplex_profiles (the form written by `hermes config set gateway.multiplex_profiles true`) must be honored. Regression test for the silent-fallback bug where the loader only forwarded the top-level key, so users who wrote it under gateway: got multiplex_profiles=False with no warning.""" hermes_home = tmp_path / ".hermes" hermes_home.mkdir() (hermes_home / "config.yaml").write_text( "gateway:\n multiplex_profiles: true\n", encoding="utf-8", ) monkeypatch.setenv("HERMES_HOME", str(hermes_home)) config = load_gateway_config() assert config.multiplex_profiles is True, ( "gateway.multiplex_profiles: true was silently ignored — " "loader only forwarded the top-level form" ) def test_multiplex_profiles_explicit_top_level_false_not_consulting_nested( self, tmp_path, monkeypatch ): """Lock in the `is None` vs `is False` distinction: when top-level is explicitly false, the loader must forward False WITHOUT consulting the nested form (so a stale `gateway.multiplex_profiles: true` cannot silently re-enable multiplexing). Guards against a future regression that flips the check to `not _mp`.""" hermes_home = tmp_path / ".hermes" hermes_home.mkdir() (hermes_home / "config.yaml").write_text( "multiplex_profiles: false\n" "gateway:\n multiplex_profiles: true\n", encoding="utf-8", ) monkeypatch.setenv("HERMES_HOME", str(hermes_home)) config = load_gateway_config() assert config.multiplex_profiles is False, ( "Explicit top-level false was overridden by nested true — " "loader must respect top-level precedence when key is present" ) class TestApiServerEnvOverride: def test_env_key_does_not_reenable_explicitly_disabled_api_server(self): """An explicit ``platforms.api_server.enabled: false`` must survive _apply_env_overrides() even when API_SERVER_KEY is present in the env. Regression: _apply_env_overrides() force-set api_server.enabled = True whenever API_SERVER_KEY (or API_SERVER_ENABLED) was set. In multiplex mode a secondary profile pins ``api_server.enabled: false`` so it shares the default profile's listener instead of binding its own port, but it still inherits the process-level API_SERVER_KEY. The unconditional re-enable flipped it back on and tripped the MultiplexConfigError check. The fix honors the explicit disable, flagged by ``_enabled_explicit`` in the platform's extra (set when the config.yaml pins enabled). """ config = GatewayConfig( platforms={ Platform.API_SERVER: PlatformConfig( enabled=False, extra={"_enabled_explicit": True}, ), }, ) api_server_key = "secret-key-at-least-16" with patch.dict(os.environ, {"API_SERVER_KEY": api_server_key}, clear=True): _apply_env_overrides(config) # Explicit disable wins over the env-var presence. assert config.platforms[Platform.API_SERVER].enabled is False # The key is still wired through for the shared listener. assert config.platforms[Platform.API_SERVER].extra.get("key") == api_server_key