fix(gateway): ignore malformed config sections

GatewayConfig.from_dict(), PlatformConfig.from_dict(), SessionResetPolicy.from_dict(), and StreamingConfig.from_dict() assumed their input sections were mappings. A malformed scalar from legacy gateway.json or an internal caller could crash config loading with AttributeError before env overrides/defaults had a chance to recover.

Coerce non-mapping sections to empty dicts, skip malformed platform entries, and keep valid sibling platform configs loading normally.

Tests cover scalar platform blocks, scalar nested reset/streaming sections, and malformed PlatformConfig home_channel/extra values.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
sharziki 2026-06-06 18:43:40 -04:00 committed by Teknium
parent 46613071e4
commit 50c66b2f8e
2 changed files with 67 additions and 8 deletions

View file

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

View file

@ -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="***")}