{"repo":"NousResearch/hermes-agent","public_id":"26804","display_label":"Config migration silently drops user data sections","summary":"Multiple bugs and fixes concern config migration and update processes silently stripping user-authored content, including .env keys and platforms sections. Fixes address preserving these sections during partial saves, version migrations, and Docker boot-time migration transactions.","subsystem":"hermes_cli","counts":{"members":8,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":3,"merged_prs":2,"attempt_prs":3},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":51675},"resolution":{"state":"partially_resolved","by":"pr-51675,pr-64606"},"freshness":{"state":"verified","last_verified_at":"2026-07-15T11:26:21+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_PARTIAL","link_density":0.20428571428571426},"fix_edges":[{"issue_number":51579,"pr_number":51675,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":62723,"pr_number":64606,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true}],"members":[{"kind":"issue","number":26804,"title":".env File Silently Stripped During hermes update / Config Migration","url":null,"state":"closed"},{"kind":"issue","number":51579,"title":"[Bug]: `gateway run` auto-migration strips $HERMES_HOME/.env on every container start (Docker) — recurs on each laptop reboot, kills Telegram (regression of #26804)","url":null,"state":"closed"},{"kind":"issue","number":62723,"title":"[Bug]: Config migration v30→v32 silently drops platforms section in multi-profile setups","url":null,"state":"closed"},{"kind":"pr","number":26807,"title":"fix(config): stop hermes update from silently stripping user-added .env keys (#26804)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#26804","number":26804}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":51615,"title":"fix(docker): restore boot-migration backups on failure","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#51675","number":51675},{"kind":"pr","value":"#51579","number":51579},{"kind":"pr","value":"#51579","number":51579}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":51675,"title":"fix(docker): make boot-time config migration transactional (#51579)","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["scripts/docker_config_migrate.py","tests/tools/test_docker_config_migrate.py"]},{"kind":"pr","number":62739,"title":"fix(config): preserve platforms on partial save_config writes (#62723)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#64606","number":64606}],"category":"partial_fix","implication":"needs_llm"},"merged":false},{"kind":"pr","number":64606,"title":"fix(config): merge_existing mode stops partial saves dropping platforms (#62723)","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["hermes_cli/config.py","hermes_cli/web_server.py","tests/hermes_cli/test_config.py"],"fix_hunks":[{"file":"hermes_cli/config.py","hunks":["@@ -5505,10 +5505,13 @@ def _persist_migration(config: Dict[str, Any]) -> None:\n \n     Every migration step MUST route its write through this helper instead of\n     calling ``save_config`` directly. It is a thin wrapper over\n-    ``save_config(config)`` (default-stripping ON); centralising the call makes\n-    the invariant impossible to regress one migration at a time. Correctness\n-    across seeds, non-default values, behaviour flips, and data transforms is\n-    verified by the migration parity tests.\n+    ``save_config(config)`` (default-stripping ON, no ``merge_existing``);\n+    centralising the call makes the invariant impossible to regress one\n+    migration at a time. Callers must pass the full raw config returned by\n+    ``read_raw_config()`` after in-place mutations (including key removals);\n+    deep-merging the on-disk file back in would resurrect keys the migration\n+    just deleted. Partial-save preservation for unrelated top-level sections\n+    belongs on ``save_config(..., merge_existing=True)``, not here.\n     \"\"\"\n     save_config(config)\n ","@@ -6283,6 +6286,25 @@ def migrate_config(interactive: bool = True, quiet: bool = False) -> Dict[str, A\n     return results\n \n \n+def _merge_partial_save(raw: dict, override: dict) -> dict:\n+    \"\"\"Merge *override* over *raw* for partial ``save_config`` writes.\n+\n+    Top-level sections omitted from *override* are preserved from *raw*.\n+    Shared top-level dict sections are deep-merged so a caller can update one\n+    nested key without dropping sibling keys from disk. Intentional key\n+    removals within a section are not supported here — migration writes must\n+    route through ``_persist_migration`` with a full ``read_raw_config()`` dict\n+    instead.\n+    \"\"\"\n+    result = copy.deepcopy(override)\n+    for key, value in raw.items():\n+        if key not in result:\n+            result[key] = copy.deepcopy(value)\n+        elif isinstance(result.get(key), dict) and isinstance(value, dict):\n+            result[key] = _deep_merge(value, result[key])\n+    return result\n+\n+\n def _deep_merge(base: dict, override: dict) -> dict:\n     \"\"\"Recursively merge *override* into *base*, preserving nested defaults.\n ","@@ -7165,6 +7187,7 @@ def save_config(\n     *,\n     strip_defaults: bool = True,\n     preserve_keys: Optional[Set[Tuple[str, ...]]] = None,\n+    merge_existing: bool = False,\n ):\n     \"\"\"Save configuration to ~/.hermes/config.yaml.\\n\n ","@@ -7173,6 +7196,12 @@ def save_config(\n     before any normalisation).  This prevents config.yaml from being\n     contaminated with schema defaults on every save, which makes future\n     default changes invisible to users.\n+\n+    When ``merge_existing`` is True, the on-disk raw config is deep-merged\n+    under *config* before writing so partial callers (migration steps via\n+    ``_persist_migration``) cannot drop unrelated sections the caller omitted.\n+    Full-document replacement callers (dashboard raw YAML editor, callers that\n+    already deep-merge) must leave this False so intentional deletions survive.\n     \"\"\"\n     with _CONFIG_LOCK:\n         if is_managed():","@@ -7207,11 +7236,17 @@ def save_config(\n         explicit_raw_paths: Optional[Set[Tuple[str, ...]]] = (\n             _explicit_config_paths(_raw_for_paths) if _raw_for_paths else None\n         )\n+        if merge_existing and _raw_for_paths:\n+            config = _merge_partial_save(_raw_for_paths, config)\n         # ----------------------------------------------------------------\n \n         current_normalized = _normalize_root_model_keys(_normalize_max_turns_config(config))\n         normalized = current_normalized\n-        raw_existing = _normalize_root_model_keys(_normalize_max_turns_config(read_raw_config()))\n+        raw_existing = (\n+            _normalize_root_model_keys(_normalize_max_turns_config(_raw_for_paths))\n+            if _raw_for_paths\n+            else {}\n+        )\n         if raw_existing:\n             normalized = _preserve_env_ref_templates(\n                 normalized,","@@ -7259,6 +7294,7 @@ def save_config(\n             extra_content=\"\".join(parts) if parts else None,\n         )\n         _secure_file(config_path)\n+        _RAW_CONFIG_CACHE.pop(str(config_path), None)\n         _LAST_EXPANDED_CONFIG_BY_PATH[str(config_path)] = copy.deepcopy(current_normalized)\n \n "]},{"file":"hermes_cli/web_server.py","hunks":["@@ -14142,7 +14142,9 @@ async def update_config_raw(body: RawConfigUpdate, profile: Optional[str] = None\n         if not isinstance(parsed, dict):\n             raise HTTPException(status_code=400, detail=\"YAML must be a mapping\")\n         with _profile_scope(body.profile or profile):\n-            save_config(parsed)\n+            # Full-document replacement: the editor owns the whole file; do not\n+            # merge omitted sections back from disk (#62723).\n+            save_config(parsed, merge_existing=False)\n         return {\"ok\": True}\n     except yaml.YAMLError as e:\n         raise HTTPException(status_code=400, detail=f\"Invalid YAML: {e}\")"]},{"file":"tests/hermes_cli/test_config.py","hunks":["@@ -1645,6 +1645,114 @@ def test_version_bump_keeps_config_lean(self, tmp_path, start_version):\n         assert loaded[\"display\"][\"compact\"] == DEFAULT_CONFIG[\"display\"][\"compact\"]\n \n \n+class TestSaveConfigPartialWritePreservation:\n+    \"\"\"Regression for #62723: partial migration writes must not drop unrelated sections.\"\"\"\n+\n+    def test_merge_existing_preserves_platforms_on_partial_write(self, tmp_path):\n+        body = \"\"\"_config_version: 30\n+model:\n+  default: deepseek-v4-pro\n+  provider: deepseek\n+agent:\n+  max_turns: 60\n+platforms:\n+  feishu:\n+    enabled: true\n+    extra:\n+      app_id: cli_xxx\n+      app_secret: xxx\n+feishu:\n+  require_mention: true\n+\"\"\"\n+        (tmp_path / \"config.yaml\").write_text(body, encoding=\"utf-8\")\n+        with patch.dict(os.environ, {\"HERMES_HOME\": str(tmp_path)}):\n+            save_config(\n+                {\n+                    \"_config_version\": 30,\n+                    \"model\": {\"default\": \"deepseek-v4-pro\", \"provider\": \"deepseek\"},\n+                    \"agent\": {\"max_turns\": 60, \"verify_on_stop\": False},\n+                },\n+                merge_existing=True,\n+            )\n+            raw = yaml.safe_load((tmp_path / \"config.yaml\").read_text(encoding=\"utf-8\"))\n+\n+        assert raw[\"platforms\"][\"feishu\"][\"extra\"][\"app_id\"] == \"cli_xxx\"\n+        assert raw[\"feishu\"][\"require_mention\"] is True\n+        assert raw[\"agent\"][\"verify_on_stop\"] is False\n+\n+    def test_partial_write_without_merge_drops_omitted_sections(self, tmp_path):\n+        \"\"\"Full-replacement callers (raw YAML editor) rely on merge_existing=False.\"\"\"\n+        body = \"\"\"_config_version: 30\n+model:\n+  default: deepseek-v4-pro\n+  provider: deepseek\n+platforms:\n+  feishu:\n+    enabled: true\n+\"\"\"\n+        (tmp_path / \"config.yaml\").write_text(body, encoding=\"utf-8\")\n+        with patch.dict(os.environ, {\"HERMES_HOME\": str(tmp_path)}):\n+            save_config({\"model\": {\"default\": \"other-model\", \"provider\": \"openrouter\"}})\n+            raw = yaml.safe_load((tmp_path / \"config.yaml\").read_text(encoding=\"utf-8\"))\n+\n+        assert raw[\"model\"][\"default\"] == \"other-model\"\n+        assert \"platforms\" not in raw\n+\n+    def test_persist_migration_writes_full_read_raw_config(self, tmp_path):\n+        from hermes_cli.config import _persist_migration, read_raw_config\n+\n+        body = \"\"\"_config_version: 30\n+model:\n+  default: deepseek-v4-pro\n+  provider: deepseek\n+agent:\n+  max_turns: 60\n+platforms:\n+  feishu:\n+    enabled: true\n+    extra:\n+      app_id: cli_xxx\n+      app_secret: xxx\n+\"\"\"\n+        (tmp_path / \"config.yaml\").write_text(body, encoding=\"utf-8\")\n+        with patch.dict(os.environ, {\"HERMES_HOME\": str(tmp_path)}):\n+            config = read_raw_config()\n+            config.setdefault(\"agent\", {})[\"verify_on_stop\"] = False\n+            config[\"_config_version\"] = 32\n+            _persist_migration(config)\n+            raw = yaml.safe_load((tmp_path / \"config.yaml\").read_text(encoding=\"utf-8\"))\n+\n+        assert raw[\"platforms\"][\"feishu\"][\"extra\"][\"app_id\"] == \"cli_xxx\"\n+        assert raw[\"agent\"][\"verify_on_stop\"] is False\n+        assert raw[\"agent\"][\"max_turns\"] == 60\n+        assert raw[\"_config_version\"] == 32\n+\n+    def test_v30_to_latest_migration_keeps_platforms(self, tmp_path):\n+        \"\"\"End-to-end: reporter's v30 feishu profile survives version bump.\"\"\"\n+        body = \"\"\"_config_version: 30\n+model:\n+  default: deepseek-v4-pro\n+  provider: deepseek\n+agent:\n+  max_turns: 60\n+platforms:\n+  feishu:\n+    enabled: true\n+    extra:\n+      app_id: cli_xxx\n+      app_secret: xxx\n+feishu:\n+  require_mention: true\n+\"\"\"\n+        (tmp_path / \"config.yaml\").write_text(body, encoding=\"utf-8\")\n+        with patch.dict(os.environ, {\"HERMES_HOME\": str(tmp_path)}):\n+            migrate_config(interactive=False, quiet=True)\n+            raw = yaml.safe_load((tmp_path / \"config.yaml\").read_text(encoding=\"utf-8\"))\n+\n+        assert raw[\"platforms\"][\"feishu\"][\"extra\"][\"app_id\"] == \"cli_xxx\"\n+        assert raw[\"feishu\"][\"require_mention\"] is True\n+\n+\n class TestVerifyOnStopMigration:\n     \"\"\"v30 → v31: switch verify_on_stop OFF once, preserving explicit choices.\"\"\"\n \n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}