# NousResearch/hermes-agent · Symlink preservation in atomic file writes

## Open now

- Nothing open on GitHub.

A bug report and associated fix attempts addressing atomic file write operations that replace symlinked targets instead of preserving them. The affected files include config.yaml and SOUL.md within HERMES_HOME.

## TL;DR

- 3 member(s): 0 open issue(s), 1 closed issue(s), 0 open PR(s), 1 merged PR(s), 1 PR(s) closed without merging.
- A fix was merged to main in PR #16980. Verify it addresses your case.
- **Recommended:** read PR #16980 (and any competing PRs below) before filing. Confirm the merged change covers your symptom.

## Maintainer resolution verdict

- Internal-tracker verdict over this cluster's issue members: `resolved` · deciding PR(s): `pr-16980`. This is distinct from the fix status above. Weigh it yourself.

## How this cluster was grouped

- Link evidence (`STRUCTURAL_SPANNING`): every member is connected by structural evidence: shared changed files, or a closing/superseded reference.
- Pairwise link density `0.67`: the share of member pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36. `1.00` means every pair is linked.

## Closed / duplicate issues

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

## Prior & competing PRs

- **#16980: fix: preserve symlinks during atomic file writes (#16743)**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`
  - files: `agent/google_oauth.py`, `agent/nous_rate_guard.py`, `agent/shell_hooks.py`, `cron/jobs.py`, `gateway/pairing.py`, `gateway/platforms/telegram.py`, `gateway/session.py`, `hermes_cli/auth.py`, `hermes_cli/config.py`, `hermes_cli/debug.py`, `hermes_cli/env_loader.py`, `hermes_cli/model_catalog.py`, `hermes_cli/webhook.py`, `tests/test_atomic_replace_symlinks.py`, `tools/memory_tool.py`, `tools/skill_manager_tool.py`, `tools/skills_sync.py`, `utils.py`
  - fix hunks:
  - `agent/google_oauth.py`

    ```diff
    @@ -98,6 +98,7 @@
     
     # Regex patterns for fallback scraping from an installed gemini-cli.
     import re as _re
    +from utils import atomic_replace
     _CLIENT_ID_PATTERN = _re.compile(
         r"OAUTH_CLIENT_ID\s*=\s*['\"]([0-9]+-[a-z0-9]+\.apps\.googleusercontent\.com)['\"]"
     )
    ```

    ```diff
    @@ -499,7 +500,7 @@ def save_credentials(creds: GoogleCredentials) -> Path:
                     fh.flush()
                     os.fsync(fh.fileno())
                 os.chmod(tmp_path, stat.S_IRUSR | stat.S_IWUSR)
    -            os.replace(tmp_path, path)
    +            atomic_replace(tmp_path, path)
             finally:
                 try:
                     if tmp_path.exists():
    ```
  - `agent/nous_rate_guard.py`

    ```diff
    @@ -18,6 +18,7 @@
     import tempfile
     import time
     from typing import Any, Mapping, Optional
    +from utils import atomic_replace
     
     logger = logging.getLogger(__name__)
     
    ```

    ```diff
    @@ -118,7 +119,7 @@ def record_nous_rate_limit(
             try:
                 with os.fdopen(fd, "w") as f:
                     json.dump(state, f)
    -            os.replace(tmp_path, path)
    +            atomic_replace(tmp_path, path)
             except Exception:
                 # Clean up temp file on failure
                 try:
    ```
  - `agent/shell_hooks.py`

    ```diff
    @@ -76,6 +76,7 @@
         fcntl = None  # type: ignore[assignment]
     
     from hermes_constants import get_hermes_home
    +from utils import atomic_replace
     
     logger = logging.getLogger(__name__)
     
    ```

    ```diff
    @@ -568,7 +569,7 @@ def save_allowlist(data: Dict[str, Any]) -> None:
             try:
                 with os.fdopen(fd, "w") as fh:
                     fh.write(json.dumps(data, indent=2, sort_keys=True))
    -            os.replace(tmp_path, p)
    +            atomic_replace(tmp_path, p)
             except Exception:
                 try:
                     os.unlink(tmp_path)
    ```
  - `cron/jobs.py`

    ```diff
    @@ -21,6 +21,7 @@
     logger = logging.getLogger(__name__)
     
     from hermes_time import now as _hermes_now
    +from utils import atomic_replace
     
     try:
         from croniter import croniter
    ```

    ```diff
    @@ -367,7 +368,7 @@ def save_jobs(jobs: List[Dict[str, Any]]):
                 json.dump({"jobs": jobs, "updated_at": _hermes_now().isoformat()}, f, indent=2)
                 f.flush()
                 os.fsync(f.fileno())
    -        os.replace(tmp_path, JOBS_FILE)
    +        atomic_replace(tmp_path, JOBS_FILE)
             _secure_file(JOBS_FILE)
         except BaseException:
             try:
    ```

    ```diff
    @@ -863,7 +864,7 @@ def save_job_output(job_id: str, output: str):
                 f.write(output)
                 f.flush()
                 os.fsync(f.fileno())
    -        os.replace(tmp_path, output_file)
    +        atomic_replace(tmp_path, output_file)
             _secure_file(output_file)
         except BaseException:
             try:
    ```
  - `gateway/pairing.py`

    ```diff
    @@ -28,6 +28,7 @@
     from typing import Optional
     
     from hermes_constants import get_hermes_dir
    +from utils import atomic_replace
     
     
     # Unambiguous alphabet -- excludes 0/O, 1/I to prevent confusion
    ```

    ```diff
    @@ -59,7 +60,7 @@ def _secure_write(path: Path, data: str) -> None:
                 f.write(data)
                 f.flush()
                 os.fsync(f.fileno())
    -        os.replace(tmp_path, str(path))
    +        atomic_replace(tmp_path, path)
             try:
                 os.chmod(path, 0o600)
             except OSError:
    ```
  - `gateway/platforms/telegram.py`

    ```diff
    @@ -84,6 +84,7 @@ class _MockContextTypes:
         discover_fallback_ips,
         parse_fallback_ip_env,
     )
    +from utils import atomic_replace
     
     
     def check_telegram_requirements() -> bool:
    ```

    ```diff
    @@ -554,7 +555,7 @@ def _persist_dm_topic_thread_id(self, chat_id: int, topic_name: str, thread_id:
                             _yaml.dump(config, f, default_flow_style=False, sort_keys=False)
                             f.flush()
                             os.fsync(f.fileno())
    -                    os.replace(tmp_path, config_path)
    +                    atomic_replace(tmp_path, config_path)
                     except BaseException:
                         try:
                             os.unlink(tmp_path)
    ```
  - `gateway/session.py`

    ```diff
    @@ -64,6 +64,7 @@ def _hash_chat_id(value: str) -> str:
         canonical_whatsapp_identifier,
         normalize_whatsapp_identifier,
     )
    +from utils import atomic_replace
     
     
     @dataclass
    ```

    ```diff
    @@ -705,7 +706,7 @@ def _save(self) -> None:
                     json.dump(data, f, indent=2)
                     f.flush()
                     os.fsync(f.fileno())
    -            os.replace(tmp_path, sessions_file)
    +            atomic_replace(tmp_path, sessions_file)
             except BaseException:
                 try:
                     os.unlink(tmp_path)
    ```
  - `hermes_cli/auth.py`

    ```diff
    @@ -43,6 +43,7 @@
     
     from hermes_cli.config import get_hermes_home, get_config_path, read_raw_config
     from hermes_constants import OPENROUTER_BASE_URL
    +from utils import atomic_replace
     
     logger = logging.getLogger(__name__)
     
    ```

    ```diff
    @@ -828,7 +829,7 @@ def _save_auth_store(auth_store: Dict[str, Any]) -> Path:
                 handle.write(payload)
                 handle.flush()
                 os.fsync(handle.fileno())
    -        os.replace(tmp_path, auth_file)
    +        atomic_replace(tmp_path, auth_file)
             try:
                 dir_fd = os.open(str(auth_file.parent), os.O_RDONLY)
             except OSError:
    ```
  - `hermes_cli/config.py`

    ```diff
    @@ -227,6 +227,7 @@ def get_container_exec_info() -> Optional[dict]:
     
     # Re-export from hermes_constants — canonical definition lives there.
     from hermes_constants import get_hermes_home  # noqa: F811,E402
    +from utils import atomic_replace
     
     def get_config_path() -> Path:
         """Get the main config file path."""
    ```

    ```diff
    @@ -3666,7 +3667,7 @@ def sanitize_env_file() -> int:
                 f.writelines(sanitized)
                 f.flush()
                 os.fsync(f.fileno())
    -        os.replace(tmp_path, env_path)
    +        atomic_replace(tmp_path, env_path)
         except BaseException:
             try:
                 os.unlink(tmp_path)
    ```

    ```diff
    @@ -3769,7 +3770,7 @@ def save_env_value(key: str, value: str):
                 f.writelines(lines)
                 f.flush()
                 os.fsync(f.fileno())
    -        os.replace(tmp_path, env_path)
    +        atomic_replace(tmp_path, env_path)
             # Restore original permissions before _secure_file may tighten them.
             if original_mode is not None:
                 try:
    ```

    ```diff
    @@ -3825,7 +3826,7 @@ def remove_env_value(key: str) -> bool:
                     f.writelines(new_lines)
                     f.flush()
                     os.fsync(f.fileno())
    -            os.replace(tmp_path, env_path)
    +            atomic_replace(tmp_path, env_path)
                 if original_mode is not None:
                     try:
                         os.chmod(env_path, original_mode)
    ```
  - `hermes_cli/debug.py`

    ```diff
    @@ -18,6 +18,7 @@
     from typing import Optional
     
     from hermes_constants import get_hermes_home
    +from utils import atomic_replace
     
     
     # ---------------------------------------------------------------------------
    ```

    ```diff
    @@ -79,7 +80,7 @@ def _save_pending(entries: list[dict]) -> None:
             path.parent.mkdir(parents=True, exist_ok=True)
             tmp = path.with_suffix(".json.tmp")
             tmp.write_text(json.dumps(entries, indent=2), encoding="utf-8")
    -        os.replace(tmp, path)
    +        atomic_replace(tmp, path)
         except OSError:
             # Non-fatal — worst case the user has to run ``hermes debug delete``
             # manually.
    ```
  - `hermes_cli/env_loader.py`

    ```diff
    @@ -7,6 +7,7 @@
     from pathlib import Path
     
     from dotenv import load_dotenv
    +from utils import atomic_replace
     
     
     # Env var name suffixes that indicate credential values.  These are the
    ```

    ```diff
    @@ -127,7 +128,7 @@ def _sanitize_env_file_if_needed(path: Path) -> None:
                         f.writelines(sanitized)
                         f.flush()
                         os.fsync(f.fileno())
    -                os.replace(tmp, path)
    +                atomic_replace(tmp, path)
                 except BaseException:
                     try:
                         os.unlink(tmp)
    ```
  - `hermes_cli/model_catalog.py`

    ```diff
    @@ -54,6 +54,7 @@
     from typing import Any
     
     from hermes_cli import __version__ as _HERMES_VERSION
    +from utils import atomic_replace
     
     logger = logging.getLogger(__name__)
     
    ```

    ```diff
    @@ -190,7 +191,7 @@ def _write_disk_cache(data: dict[str, Any]) -> None:
             with open(tmp, "w") as fh:
                 json.dump(data, fh, indent=2)
                 fh.write("\n")
    -        os.replace(tmp, path)
    +        atomic_replace(tmp, path)
         except OSError as exc:
             logger.info("model catalog cache write failed: %s", exc)
     
    ```
  - `hermes_cli/webhook.py`

    ```diff
    @@ -19,6 +19,7 @@
     from typing import Dict
     
     from hermes_constants import display_hermes_home
    +from utils import atomic_replace
     
     
     _SUBSCRIPTIONS_FILENAME = "webhook_subscriptions.json"
    ```

    ```diff
    @@ -52,7 +53,7 @@ def _save_subscriptions(subs: Dict[str, dict]) -> None:
             json.dumps(subs, indent=2, ensure_ascii=False),
             encoding="utf-8",
         )
    -    os.replace(str(tmp_path), str(path))
    +    atomic_replace(tmp_path, path)
     
     
     def _get_webhook_config() -> dict:
    ```
  - `tests/test_atomic_replace_symlinks.py`

    ```diff
    @@ -0,0 +1,160 @@
    +"""Regression tests for GitHub #16743 — atomic writes must preserve symlinks.
    +
    +``os.replace(tmp, target)`` replaces whatever exists at ``target`` — including
    +symlinks, which it swaps for a regular file.  Managed deployments that
    +symlink ``~/.hermes/config.yaml`` (and other state files) to a git-tracked
    +profile package were silently detached on every config write.
    +
    +The fix: a shared ``atomic_replace`` helper in ``utils.py`` that resolves the
    +target through ``os.path.realpath`` when it is a symlink, so the real file is
    +overwritten in-place while the symlink survives.  All atomic-write sites in
    +the codebase were migrated to the helper; these tests pin that invariant.
    +"""
    +from __future__ import annotations
    +
    +import json
    +import os
    +import sys
    +from pathlib import Path
    +
    +import pytest
    +import yaml
    +
    +# Ensure the repo root is importable when running via `pytest tests/...`.
    +_REPO_ROOT = Path(__file__).resolve().parent.parent
    +if str(_REPO_ROOT) not in sys.path:
    +    sys.path.insert(0, str(_REPO_ROOT))
    +
    +from utils import atomic_json_write, atomic_replace, atomic_yaml_write
    +
    +
    +# ─── Direct helper ────────────────────────────────────────────────────────────
    +
    +
    +def _write_tmp(dir_: Path, content: str) -> Path:
    +    tmp = dir_ / ".src.tmp"
    +    tmp.write_text(content, encoding="utf-8")
    +    return tmp
    +
    +
    +def test_atomic_replace_preserves_symlink(tmp_path: Path) -> None:
    +    real = tmp_path / "real.yaml"
    +    link = tmp_path / "link.yaml"
    +    real.write_text("original\n", encoding="utf-8")
    +    link.symlink_to(real)
    +
    +    tmp = _write_tmp(tmp_path, "updated\n")
    +    returned = atomic_replace(tmp, link)
    +
    +    assert link.is_symlink(), "symlink must not be replaced with a regular file"
    +    assert real.read_text(encoding="utf-8") == "updated\n"
    +    assert Path(returned) == real
    +    # Follow the symlink — same content.
    +    assert link.read_text(encoding="utf-8") == "updated\n"
    +
    +
    +def test_atomic_replace_regular_file(tmp_path: Path) -> None:
    +    target = tmp_path / "plain.yaml"
    +    target.write_text("old\n", encoding="utf-8")
    +
    +    tmp = _write_tmp(tmp_path, "fresh\n")
    +    returned = atomic_replace(tmp, target)
    +
    +    assert Path(returned) == target
    +    assert target.read_text(encoding="utf-8") == "fresh\n"
    +    assert not target.is_symlink()
    +
    +
    +def test_atomic_replace_first_time_create(tmp_path: Path) -> None:
    +    target = tmp_path / "new.yaml"
    +    assert not target.exists()
    +
    +    tmp = _write_tmp(tmp_path, "brand new\n")
    +    returned = atomic_replace(tmp, target)
    +
    +    assert Path(returned) == target
    +    assert target.read_text(encoding="utf-8") == "brand new\n"
    +
    +
    +def test_atomic_replace_accepts_pathlike_and_str(tmp_path: Path) -> None:
    +    target = tmp_path / "dual.json"
    +    target.write_text("{}", encoding="utf-8")
    +
    +    # str inputs
    +    tmp1 = _write_tmp(tmp_path, "1")
    +    atomic_replace(str(tmp1), str(target))
    +    assert target.read_text(encoding="utf-8") == "1"
    +
    +    # Path inputs
    +    tmp2 = _write_tmp(tmp_path, "2")
    +    atomic_replace(tmp2, target)
    +    assert target.read_text(encoding="utf-8") == "2"
    +
    +
    +# ─── atomic_json_write / atomic_yaml_write wiring ──────────────────────────
    +
    +
    +def test_atomic_json_write_preserves_symlink(tmp_path: Path) -> None:
    +    real = tmp_path / "real.json"
    +    link = tmp_path / "link.json"
    +    real.write_text("{}", encoding="utf-8")
    +    link.symlink_to(real)
    +
    +    atomic_json_write(link, {"hello": "world"})
    +
    +    assert link.is_symlink()
    +    loaded = json.loads(real.read_text(encoding="utf-8"))
    +    assert loaded == {"hello": "world"}
    +
    +
    +def test_atomic_yaml_write_preserves_symlink(tmp_path: Path) -> None:
    +    real = tmp_path / "real.yaml"
    +    link = tmp_path / "link.yaml"
    +    real.write_text("placeholder: true\n", encoding="utf-8")
    +    link.symlink_to(real)
    +
    +    atomic_yaml_write(link, {"model": {"provider": "openrouter"}})
    +
    +    assert link.is_symlink()
    +    data = yaml.safe_load(real.read_text(encoding="utf-8"))
    +    assert data == {"model": {"provider": "openrouter"}}
    +
    +
    +def test_atomic_json_write_preserves_symlink_permissions(tmp_path: Path) -> None:
    +    """Symlinked targets keep the real file's permission bits."""
    +    if os.name != "posix":
    +        pytest.skip("POSIX-only")
    +
    +    real = tmp_path / "real.json"
    +    link = tmp_path / "link.json"
    +    real.write_text("{}", encoding="utf-8")
    +    os.chmod(real, 0o644)
    +    link.symlink_to(real)
    +
    +    atomic_json_write(link, {"x": 1})
    +
    +    import stat as _stat
    +    mode = _stat.S_IMODE(real.stat().st_mode)
    +    assert mode == 0o644, f"permissions drifted after symlinked write: {oct(mode)}"
    +
    +
    +# ─── Broken-symlink edge case ─────────────────────────────────────────────
    +
    +
    +def test_atomic_replace_broken_symlink_creates_target(tmp_path: Path) -> None:
    +    """A symlink pointing at a missing file: the write should create the
    +    real target (resolving via realpath) rather than leaving the dangling
    +    link in place as a regular file.
    +    """
    +    missing = tmp_path / "does_not_exist_yet.yaml"
    +    link = tmp_path / "link.yaml"
    +    link.symlink_to(missing)
    +    assert link.is_symlink()
    +    assert not missing.exists()
    +
    +    tmp = _write_tmp(tmp_path, "created-through-link\n")
    +    atomic_replace(tmp, link)
    +
    +    assert link.is_symlink(), "symlink must be preserved"
    +    assert missing.exists(), "real target should now exist"
    +    assert missing.read_text(encoding="utf-8") == "created-through-link\n"
    ```
  - `tools/memory_tool.py`

    ```diff
    @@ -33,6 +33,8 @@
     from hermes_constants import get_hermes_home
     from typing import Dict, Any, List, Optional
     
    +from utils import atomic_replace
    +
     # fcntl is Unix-only; on Windows use msvcrt for file locking
     msvcrt = None
     try:
    ```

    ```diff
    @@ -448,7 +450,7 @@ def _write_file(path: Path, entries: List[str]):
                         f.write(content)
                         f.flush()
                         os.fsync(f.fileno())
    -                os.replace(tmp_path, str(path))  # Atomic on same filesystem
    +                atomic_replace(tmp_path, path)
                 except BaseException:
                     # Clean up temp file on any failure
                     try:
    ```
  - `tools/skill_manager_tool.py`

    ```diff
    @@ -42,6 +42,8 @@
     from hermes_constants import get_hermes_home, display_hermes_home
     from typing import Dict, Any, Optional, Tuple
     
    +from utils import atomic_replace
    +
     logger = logging.getLogger(__name__)
     
     # Import security scanner — external hub installs always get scanned;
    ```

    ```diff
    @@ -309,7 +311,7 @@ def _atomic_write_text(file_path: Path, content: str, encoding: str = "utf-8") -
         try:
             with os.fdopen(fd, "w", encoding=encoding) as f:
                 f.write(content)
    -        os.replace(temp_path, file_path)
    +        atomic_replace(temp_path, file_path)
         except Exception:
             # Clean up temp file on error
             try:
    ```
  - `tools/skills_sync.py`

    ```diff
    @@ -28,6 +28,7 @@
     from pathlib import Path
     from hermes_constants import get_hermes_home
     from typing import Dict, List, Tuple
    +from utils import atomic_replace
     
     logger = logging.getLogger(__name__)
     
    ```

    ```diff
    @@ -98,7 +99,7 @@ def _write_manifest(entries: Dict[str, str]):
                     f.write(data)
                     f.flush()
                     os.fsync(f.fileno())
    -            os.replace(tmp_path, MANIFEST_FILE)
    +            atomic_replace(tmp_path, MANIFEST_FILE)
             except BaseException:
                 try:
                     os.unlink(tmp_path)
    ```
  - `utils.py`

    ```diff
    @@ -58,6 +58,30 @@ def _restore_file_mode(path: Path, mode: "int | None") -> None:
             pass
     
     
    +def atomic_replace(tmp_path: Union[str, Path], target: Union[str, Path]) -> str:
    +    """Atomically move *tmp_path* onto *target*, preserving symlinks.
    +
    +    ``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 — silently detaching managed deployments
    +    that symlink ``config.yaml`` / ``SOUL.md`` / ``auth.json`` etc. from
    +    ``~/.hermes/`` to a git-tracked profile package or dotfiles repo
    +    (GitHub #16743).
    +
    +    This helper resolves the symlink first so ``os.replace`` writes to
    +    the real file in-place while the symlink survives.  For non-symlink
    +    and non-existent paths the behavior is identical to a plain
    +    ``os.replace`` call.
    +
    +    Returns the resolved real path used for the replace, so callers that
    +    need to re-apply permissions can target it instead of the symlink.
    +    """
    +    target_str = str(target)
    +    real_path = os.path.realpath(target_str) if os.path.islink(target_str) else target_str
    +    os.replace(str(tmp_path), real_path)
    +    return real_path
    +
    +
     def atomic_json_write(
         path: Union[str, Path],
         data: Any,
    ```

    ```diff
    @@ -99,8 +123,9 @@ def atomic_json_write(
                 )
                 f.flush()
                 os.fsync(f.fileno())
    -        os.replace(tmp_path, path)
    -        _restore_file_mode(path, original_mode)
    +        # Preserve symlinks — swap in-place on the real file (GitHub #16743).
    +        real_path = atomic_replace(tmp_path, path)
    +        _restore_file_mode(real_path, original_mode)
         except BaseException:
             # Intentionally catch BaseException so temp-file cleanup still runs for
             # KeyboardInterrupt/SystemExit before re-raising the original signal.
    ```

    ```diff
    @@ -150,8 +175,9 @@ def atomic_yaml_write(
                     f.write(extra_content)
                 f.flush()
                 os.fsync(f.fileno())
    -        os.replace(tmp_path, path)
    -        _restore_file_mode(path, original_mode)
    +        # Preserve symlinks — swap in-place on the real file (GitHub #16743).
    +        real_path = atomic_replace(tmp_path, path)
    +        _restore_file_mode(real_path, original_mode)
         except BaseException:
             # Match atomic_json_write: cleanup must also happen for process-level
             # interruptions before we re-raise them.
    ```
- **#16777: fix: preserve symlinks during atomic file writes (#16743)**. closed without merge
  - maintainer direction: category `merged_via`; implication `superseded`; refs `3ab97a32d`, `b61d9b297`, `#16980`

## Fix status

- Merged to main in PR #16980 (cited above). No release data is tracked. Verify downstream.
- Fix last verified (merged) `2026-04-28`. This confirmation is old and may be out of date (a revert/reopen would not have invalidated it here); re-verify.
