{"repo":"NousResearch/hermes-agent","public_id":"40834","display_label":"Gateway crashes on malformed scalar config sections","summary":"These issues and fixes address crashes or errors that occur when the gateway encounters scalar (non-mapping) config blocks or sections. Multiple fixes were proposed to guard against or ignore such malformed configuration inputs.","subsystem":"gateway","counts":{"members":6,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":2,"merged_prs":1,"attempt_prs":3},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":61740},"resolution":{"state":"partially_resolved","by":"pr-61740"},"freshness":{"state":"verified","last_verified_at":"2026-07-10T01:23:28+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_SPANNING","link_density":0.35733333333333334},"fix_edges":[{"issue_number":40834,"pr_number":61740,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":40836,"pr_number":61740,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false}],"members":[{"kind":"issue","number":40834,"title":"Gateway config deserializers crash on malformed scalar sections","url":null,"state":"closed"},{"kind":"issue","number":40836,"title":"Scalar gateway config block crashes streaming fallback","url":null,"state":"closed"},{"kind":"pr","number":40835,"title":"fix(gateway): ignore malformed config sections","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"50c66b2f8","number":0},{"kind":"commit","value":"a7f65e3bc","number":0},{"kind":"pr","value":"#61740","number":61740},{"kind":"pr","value":"#40837","number":40837}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":40837,"title":"fix(gateway): tolerate scalar gateway config block","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"a7f65e3bc","number":0},{"kind":"pr","value":"#61740","number":61740},{"kind":"pr","value":"#40835","number":40835}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":40914,"title":"fix: guard streaming-fallback lookup against scalar gateway value","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"a7f65e3bcd937cd095ba599ab5927af2093a0d95","number":0},{"kind":"pr","value":"#61740","number":61740}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":61740,"title":"fix(gateway): malformed scalar config sections no longer crash gateway startup (#40834)","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["gateway/config.py","tests/gateway/test_config.py"],"fix_hunks":[{"file":"gateway/config.py","hunks":["@@ -130,6 +130,11 @@ def _coerce_optional_positive_int(value: Any, key: str) -> Optional[int]:\n     return parsed\n \n \n+def _coerce_dict(value: Any) -> Dict[str, Any]:\n+    \"\"\"Return *value* when it is a mapping, otherwise an empty dict.\"\"\"\n+    return value if isinstance(value, dict) else {}\n+\n+\n def _normalize_unauthorized_dm_behavior(value: Any, default: str = \"pair\") -> str:\n     \"\"\"Normalize unauthorized DM behavior to a supported value.\"\"\"\n     if isinstance(value, str):","@@ -383,6 +388,7 @@ def to_dict(self) -> Dict[str, Any]:\n     \n     @classmethod\n     def from_dict(cls, data: Dict[str, Any]) -> \"SessionResetPolicy\":\n+        data = _coerce_dict(data)\n         # Handle both missing keys and explicit null values (YAML null → None)\n         mode = data.get(\"mode\")\n         at_hour = data.get(\"at_hour\")","@@ -492,24 +498,26 @@ def to_dict(self) -> Dict[str, Any]:\n \n     @classmethod\n     def from_dict(cls, data: Dict[str, Any]) -> \"PlatformConfig\":\n+        data = _coerce_dict(data)\n         home_channel = None\n-        if \"home_channel\" in data:\n+        if isinstance(data.get(\"home_channel\"), dict):\n             home_channel = HomeChannel.from_dict(data[\"home_channel\"])\n \n         # gateway_restart_notification may be bridged into extra via the\n         # shared-key loop in load_gateway_config(); check both top-level\n         # and extra so YAML ``discord: gateway_restart_notification: false``\n         # works without needing a separate platforms: block.\n+        extra = _coerce_dict(data.get(\"extra\", {}))\n         _grn = data.get(\"gateway_restart_notification\")\n         if _grn is None:\n-            _grn = data.get(\"extra\", {}).get(\"gateway_restart_notification\")\n+            _grn = extra.get(\"gateway_restart_notification\")\n \n         # typing_indicator mirrors gateway_restart_notification: it may arrive\n         # top-level or bridged into extra by the shared-key loop in\n         # load_gateway_config(), so check both.\n         _typing = data.get(\"typing_indicator\")\n         if _typing is None:\n-            _typing = data.get(\"extra\", {}).get(\"typing_indicator\")\n+            _typing = extra.get(\"typing_indicator\")\n \n         channel_overrides: Dict[str, ChannelOverride] = {}\n         raw_overrides = data.get(\"channel_overrides\") or {}","@@ -527,7 +535,7 @@ def from_dict(cls, data: Dict[str, Any]) -> \"PlatformConfig\":\n             gateway_restart_notification=_coerce_bool(_grn, True),\n             typing_indicator=_coerce_bool(_typing, True),\n             channel_overrides=channel_overrides,\n-            extra=data.get(\"extra\", {}),\n+            extra=extra,\n         )\n \n ","@@ -586,7 +594,7 @@ def to_dict(self) -> Dict[str, Any]:\n \n     @classmethod\n     def from_dict(cls, data: Dict[str, Any]) -> \"StreamingConfig\":\n-        if not data:\n+        if not isinstance(data, dict) or not data:\n             return cls()\n         return cls(\n             enabled=_coerce_bool(data.get(\"enabled\"), False),","@@ -823,8 +831,12 @@ def to_dict(self) -> Dict[str, Any]:\n     \n     @classmethod\n     def from_dict(cls, data: Dict[str, Any]) -> \"GatewayConfig\":\n+        data = _coerce_dict(data)\n         platforms = {}\n-        for platform_name, platform_data in data.get(\"platforms\", {}).items():\n+        platforms_data = _coerce_dict(data.get(\"platforms\", {}))\n+        for platform_name, platform_data in platforms_data.items():\n+            if not isinstance(platform_data, dict):\n+                continue\n             try:\n                 platform = Platform(platform_name)\n                 platforms[platform] = PlatformConfig.from_dict(platform_data)","@@ -832,11 +844,11 @@ def from_dict(cls, data: Dict[str, Any]) -> \"GatewayConfig\":\n                 pass  # Skip unknown platforms\n         \n         reset_by_type = {}\n-        for type_name, policy_data in data.get(\"reset_by_type\", {}).items():\n+        for type_name, policy_data in _coerce_dict(data.get(\"reset_by_type\", {})).items():\n             reset_by_type[type_name] = SessionResetPolicy.from_dict(policy_data)\n         \n         reset_by_platform = {}\n-        for platform_name, policy_data in data.get(\"reset_by_platform\", {}).items():\n+        for platform_name, policy_data in _coerce_dict(data.get(\"reset_by_platform\", {})).items():\n             try:\n                 platform = Platform(platform_name)\n                 reset_by_platform[platform] = SessionResetPolicy.from_dict(policy_data)","@@ -1027,6 +1039,8 @@ def load_gateway_config() -> GatewayConfig:\n             if \"stt_echo_transcripts\" in yaml_cfg:\n                 gw_data[\"stt_echo_transcripts\"] = yaml_cfg[\"stt_echo_transcripts\"]\n \n+            gateway_cfg = yaml_cfg.get(\"gateway\")\n+\n             if \"group_sessions_per_user\" in yaml_cfg:\n                 gw_data[\"group_sessions_per_user\"] = yaml_cfg[\"group_sessions_per_user\"]\n ","@@ -1054,7 +1068,11 @@ def load_gateway_config() -> GatewayConfig:\n             if not isinstance(streaming_cfg, dict):\n                 # Fall back to nested gateway.streaming written by\n                 # ``hermes config set gateway.streaming.*``\n-                streaming_cfg = yaml_cfg.get(\"gateway\", {}).get(\"streaming\")\n+                streaming_cfg = (\n+                    gateway_cfg.get(\"streaming\")\n+                    if isinstance(gateway_cfg, dict)\n+                    else None\n+                )\n             if isinstance(streaming_cfg, dict):\n                 gw_data[\"streaming\"] = streaming_cfg\n ","@@ -1087,7 +1105,6 @@ def load_gateway_config() -> GatewayConfig:\n             # ``gateway.platforms`` are loaded the same way as top-level\n             # ``platforms``. Merge nested first so top-level config keeps\n             # precedence, matching the existing gateway.streaming fallback.\n-            gateway_cfg = yaml_cfg.get(\"gateway\")\n             gateway_platforms = gateway_cfg.get(\"platforms\") if isinstance(gateway_cfg, dict) else None\n             platforms_data = gw_data.setdefault(\"platforms\", {})\n             if not isinstance(platforms_data, dict):"]},{"file":"tests/gateway/test_config.py","hunks":["@@ -142,6 +142,21 @@ def test_to_dict_omits_none(self):\n         assert d[\"system_prompt\"] == \"Hi\"\n \n \n+class TestPlatformConfigMalformedSections:\n+    def test_from_dict_ignores_malformed_nested_sections(self):\n+        restored = PlatformConfig.from_dict(\n+            {\n+                \"enabled\": True,\n+                \"home_channel\": \"telegram:123\",\n+                \"extra\": \"oops\",\n+            }\n+        )\n+\n+        assert restored.enabled is True\n+        assert restored.home_channel is None\n+        assert restored.extra == {}\n+\n+\n class TestGetConnectedPlatforms:\n     def test_returns_enabled_with_token(self):\n         config = GatewayConfig(","@@ -238,6 +253,12 @@ def test_from_dict_coerces_quoted_false_notify(self):\n         restored = SessionResetPolicy.from_dict({\"notify\": \"false\"})\n         assert restored.notify is False\n \n+    def test_from_dict_malformed_section_falls_back_to_defaults(self):\n+        restored = SessionResetPolicy.from_dict(\"oops\")\n+        assert restored.mode == SessionResetPolicy().mode\n+        assert restored.at_hour == 4\n+        assert restored.idle_minutes == 1440\n+\n \n class TestStreamingConfig:\n     def test_defaults_to_auto_transport(self):","@@ -263,6 +284,11 @@ def test_from_dict_malformed_numeric_values_fall_back_to_defaults(self):\n         assert restored.buffer_threshold == 24\n         assert restored.fresh_final_after_seconds == 0.0\n \n+    def test_from_dict_malformed_section_falls_back_to_defaults(self):\n+        restored = StreamingConfig.from_dict(\"enabled\")\n+        assert restored.enabled is False\n+        assert restored.transport == \"auto\"\n+\n \n class TestGatewayConfigRoundtrip:\n     def test_full_roundtrip(self):","@@ -365,6 +391,27 @@ def test_from_dict_coerces_quoted_false_always_log_local(self):\n         restored = GatewayConfig.from_dict({\"always_log_local\": \"false\"})\n         assert restored.always_log_local is False\n \n+    def test_from_dict_ignores_malformed_nested_sections(self):\n+        restored = GatewayConfig.from_dict(\n+            {\n+                \"platforms\": {\n+                    \"telegram\": \"enabled\",\n+                    \"discord\": {\"enabled\": True, \"token\": \"tok\"},\n+                },\n+                \"default_reset_policy\": \"daily\",\n+                \"reset_by_type\": [\"oops\"],\n+                \"reset_by_platform\": \"oops\",\n+                \"streaming\": \"enabled\",\n+            }\n+        )\n+\n+        assert Platform.TELEGRAM not in restored.platforms\n+        assert restored.platforms[Platform.DISCORD].enabled is True\n+        assert restored.default_reset_policy.mode == SessionResetPolicy().mode\n+        assert restored.reset_by_type == {}\n+        assert restored.reset_by_platform == {}\n+        assert restored.streaming.transport == \"auto\"\n+\n     def test_get_notice_delivery_defaults_to_public(self):\n         config = GatewayConfig(\n             platforms={Platform.SLACK: PlatformConfig(enabled=True, token=\"***\")}","@@ -561,6 +608,18 @@ def test_top_level_max_concurrent_sessions_overrides_nested_config_yaml(self, tm\n \n         assert config.max_concurrent_sessions == 2\n \n+    def test_scalar_gateway_section_does_not_crash_streaming_fallback(self, tmp_path, monkeypatch):\n+        hermes_home = tmp_path / \".hermes\"\n+        hermes_home.mkdir()\n+        config_path = hermes_home / \"config.yaml\"\n+        config_path.write_text(\"gateway: disabled\\n\", encoding=\"utf-8\")\n+\n+        monkeypatch.setenv(\"HERMES_HOME\", str(hermes_home))\n+\n+        config = load_gateway_config()\n+\n+        assert config.streaming.transport == \"auto\"\n+\n     def test_bridges_discord_thread_require_mention_from_config_yaml(self, tmp_path, monkeypatch):\n         \"\"\"discord.thread_require_mention in config.yaml should reach the runtime env var.\"\"\"\n         hermes_home = tmp_path / \".hermes\"\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}