tui_gateway/server.py:_save_cfg called yaml.safe_dump on a deep-loaded
config dict, which reordered top-level keys alphabetically, stripped
every user-edited comment, and re-escaped non-ASCII (kaomoji/Chinese)
personality prompts to \uXXXX. Every TUI setting change - /personality,
/reasoning, /details_mode, /skin, /prompt - rewrote the file top to
bottom.
Changes:
* Add atomic_roundtrip_yaml_save(path, new_state) in utils.py - a
comment-, ordering-, and unicode-preserving full-state replacement
for yaml.safe_dump(cfg, f). Uses ruamel round-trip mode like the
existing atomic_roundtrip_yaml_update, but accepts the whole cfg
dict so callers that mutate multiple keys before saving (the
_save_cfg pattern) don't have to be rewritten. Recurses into nested
dicts, deletes keys missing from new_state (preserves the
cfg.pop()-then-save semantic), and overwrites lists/scalars
wholesale.
* Fail closed on an unreadable existing config.yaml the same way
hermes_cli.config.atomic_config_write does, via a lazy import of
require_readable_config_before_write (avoids a module-level circular
import, since hermes_cli.config itself imports from utils). Also
preserves both file mode and owner across the write, matching the
existing atomic_roundtrip_yaml_update contract.
* Force-quote any new string value that YAML 1.1 would misparse as a
bool/null (yes/no/on/off/true/false/null/~). ruamel's round-trip
dumper resolves against the YAML 1.2 core schema and emits these
unquoted, but PyYAML-based readers elsewhere in the codebase parse
under YAML 1.1 rules - so an unquoted `approvals.mode: off` would
silently round-trip back as the boolean False.
* tui_gateway/server.py:_save_cfg now delegates to
atomic_roundtrip_yaml_save. Drop-in - all call sites (/personality,
/reasoning, /details_mode, /prompt, etc.) inherit comment
preservation and the fail-closed contract.
Tests:
* tests/test_utils_atomic_roundtrip_yaml_save.py - unit tests covering
create-from-empty, top-level key-order preservation, comment
preservation, readable Unicode, append-new-keys, delete-missing-keys,
scalar/list overwrite, nested-dict recursion, refusal on an
unreadable existing config, and owner preservation.
* tests/test_atomic_replace_symlinks.py - owner-preservation regression
test mirroring the existing atomic_roundtrip_yaml_update coverage.
* tests/test_tui_gateway_server.py - 4 new tests pinning _save_cfg
comment preservation, top-level key-order preservation, and
unicode-readability under unrelated writes.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Extract the islink/realpath guard from the 16743 fix into a single
atomic_replace() helper in utils.py, then migrate every os.replace()
call site in the codebase to use it.
The original PR #16777 correctly identified and fixed the bug, but
only patched 9 of ~24 call sites. The same bug class (managed
deployments that symlink state files silently losing the link on
every write) still existed at auth.json, sessions file, gateway
config, env_loader, webhook subscriptions, debug store, model
catalog, pairing, google OAuth, nous rate guard, and more.
Rather than add another 10+ copies of the same three-line guard,
consolidate into atomic_replace(tmp, target) which:
- resolves symlinks via os.path.realpath before os.replace
- returns the resolved real path so callers can re-apply permissions
- is a drop-in replacement for os.replace at the use sites
Changes:
- utils.py: new atomic_replace() helper + atomic_json_write /
atomic_yaml_write now call it instead of inlining the guard
- 16 files: all os.replace() call sites migrated to atomic_replace()
- agent/{google_oauth, nous_rate_guard, shell_hooks}.py
- cron/jobs.py
- gateway/{pairing, session, platforms/telegram}.py
- hermes_cli/{auth, config, debug, env_loader, model_catalog, webhook}.py
- tools/{memory_tool, skill_manager_tool, skills_sync}.py
Tests: tests/test_atomic_replace_symlinks.py pins the invariant for
atomic_replace + atomic_json_write + atomic_yaml_write, covers plain
files, first-time creates, broken symlinks, and permission preservation.
Refs #16743
Builds on #16777 by @vominh1919.