Skip to content

fix: prevent duplicate YAML keys in Hermes config#3267

Merged
farion1231 merged 4 commits into
farion1231:mainfrom
que3sui:fix/hermes-duplicate-key
Jun 10, 2026
Merged

fix: prevent duplicate YAML keys in Hermes config#3267
farion1231 merged 4 commits into
farion1231:mainfrom
que3sui:fix/hermes-duplicate-key

Conversation

@que3sui

@que3sui que3sui commented May 28, 2026

Copy link
Copy Markdown
Contributor

Summary

Fixes a bug where top-level YAML keys (typically mcp_servers) get duplicated in ~/.hermes/config.yaml, causing subsequent reads to fail with: Failed to parse Hermes config as YAML: duplicate entry with key "mcp_servers".

Root Cause

replace_yaml_section() only replaced the first occurrence. Duplicates in the remainder persisted permanently.

Fix (3 changes in hermes_config.rs)

  1. deduplicate_top_level_keys() - removes duplicate keys before YAML parsing
  2. remove_all_sections() - strips all occurrences of a given key from raw YAML
  3. replace_yaml_section() - now cleans duplicates from remainder after replacement

Test plan

  • Existing 41 unit tests preserved
  • Config files with existing duplicates are healed on next write
  • CI: cargo test / cargo check

Three changes in hermes_config.rs:
1. deduplicate_top_level_keys() - scan and remove duplicate top-level
   keys before YAML parsing, preventing "duplicate entry" parse errors
2. remove_all_sections() - helper to strip all occurrences of a given
   top-level key from raw YAML text
3. replace_yaml_section() now calls remove_all_sections() on the
   remainder after replacing the primary occurrence, preventing
   duplicate sections from accumulating on repeated writes

Fixes the issue where mcp_servers (or any top-level key) gets
duplicated in config.yaml, causing "Failed to parse Hermes config
as YAML: duplicate entry with key" errors.

Co-Authored-By: que3sui <[email protected]>
@que3sui
que3sui force-pushed the fix/hermes-duplicate-key branch from 4101154 to d12bd21 Compare May 28, 2026 15:38
@farion1231

Copy link
Copy Markdown
Owner

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: d12bd21162

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src-tauri/src/hermes_config.rs Outdated
is_top_level_key_line only accepted empty, space, or tab after the colon,
but deduplicate_top_level_keys uses split_inclusive('\n'), so lines end
with \n (LF) or \r\n (CRLF). Without accepting \r and \n as valid
post-colon characters, the dedup safety net never activates.

Add \r and \n checks to is_top_level_key_line, and three tests covering
LF, CRLF, and first-occurrence preservation.

Co-Authored-By: Claude Opus 4.7 <[email protected]>

Copy link
Copy Markdown

Confirmed this still affects the Windows v3.16.2 release in a real Hermes setup. The config had mostly CRLF line endings, so is_top_level_key_line() did not recognize existing top-level sections and provider/key updates appended duplicate custom_providers: / model: blocks.

I tested a local fix that:

  • accepts CRLF in is_top_level_key_line();
  • recovers already-broken configs by keeping the last duplicate occurrence only for CC Switch-managed Hermes sections (custom_providers, model, mcp_servers, memory);
  • leaves duplicate unmanaged/user-authored sections as parse errors;
  • removes managed duplicates on the next successful section write.

Validation on current v3.16.2 source: cargo test --lib passed with 1560 passed, 0 failed, 2 ignored, including regressions for CRLF replacement, existing duplicate recovery, and not hiding unmanaged duplicate sections.

The recovery behavior may be worth including alongside the CRLF prevention fix, because affected users currently cannot edit/switch providers once serde_yaml rejects the already-duplicated file.

@2836048681

Copy link
Copy Markdown

在 Windows 11 + CC Switch v3.16.2(便携版)+ Hermes Agent 上确认复现,这个 PR 正好修复了我们今天遇到的问题。

复现情况:

  • ~/.hermes/config.yaml 由 Hermes 生成,全文件 CRLF 换行(已逐行确认);
  • 在 CC Switch 里应用/切换 Hermes 供应商后,文件末尾被追加了一份重复的 mcp_servers: 块;
  • 手动删除重复块后,下一次应用又在末尾追加了重复的 custom_providers: 块;
  • 之后 CC Switch 启动导入报错 Failed to parse Hermes config as YAML: duplicate entry with key "custom_providers",Hermes 面板不可用;
  • 有效的临时规避:把 config.yaml 转成 LF 换行后,小节能被正确识别并原地替换,不再产生重复。

