mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-15 14:22:43 +00:00
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:
parent
46613071e4
commit
50c66b2f8e
2 changed files with 67 additions and 8 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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="***")}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue