diff --git a/gateway/config.py b/gateway/config.py index 8c467196e3b..ce8b3018102 100644 --- a/gateway/config.py +++ b/gateway/config.py @@ -130,6 +130,11 @@ def _coerce_optional_positive_int(value: Any, key: str) -> Optional[int]: return parsed +def _coerce_dict(value: Any) -> Dict[str, Any]: + """Return *value* when it is a mapping, otherwise an empty dict.""" + return value if isinstance(value, dict) else {} + + def _normalize_unauthorized_dm_behavior(value: Any, default: str = "pair") -> str: """Normalize unauthorized DM behavior to a supported value.""" if isinstance(value, str): @@ -383,6 +388,7 @@ class SessionResetPolicy: @classmethod def from_dict(cls, data: Dict[str, Any]) -> "SessionResetPolicy": + data = _coerce_dict(data) # Handle both missing keys and explicit null values (YAML null → None) mode = data.get("mode") at_hour = data.get("at_hour") @@ -492,24 +498,26 @@ class PlatformConfig: @classmethod def from_dict(cls, data: Dict[str, Any]) -> "PlatformConfig": + data = _coerce_dict(data) home_channel = None - if "home_channel" in data: + if isinstance(data.get("home_channel"), dict): home_channel = HomeChannel.from_dict(data["home_channel"]) # gateway_restart_notification may be bridged into extra via the # shared-key loop in load_gateway_config(); check both top-level # and extra so YAML ``discord: gateway_restart_notification: false`` # works without needing a separate platforms: block. + extra = _coerce_dict(data.get("extra", {})) _grn = data.get("gateway_restart_notification") if _grn is None: - _grn = data.get("extra", {}).get("gateway_restart_notification") + _grn = extra.get("gateway_restart_notification") # typing_indicator mirrors gateway_restart_notification: it may arrive # top-level or bridged into extra by the shared-key loop in # load_gateway_config(), so check both. _typing = data.get("typing_indicator") if _typing is None: - _typing = data.get("extra", {}).get("typing_indicator") + _typing = extra.get("typing_indicator") channel_overrides: Dict[str, ChannelOverride] = {} raw_overrides = data.get("channel_overrides") or {} @@ -527,7 +535,7 @@ class PlatformConfig: gateway_restart_notification=_coerce_bool(_grn, True), typing_indicator=_coerce_bool(_typing, True), channel_overrides=channel_overrides, - extra=data.get("extra", {}), + extra=extra, ) @@ -586,7 +594,7 @@ class StreamingConfig: @classmethod def from_dict(cls, data: Dict[str, Any]) -> "StreamingConfig": - if not data: + if not isinstance(data, dict) or not data: return cls() return cls( enabled=_coerce_bool(data.get("enabled"), False), @@ -823,8 +831,12 @@ class GatewayConfig: @classmethod def from_dict(cls, data: Dict[str, Any]) -> "GatewayConfig": + data = _coerce_dict(data) platforms = {} - for platform_name, platform_data in data.get("platforms", {}).items(): + platforms_data = _coerce_dict(data.get("platforms", {})) + for platform_name, platform_data in platforms_data.items(): + if not isinstance(platform_data, dict): + continue try: platform = Platform(platform_name) platforms[platform] = PlatformConfig.from_dict(platform_data) @@ -832,11 +844,11 @@ class GatewayConfig: pass # Skip unknown platforms reset_by_type = {} - for type_name, policy_data in data.get("reset_by_type", {}).items(): + for type_name, policy_data in _coerce_dict(data.get("reset_by_type", {})).items(): reset_by_type[type_name] = SessionResetPolicy.from_dict(policy_data) reset_by_platform = {} - for platform_name, policy_data in data.get("reset_by_platform", {}).items(): + for platform_name, policy_data in _coerce_dict(data.get("reset_by_platform", {})).items(): try: platform = Platform(platform_name) reset_by_platform[platform] = SessionResetPolicy.from_dict(policy_data) diff --git a/tests/gateway/test_config.py b/tests/gateway/test_config.py index 6dc246e83db..aa51c2fba54 100644 --- a/tests/gateway/test_config.py +++ b/tests/gateway/test_config.py @@ -142,6 +142,21 @@ class TestChannelOverride: assert d["system_prompt"] == "Hi" +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( @@ -238,6 +253,12 @@ class TestSessionResetPolicy: restored = SessionResetPolicy.from_dict({"notify": "false"}) assert restored.notify is False + def test_from_dict_malformed_section_falls_back_to_defaults(self): + restored = SessionResetPolicy.from_dict("oops") + assert restored.mode == SessionResetPolicy().mode + assert restored.at_hour == 4 + assert restored.idle_minutes == 1440 + class TestStreamingConfig: def test_defaults_to_auto_transport(self): @@ -263,6 +284,11 @@ class TestStreamingConfig: assert restored.buffer_threshold == 24 assert restored.fresh_final_after_seconds == 0.0 + def test_from_dict_malformed_section_falls_back_to_defaults(self): + restored = StreamingConfig.from_dict("enabled") + assert restored.enabled is False + assert restored.transport == "auto" + class TestGatewayConfigRoundtrip: def test_full_roundtrip(self): @@ -365,6 +391,27 @@ class TestGatewayConfigRoundtrip: restored = GatewayConfig.from_dict({"always_log_local": "false"}) assert restored.always_log_local is False + def test_from_dict_ignores_malformed_nested_sections(self): + restored = GatewayConfig.from_dict( + { + "platforms": { + "telegram": "enabled", + "discord": {"enabled": True, "token": "tok"}, + }, + "default_reset_policy": "daily", + "reset_by_type": ["oops"], + "reset_by_platform": "oops", + "streaming": "enabled", + } + ) + + assert Platform.TELEGRAM not in restored.platforms + assert restored.platforms[Platform.DISCORD].enabled is True + assert restored.default_reset_policy.mode == SessionResetPolicy().mode + assert restored.reset_by_type == {} + assert restored.reset_by_platform == {} + assert restored.streaming.transport == "auto" + def test_get_notice_delivery_defaults_to_public(self): config = GatewayConfig( platforms={Platform.SLACK: PlatformConfig(enabled=True, token="***")}