这与 #3633 的根因分析一致:is_top_level_key_line() 不接受冒号后的 \r,导致 CRLF 文件上所有「替换小节」都变成了「追加」。

本 PR 当前版本(is_top_level_key_line 接受 \r/\n、读取时去重自愈、写入时清理残留重复)可以同时预防和自愈我们这种情况,希望能尽快合并。相关 issue:#3633 #2973 #2529 #3310 #3762

Reworks the healing layers on top of the CRLF root-cause fix:

- deduplicate_top_level_keys: keep the LAST occurrence of each duplicated
  key instead of the first. Duplicates come from section replacement
  degrading into appends (farion1231#3633), so the last block is the newest data --
  and Hermes itself reads the config with PyYAML, whose duplicate-key
  semantics are last-wins. Keeping the first occurrence would silently
  roll users back to stale config and diverge from what Hermes runs with.
  Healthy files take a fast path and are returned untouched.
- Drop the unused dup_key variable (fails cargo clippy -- -D warnings,
  which CI enforces).
- replace_yaml_section: clean residual duplicate sections from the
  remainder via remove_all_sections; values come from the keep-last
  healed read, so dropping all stale on-disk copies loses nothing.
- Add regression tests for the actual root cause (find/replace on CRLF
  input must replace in place, not append), keep-last semantics,
  identity on healthy files, end-to-end heal-then-parse, and duplicate
  cleanup on write.

Fixes farion1231#3633 farion1231#2973 farion1231#2529 farion1231#3310 farion1231#3762

@farion1231 farion1231 left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

非常感谢您的贡献!

@farion1231
farion1231 merged commit 4f91172 into farion1231:main Jun 10, 2026
2 checks passed
gfunc pushed a commit to gfunc/cc-switch that referenced this pull request Jun 19, 2026
* fix: prevent duplicate YAML keys in Hermes config

Three changes in hermes_config.rs:
1. deduplicate_top_level_keys() - scan and remove duplicate top-level
   keys before YAML parsing, preventing "duplicate entry" parse errors
2. remove_all_sections() - helper to strip all occurrences of a given
   top-level key from raw YAML text
3. replace_yaml_section() now calls remove_all_sections() on the
   remainder after replacing the primary occurrence, preventing
   duplicate sections from accumulating on repeated writes

Fixes the issue where mcp_servers (or any top-level key) gets
duplicated in config.yaml, causing "Failed to parse Hermes config
as YAML: duplicate entry with key" errors.

Co-Authored-By: que3sui <[email protected]>

* fix: handle CRLF and LF line endings in top-level key deduplication

is_top_level_key_line only accepted empty, space, or tab after the colon,
but deduplicate_top_level_keys uses split_inclusive('\n'), so lines end
with \n (LF) or \r\n (CRLF). Without accepting \r and \n as valid
post-colon characters, the dedup safety net never activates.

Add \r and \n checks to is_top_level_key_line, and three tests covering
LF, CRLF, and first-occurrence preservation.

Co-Authored-By: Claude Opus 4.7 <[email protected]>

* refactor(hermes): keep last occurrence when healing duplicate YAML keys

Reworks the healing layers on top of the CRLF root-cause fix:

- deduplicate_top_level_keys: keep the LAST occurrence of each duplicated
  key instead of the first. Duplicates come from section replacement
  degrading into appends (farion1231#3633), so the last block is the newest data --
  and Hermes itself reads the config with PyYAML, whose duplicate-key
  semantics are last-wins. Keeping the first occurrence would silently
  roll users back to stale config and diverge from what Hermes runs with.
  Healthy files take a fast path and are returned untouched.
- Drop the unused dup_key variable (fails cargo clippy -- -D warnings,
  which CI enforces).
- replace_yaml_section: clean residual duplicate sections from the
  remainder via remove_all_sections; values come from the keep-last
  healed read, so dropping all stale on-disk copies loses nothing.
- Add regression tests for the actual root cause (find/replace on CRLF
  input must replace in place, not append), keep-last semantics,
  identity on healthy files, end-to-end heal-then-parse, and duplicate
  cleanup on write.

Fixes farion1231#3633 farion1231#2973 farion1231#2529 farion1231#3310 farion1231#3762

---------

Co-authored-by: que3sui <[email protected]>
Co-authored-by: Claude Opus 4.7 <[email protected]>
Co-authored-by: Jason <[email protected]>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants