Skip to content

fix: preserve symlinks during atomic file writes (#16743)#16980

Merged
teknium1 merged 2 commits into
mainfrom
hermes/hermes-0793ff7c
Apr 28, 2026
Merged

fix: preserve symlinks during atomic file writes (#16743)#16980
teknium1 merged 2 commits into
mainfrom
hermes/hermes-0793ff7c

Conversation

@teknium1

Copy link
Copy Markdown
Contributor

Atomic writes no longer detach symlinks from their tracked targets. Managed deployments that symlink ~/.hermes/config.yaml, SOUL.md, auth.json, .env, sessions, cron state, etc. to a git-tracked profile package or dotfiles repo now stay linked through every write path.

Builds on #16777 by @vominh1919.

Changes

  • utils.py: new shared atomic_replace(tmp, target) helper that resolves symlinks through os.path.realpath before os.replace. atomic_json_write / atomic_yaml_write call it instead of inlining the guard.
  • 16 files: every os.replace() call site in the codebase migrated to atomic_replace(). fix: preserve symlinks during atomic file writes (#16743) #16777 fixed 9 sites; this PR widens the same fix to the 10+ sibling sites the original missed:
    • agent: google_oauth.py, nous_rate_guard.py, shell_hooks.py
    • cron: jobs.py
    • gateway: pairing.py, session.py, platforms/telegram.py
    • hermes_cli: auth.py, config.py, debug.py, env_loader.py, model_catalog.py, webhook.py
    • tools: memory_tool.py, skill_manager_tool.py, skills_sync.py

Zero bare os.replace() calls remain in the codebase outside the helper itself.

Root cause

os.replace(tmp, target) atomically swaps tmp into place at target. When target is a symlink, the symlink itself is replaced with a regular file, detaching the user's source-of-truth silently. The helper resolves through realpath first so the real file is overwritten in-place while the symlink survives.

Validation

Before After
symlinked config.yaml after save_config regular file symlink preserved, real file updated
symlinked .env after save_env_value regular file symlink preserved, real file updated
first-time creates worked worked
plain files worked worked
broken symlinks dangling link replaced with regular file symlink preserved, real target created
  • tests/test_atomic_replace_symlinks.py: 8 new tests covering the helper, atomic_json_write, atomic_yaml_write, permission preservation, and the broken-symlink edge case — all pass.
  • 488 tests across affected subsystems (memory, skill manager, cron, config, env_loader, session) pass.
  • E2E: real save_config + save_env_value against a symlinked HERMES_HOME → symlinks survive, tracked source files updated in place.
  • All 16 modified modules import cleanly (no circular-import regressions).

Fixes #16743
Supersedes #16777 (vominh1919's commit cherry-picked, authorship preserved).

vominh1919 and others added 2 commits April 28, 2026 04:58
os.replace(tmp, path) replaces the symlink itself with a regular file,
breaking users who symlink config.yaml, SOUL.md, or .env from ~/.hermes/
to a dotfiles repo or managed profile package.

Fix: resolve symlinks via os.path.realpath() before os.replace(), so the
real file is overwritten in-place while the symlink survives.

Fixed in 7 files covering all os.replace call sites:
- utils.py (atomic_json_write, atomic_yaml_write — fixes save_config)
- hermes_cli/config.py (env sanitizer, save_env_value, remove_env_value)
- tools/skill_manager_tool.py (_atomic_write_text — SOUL.md writes)
- tools/memory_tool.py (memory file writes)
- tools/skills_sync.py (manifest writes)
- cron/jobs.py (job state + output file writes)
- agent/shell_hooks.py (hook file writes)

Fixes #16743
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.
@teknium1 teknium1 force-pushed the hermes/hermes-0793ff7c branch from 096a3da to 4ad210c Compare April 28, 2026 11:58
@teknium1 teknium1 merged commit b61d9b2 into main Apr 28, 2026
4 checks passed
@teknium1 teknium1 deleted the hermes/hermes-0793ff7c branch April 28, 2026 11:58
@alt-glitch alt-glitch added type/bug Something isn't working P1 High — major feature broken, no workaround comp/agent Core agent loop, run_agent.py, prompt builder comp/cli CLI entry point, hermes_cli/, setup wizard comp/gateway Gateway runner, session dispatch, delivery comp/cron Cron scheduler and job management labels Apr 28, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

comp/agent Core agent loop, run_agent.py, prompt builder comp/cli CLI entry point, hermes_cli/, setup wizard comp/cron Cron scheduler and job management comp/gateway Gateway runner, session dispatch, delivery P1 High — major feature broken, no workaround type/bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug]: atomic writes to HERMES_HOME files replace symlinked targets (config.yaml/SOUL.md)

3 participants