Config migration silently drops user data sections
NousResearch/hermes-agent · cluster 26804 · hermes cli
Data as of
Nothing open on GitHub. Fix merged in #51675. Check it covers your case.
The short answer
- Known?
- Yes. 8 related items grouped here, 3 issues already closed.
- Fix event
- A fix was merged to main in PR #51675. Check it covers your case. No release data is tracked.
- Tracker
- The maintainers' tracker marks this
partially resolved, deciding PRs #51675, #64606. That is separate from the fix event. - Still open
- Nothing open on GitHub.
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.
Open issues0
Open PRs0
Fixes on record
-
#51675 fix(docker): make boot-time config migration transactional (#51579)
files:
scripts/docker_config_migrate.py,tests/tools/test_docker_config_migrate.py -
#64606 fix(config): merge_existing mode stops partial saves dropping platforms (#62723)
files:
hermes_cli/config.py,hermes_cli/web_server.py,tests/hermes_cli/test_config.pyShow the diff
-
hermes_cli/config.py@@ -5505,10 +5505,13 @@ def _persist_migration(config: Dict[str, Any]) -> None: Every migration step MUST route its write through this helper instead of calling ``save_config`` directly. It is a thin wrapper over - ``save_config(config)`` (default-stripping ON); centralising the call makes - the invariant impossible to regress one migration at a time. Correctness - across seeds, non-default values, behaviour flips, and data transforms is - verified by the migration parity tests. + ``save_config(config)`` (default-stripping ON, no ``merge_existing``); + centralising the call makes the invariant impossible to regress one + migration at a time. Callers must pass the full raw config returned by + ``read_raw_config()`` after in-place mutations (including key removals); + deep-merging the on-disk file back in would resurrect keys the migration + just deleted. Partial-save preservation for unrelated top-level sections + belongs on ``save_config(..., merge_existing=True)``, not here. """ save_config(config) @@ -6283,6 +6286,25 @@ def migrate_config(interactive: bool = True, quiet: bool = False) -> Dict[str, A return results +def _merge_partial_save(raw: dict, override: dict) -> dict: + """Merge *override* over *raw* for partial ``save_config`` writes. + + Top-level sections omitted from *override* are preserved from *raw*. + Shared top-level dict sections are deep-merged so a caller can update one + nested key without dropping sibling keys from disk. Intentional key + removals within a section are not supported here — migration writes must + route through ``_persist_migration`` with a full ``read_raw_config()`` dict + instead. + """ + result = copy.deepcopy(override) + for key, value in raw.items(): + if key not in result: + result[key] = copy.deepcopy(value) + elif isinstance(result.get(key), dict) and isinstance(value, dict): + result[key] = _deep_merge(value, result[key]) + return result + + def _deep_merge(base: dict, override: dict) -> dict: """Recursively merge *override* into *base*, preserving nested defaults. @@ -7165,6 +7187,7 @@ def save_config( *, strip_defaults: bool = True, preserve_keys: Optional[Set[Tuple[str, ...]]] = None, + merge_existing: bool = False, ): """Save configuration to ~/.hermes/config.yaml.\n @@ -7173,6 +7196,12 @@ def save_config( before any normalisation). This prevents config.yaml from being contaminated with schema defaults on every save, which makes future default changes invisible to users. + + When ``merge_existing`` is True, the on-disk raw config is deep-merged + under *config* before writing so partial callers (migration steps via + ``_persist_migration``) cannot drop unrelated sections the caller omitted. + Full-document replacement callers (dashboard raw YAML editor, callers that + already deep-merge) must leave this False so intentional deletions survive. """ with _CONFIG_LOCK: if is_managed(): @@ -7207,11 +7236,17 @@ def save_config( explicit_raw_paths: Optional[Set[Tuple[str, ...]]] = ( _explicit_config_paths(_raw_for_paths) if _raw_for_paths else None ) + if merge_existing and _raw_for_paths: + config = _merge_partial_save(_raw_for_paths, config) # ---------------------------------------------------------------- current_normalized = _normalize_root_model_keys(_normalize_max_turns_config(config)) normalized = current_normalized - raw_existing = _normalize_root_model_keys(_normalize_max_turns_config(read_raw_config())) + raw_existing = ( + _normalize_root_model_keys(_normalize_max_turns_config(_raw_for_paths)) + if _raw_for_paths + else {} + ) if raw_existing: normalized = _preserve_env_ref_templates( normalized, @@ -7259,6 +7294,7 @@ def save_config( extra_content="".join(parts) if parts else None, ) _secure_file(config_path) + _RAW_CONFIG_CACHE.pop(str(config_path), None) _LAST_EXPANDED_CONFIG_BY_PATH[str(config_path)] = copy.deepcopy(current_normalized) -
hermes_cli/web_server.py@@ -14142,7 +14142,9 @@ async def update_config_raw(body: RawConfigUpdate, profile: Optional[str] = None if not isinstance(parsed, dict): raise HTTPException(status_code=400, detail="YAML must be a mapping") with _profile_scope(body.profile or profile): - save_config(parsed) + # Full-document replacement: the editor owns the whole file; do not + # merge omitted sections back from disk (#62723). + save_config(parsed, merge_existing=False) return {"ok": True} except yaml.YAMLError as e: raise HTTPException(status_code=400, detail=f"Invalid YAML: {e}") -
tests/hermes_cli/test_config.py@@ -1645,6 +1645,114 @@ def test_version_bump_keeps_config_lean(self, tmp_path, start_version): assert loaded["display"]["compact"] == DEFAULT_CONFIG["display"]["compact"] +class TestSaveConfigPartialWritePreservation: + """Regression for #62723: partial migration writes must not drop unrelated sections.""" + + def test_merge_existing_preserves_platforms_on_partial_write(self, tmp_path): + body = """_config_version: 30 +model: + default: deepseek-v4-pro + provider: deepseek +agent: + max_turns: 60 +platforms: + feishu: + enabled: true + extra: + app_id: cli_xxx + app_secret: xxx +feishu: + require_mention: true +""" + (tmp_path / "config.yaml").write_text(body, encoding="utf-8") + with patch.dict(os.environ, {"HERMES_HOME": str(tmp_path)}): + save_config( + { + "_config_version": 30, + "model": {"default": "deepseek-v4-pro", "provider": "deepseek"}, + "agent": {"max_turns": 60, "verify_on_stop": False}, + }, + merge_existing=True, + ) + raw = yaml.safe_load((tmp_path / "config.yaml").read_text(encoding="utf-8")) + + assert raw["platforms"]["feishu"]["extra"]["app_id"] == "cli_xxx" + assert raw["feishu"]["require_mention"] is True + assert raw["agent"]["verify_on_stop"] is False + + def test_partial_write_without_merge_drops_omitted_sections(self, tmp_path): + """Full-replacement callers (raw YAML editor) rely on merge_existing=False.""" + body = """_config_version: 30 +model: + default: deepseek-v4-pro + provider: deepseek +platforms: + feishu: + enabled: true +""" + (tmp_path / "config.yaml").write_text(body, encoding="utf-8") + with patch.dict(os.environ, {"HERMES_HOME": str(tmp_path)}): + save_config({"model": {"default": "other-model", "provider": "openrouter"}}) + raw = yaml.safe_load((tmp_path / "config.yaml").read_text(encoding="utf-8")) + + assert raw["model"]["default"] == "other-model" + assert "platforms" not in raw + + def test_persist_migration_writes_full_read_raw_config(self, tmp_path): + from hermes_cli.config import _persist_migration, read_raw_config + + body = """_config_version: 30 +model: + default: deepseek-v4-pro + provider: deepseek +agent: + max_turns: 60 +platforms: + feishu: + enabled: true + extra: + app_id: cli_xxx + app_secret: xxx +""" + (tmp_path / "config.yaml").write_text(body, encoding="utf-8") + with patch.dict(os.environ, {"HERMES_HOME": str(tmp_path)}): + config = read_raw_config() + config.setdefault("agent", {})["verify_on_stop"] = False + config["_config_version"] = 32 + _persist_migration(config) + raw = yaml.safe_load((tmp_path / "config.yaml").read_text(encoding="utf-8")) + + assert raw["platforms"]["feishu"]["extra"]["app_id"] == "cli_xxx" + assert raw["agent"]["verify_on_stop"] is False + assert raw["agent"]["max_turns"] == 60 + assert raw["_config_version"] == 32 + + def test_v30_to_latest_migration_keeps_platforms(self, tmp_path): + """End-to-end: reporter's v30 feishu profile survives version bump.""" + body = """_config_version: 30 +model: + default: deepseek-v4-pro + provider: deepseek +agent: + max_turns: 60 +platforms: + feishu: + enabled: true + extra: + app_id: cli_xxx + app_secret: xxx +feishu: + require_mention: true +""" + (tmp_path / "config.yaml").write_text(body, encoding="utf-8") + with patch.dict(os.environ, {"HERMES_HOME": str(tmp_path)}): + migrate_config(interactive=False, quiet=True) + raw = yaml.safe_load((tmp_path / "config.yaml").read_text(encoding="utf-8")) + + assert raw["platforms"]["feishu"]["extra"]["app_id"] == "cli_xxx" + assert raw["feishu"]["require_mention"] is True + + class TestVerifyOnStopMigration: """v30 → v31: switch verify_on_stop OFF once, preserving explicit choices."""
-
3 PRs closed without merging
From the close comments: done elsewhere 1 · merged via another change 1 · partial fix 1
-
#26807 fix(config): stop hermes update from silently stripping user-added .env keys (#26804) · done elsewhere →
#26804 -
#51615 fix(docker): restore boot-migration backups on failure · merged via another change →
#51675,#51579 -
#62739 fix(config): preserve platforms on partial save_config writes (#62723) · partial fix →
#64606
Closed issues · 3
- Closed#26804.env File Silently Stripped During hermes update / Config Migration
- Closed#51579[Bug]: `gateway run` auto-migration strips $HERMES_HOME/.env on every container start (Docker) — recurs on each laptop reboot, kills Telegram (regression of #26804)
- Closed#62723[Bug]: Config migration v30→v32 silently drops platforms section in multi-profile setups
How this cluster was grouped
-
Link evidence (
partly structural links): some members are connected by structural evidence (shared changed files, or a closing/superseded reference); the rest are joined by an automated relevance judgement over their text. - Members outside that structural core may belong to a different underlying problem.
-
Pairwise link density
0.20: the share of item pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36.1.00means every pair is linked.
Pick up a Lab task
Check whether a fix covers the open issues here, with your own agent. Bring back evidence for maintainers.
Open the Lab