{"repo":"NousResearch/hermes-agent","public_id":"16743","display_label":"Symlink preservation in atomic file writes","summary":"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.","subsystem":null,"counts":{"members":3,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":1,"merged_prs":1,"attempt_prs":1},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":16980,"merged_at":"2026-04-28T11:58:22+00:00"},"resolution":{"state":"resolved","by":"pr-16980"},"freshness":{"state":"stale","last_verified_at":"2026-04-28T11:58:22+00:00","is_stale":true},"grouping":{"backbone":"STRUCTURAL_SPANNING","link_density":0.6666666666666666},"fix_edges":[{"issue_number":16743,"pr_number":16980,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true,"pr_merged_at":"2026-04-28T11:58:22+00:00"}],"members":[{"kind":"issue","number":16743,"title":"[Bug]: atomic writes to HERMES_HOME files replace symlinked targets (config.yaml/SOUL.md)","url":null,"state":"closed"},{"kind":"pr","number":16777,"title":"fix: preserve symlinks during atomic file writes (#16743)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"3ab97a32d","number":0},{"kind":"commit","value":"b61d9b297","number":0},{"kind":"pr","value":"#16980","number":16980}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":16980,"title":"fix: preserve symlinks during atomic file writes (#16743)","url":null,"state":"merged","close_reason":{"category":"merged"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["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":[{"file":"agent/google_oauth.py","hunks":["@@ -98,6 +98,7 @@\n \n # Regex patterns for fallback scraping from an installed gemini-cli.\n import re as _re\n+from utils import atomic_replace\n _CLIENT_ID_PATTERN = _re.compile(\n     r\"OAUTH_CLIENT_ID\\s*=\\s*['\\\"]([0-9]+-[a-z0-9]+\\.apps\\.googleusercontent\\.com)['\\\"]\"\n )","@@ -499,7 +500,7 @@ def save_credentials(creds: GoogleCredentials) -> Path:\n                 fh.flush()\n                 os.fsync(fh.fileno())\n             os.chmod(tmp_path, stat.S_IRUSR | stat.S_IWUSR)\n-            os.replace(tmp_path, path)\n+            atomic_replace(tmp_path, path)\n         finally:\n             try:\n                 if tmp_path.exists():"]},{"file":"agent/nous_rate_guard.py","hunks":["@@ -18,6 +18,7 @@\n import tempfile\n import time\n from typing import Any, Mapping, Optional\n+from utils import atomic_replace\n \n logger = logging.getLogger(__name__)\n ","@@ -118,7 +119,7 @@ def record_nous_rate_limit(\n         try:\n             with os.fdopen(fd, \"w\") as f:\n                 json.dump(state, f)\n-            os.replace(tmp_path, path)\n+            atomic_replace(tmp_path, path)\n         except Exception:\n             # Clean up temp file on failure\n             try:"]},{"file":"agent/shell_hooks.py","hunks":["@@ -76,6 +76,7 @@\n     fcntl = None  # type: ignore[assignment]\n \n from hermes_constants import get_hermes_home\n+from utils import atomic_replace\n \n logger = logging.getLogger(__name__)\n ","@@ -568,7 +569,7 @@ def save_allowlist(data: Dict[str, Any]) -> None:\n         try:\n             with os.fdopen(fd, \"w\") as fh:\n                 fh.write(json.dumps(data, indent=2, sort_keys=True))\n-            os.replace(tmp_path, p)\n+            atomic_replace(tmp_path, p)\n         except Exception:\n             try:\n                 os.unlink(tmp_path)"]},{"file":"cron/jobs.py","hunks":["@@ -21,6 +21,7 @@\n logger = logging.getLogger(__name__)\n \n from hermes_time import now as _hermes_now\n+from utils import atomic_replace\n \n try:\n     from croniter import croniter","@@ -367,7 +368,7 @@ def save_jobs(jobs: List[Dict[str, Any]]):\n             json.dump({\"jobs\": jobs, \"updated_at\": _hermes_now().isoformat()}, f, indent=2)\n             f.flush()\n             os.fsync(f.fileno())\n-        os.replace(tmp_path, JOBS_FILE)\n+        atomic_replace(tmp_path, JOBS_FILE)\n         _secure_file(JOBS_FILE)\n     except BaseException:\n         try:","@@ -863,7 +864,7 @@ def save_job_output(job_id: str, output: str):\n             f.write(output)\n             f.flush()\n             os.fsync(f.fileno())\n-        os.replace(tmp_path, output_file)\n+        atomic_replace(tmp_path, output_file)\n         _secure_file(output_file)\n     except BaseException:\n         try:"]},{"file":"gateway/pairing.py","hunks":["@@ -28,6 +28,7 @@\n from typing import Optional\n \n from hermes_constants import get_hermes_dir\n+from utils import atomic_replace\n \n \n # Unambiguous alphabet -- excludes 0/O, 1/I to prevent confusion","@@ -59,7 +60,7 @@ def _secure_write(path: Path, data: str) -> None:\n             f.write(data)\n             f.flush()\n             os.fsync(f.fileno())\n-        os.replace(tmp_path, str(path))\n+        atomic_replace(tmp_path, path)\n         try:\n             os.chmod(path, 0o600)\n         except OSError:"]},{"file":"gateway/platforms/telegram.py","hunks":["@@ -84,6 +84,7 @@ class _MockContextTypes:\n     discover_fallback_ips,\n     parse_fallback_ip_env,\n )\n+from utils import atomic_replace\n \n \n def check_telegram_requirements() -> bool:","@@ -554,7 +555,7 @@ def _persist_dm_topic_thread_id(self, chat_id: int, topic_name: str, thread_id:\n                         _yaml.dump(config, f, default_flow_style=False, sort_keys=False)\n                         f.flush()\n                         os.fsync(f.fileno())\n-                    os.replace(tmp_path, config_path)\n+                    atomic_replace(tmp_path, config_path)\n                 except BaseException:\n                     try:\n                         os.unlink(tmp_path)"]},{"file":"gateway/session.py","hunks":["@@ -64,6 +64,7 @@ def _hash_chat_id(value: str) -> str:\n     canonical_whatsapp_identifier,\n     normalize_whatsapp_identifier,\n )\n+from utils import atomic_replace\n \n \n @dataclass","@@ -705,7 +706,7 @@ def _save(self) -> None:\n                 json.dump(data, f, indent=2)\n                 f.flush()\n                 os.fsync(f.fileno())\n-            os.replace(tmp_path, sessions_file)\n+            atomic_replace(tmp_path, sessions_file)\n         except BaseException:\n             try:\n                 os.unlink(tmp_path)"]},{"file":"hermes_cli/auth.py","hunks":["@@ -43,6 +43,7 @@\n \n from hermes_cli.config import get_hermes_home, get_config_path, read_raw_config\n from hermes_constants import OPENROUTER_BASE_URL\n+from utils import atomic_replace\n \n logger = logging.getLogger(__name__)\n ","@@ -828,7 +829,7 @@ def _save_auth_store(auth_store: Dict[str, Any]) -> Path:\n             handle.write(payload)\n             handle.flush()\n             os.fsync(handle.fileno())\n-        os.replace(tmp_path, auth_file)\n+        atomic_replace(tmp_path, auth_file)\n         try:\n             dir_fd = os.open(str(auth_file.parent), os.O_RDONLY)\n         except OSError:"]},{"file":"hermes_cli/config.py","hunks":["@@ -227,6 +227,7 @@ def get_container_exec_info() -> Optional[dict]:\n \n # Re-export from hermes_constants — canonical definition lives there.\n from hermes_constants import get_hermes_home  # noqa: F811,E402\n+from utils import atomic_replace\n \n def get_config_path() -> Path:\n     \"\"\"Get the main config file path.\"\"\"","@@ -3666,7 +3667,7 @@ def sanitize_env_file() -> int:\n             f.writelines(sanitized)\n             f.flush()\n             os.fsync(f.fileno())\n-        os.replace(tmp_path, env_path)\n+        atomic_replace(tmp_path, env_path)\n     except BaseException:\n         try:\n             os.unlink(tmp_path)","@@ -3769,7 +3770,7 @@ def save_env_value(key: str, value: str):\n             f.writelines(lines)\n             f.flush()\n             os.fsync(f.fileno())\n-        os.replace(tmp_path, env_path)\n+        atomic_replace(tmp_path, env_path)\n         # Restore original permissions before _secure_file may tighten them.\n         if original_mode is not None:\n             try:","@@ -3825,7 +3826,7 @@ def remove_env_value(key: str) -> bool:\n                 f.writelines(new_lines)\n                 f.flush()\n                 os.fsync(f.fileno())\n-            os.replace(tmp_path, env_path)\n+            atomic_replace(tmp_path, env_path)\n             if original_mode is not None:\n                 try:\n                     os.chmod(env_path, original_mode)"]},{"file":"hermes_cli/debug.py","hunks":["@@ -18,6 +18,7 @@\n from typing import Optional\n \n from hermes_constants import get_hermes_home\n+from utils import atomic_replace\n \n \n # ---------------------------------------------------------------------------","@@ -79,7 +80,7 @@ def _save_pending(entries: list[dict]) -> None:\n         path.parent.mkdir(parents=True, exist_ok=True)\n         tmp = path.with_suffix(\".json.tmp\")\n         tmp.write_text(json.dumps(entries, indent=2), encoding=\"utf-8\")\n-        os.replace(tmp, path)\n+        atomic_replace(tmp, path)\n     except OSError:\n         # Non-fatal — worst case the user has to run ``hermes debug delete``\n         # manually."]},{"file":"hermes_cli/env_loader.py","hunks":["@@ -7,6 +7,7 @@\n from pathlib import Path\n \n from dotenv import load_dotenv\n+from utils import atomic_replace\n \n \n # Env var name suffixes that indicate credential values.  These are the","@@ -127,7 +128,7 @@ def _sanitize_env_file_if_needed(path: Path) -> None:\n                     f.writelines(sanitized)\n                     f.flush()\n                     os.fsync(f.fileno())\n-                os.replace(tmp, path)\n+                atomic_replace(tmp, path)\n             except BaseException:\n                 try:\n                     os.unlink(tmp)"]},{"file":"hermes_cli/model_catalog.py","hunks":["@@ -54,6 +54,7 @@\n from typing import Any\n \n from hermes_cli import __version__ as _HERMES_VERSION\n+from utils import atomic_replace\n \n logger = logging.getLogger(__name__)\n ","@@ -190,7 +191,7 @@ def _write_disk_cache(data: dict[str, Any]) -> None:\n         with open(tmp, \"w\") as fh:\n             json.dump(data, fh, indent=2)\n             fh.write(\"\\n\")\n-        os.replace(tmp, path)\n+        atomic_replace(tmp, path)\n     except OSError as exc:\n         logger.info(\"model catalog cache write failed: %s\", exc)\n "]},{"file":"hermes_cli/webhook.py","hunks":["@@ -19,6 +19,7 @@\n from typing import Dict\n \n from hermes_constants import display_hermes_home\n+from utils import atomic_replace\n \n \n _SUBSCRIPTIONS_FILENAME = \"webhook_subscriptions.json\"","@@ -52,7 +53,7 @@ def _save_subscriptions(subs: Dict[str, dict]) -> None:\n         json.dumps(subs, indent=2, ensure_ascii=False),\n         encoding=\"utf-8\",\n     )\n-    os.replace(str(tmp_path), str(path))\n+    atomic_replace(tmp_path, path)\n \n \n def _get_webhook_config() -> dict:"]},{"file":"tests/test_atomic_replace_symlinks.py","hunks":["@@ -0,0 +1,160 @@\n+\"\"\"Regression tests for GitHub #16743 — atomic writes must preserve symlinks.\n+\n+``os.replace(tmp, target)`` replaces whatever exists at ``target`` — including\n+symlinks, which it swaps for a regular file.  Managed deployments that\n+symlink ``~/.hermes/config.yaml`` (and other state files) to a git-tracked\n+profile package were silently detached on every config write.\n+\n+The fix: a shared ``atomic_replace`` helper in ``utils.py`` that resolves the\n+target through ``os.path.realpath`` when it is a symlink, so the real file is\n+overwritten in-place while the symlink survives.  All atomic-write sites in\n+the codebase were migrated to the helper; these tests pin that invariant.\n+\"\"\"\n+from __future__ import annotations\n+\n+import json\n+import os\n+import sys\n+from pathlib import Path\n+\n+import pytest\n+import yaml\n+\n+# Ensure the repo root is importable when running via `pytest tests/...`.\n+_REPO_ROOT = Path(__file__).resolve().parent.parent\n+if str(_REPO_ROOT) not in sys.path:\n+    sys.path.insert(0, str(_REPO_ROOT))\n+\n+from utils import atomic_json_write, atomic_replace, atomic_yaml_write\n+\n+\n+# ─── Direct helper ────────────────────────────────────────────────────────────\n+\n+\n+def _write_tmp(dir_: Path, content: str) -> Path:\n+    tmp = dir_ / \".src.tmp\"\n+    tmp.write_text(content, encoding=\"utf-8\")\n+    return tmp\n+\n+\n+def test_atomic_replace_preserves_symlink(tmp_path: Path) -> None:\n+    real = tmp_path / \"real.yaml\"\n+    link = tmp_path / \"link.yaml\"\n+    real.write_text(\"original\\n\", encoding=\"utf-8\")\n+    link.symlink_to(real)\n+\n+    tmp = _write_tmp(tmp_path, \"updated\\n\")\n+    returned = atomic_replace(tmp, link)\n+\n+    assert link.is_symlink(), \"symlink must not be replaced with a regular file\"\n+    assert real.read_text(encoding=\"utf-8\") == \"updated\\n\"\n+    assert Path(returned) == real\n+    # Follow the symlink — same content.\n+    assert link.read_text(encoding=\"utf-8\") == \"updated\\n\"\n+\n+\n+def test_atomic_replace_regular_file(tmp_path: Path) -> None:\n+    target = tmp_path / \"plain.yaml\"\n+    target.write_text(\"old\\n\", encoding=\"utf-8\")\n+\n+    tmp = _write_tmp(tmp_path, \"fresh\\n\")\n+    returned = atomic_replace(tmp, target)\n+\n+    assert Path(returned) == target\n+    assert target.read_text(encoding=\"utf-8\") == \"fresh\\n\"\n+    assert not target.is_symlink()\n+\n+\n+def test_atomic_replace_first_time_create(tmp_path: Path) -> None:\n+    target = tmp_path / \"new.yaml\"\n+    assert not target.exists()\n+\n+    tmp = _write_tmp(tmp_path, \"brand new\\n\")\n+    returned = atomic_replace(tmp, target)\n+\n+    assert Path(returned) == target\n+    assert target.read_text(encoding=\"utf-8\") == \"brand new\\n\"\n+\n+\n+def test_atomic_replace_accepts_pathlike_and_str(tmp_path: Path) -> None:\n+    target = tmp_path / \"dual.json\"\n+    target.write_text(\"{}\", encoding=\"utf-8\")\n+\n+    # str inputs\n+    tmp1 = _write_tmp(tmp_path, \"1\")\n+    atomic_replace(str(tmp1), str(target))\n+    assert target.read_text(encoding=\"utf-8\") == \"1\"\n+\n+    # Path inputs\n+    tmp2 = _write_tmp(tmp_path, \"2\")\n+    atomic_replace(tmp2, target)\n+    assert target.read_text(encoding=\"utf-8\") == \"2\"\n+\n+\n+# ─── atomic_json_write / atomic_yaml_write wiring ──────────────────────────\n+\n+\n+def test_atomic_json_write_preserves_symlink(tmp_path: Path) -> None:\n+    real = tmp_path / \"real.json\"\n+    link = tmp_path / \"link.json\"\n+    real.write_text(\"{}\", encoding=\"utf-8\")\n+    link.symlink_to(real)\n+\n+    atomic_json_write(link, {\"hello\": \"world\"})\n+\n+    assert link.is_symlink()\n+    loaded = json.loads(real.read_text(encoding=\"utf-8\"))\n+    assert loaded == {\"hello\": \"world\"}\n+\n+\n+def test_atomic_yaml_write_preserves_symlink(tmp_path: Path) -> None:\n+    real = tmp_path / \"real.yaml\"\n+    link = tmp_path / \"link.yaml\"\n+    real.write_text(\"placeholder: true\\n\", encoding=\"utf-8\")\n+    link.symlink_to(real)\n+\n+    atomic_yaml_write(link, {\"model\": {\"provider\": \"openrouter\"}})\n+\n+    assert link.is_symlink()\n+    data = yaml.safe_load(real.read_text(encoding=\"utf-8\"))\n+    assert data == {\"model\": {\"provider\": \"openrouter\"}}\n+\n+\n+def test_atomic_json_write_preserves_symlink_permissions(tmp_path: Path) -> None:\n+    \"\"\"Symlinked targets keep the real file's permission bits.\"\"\"\n+    if os.name != \"posix\":\n+        pytest.skip(\"POSIX-only\")\n+\n+    real = tmp_path / \"real.json\"\n+    link = tmp_path / \"link.json\"\n+    real.write_text(\"{}\", encoding=\"utf-8\")\n+    os.chmod(real, 0o644)\n+    link.symlink_to(real)\n+\n+    atomic_json_write(link, {\"x\": 1})\n+\n+    import stat as _stat\n+    mode = _stat.S_IMODE(real.stat().st_mode)\n+    assert mode == 0o644, f\"permissions drifted after symlinked write: {oct(mode)}\"\n+\n+\n+# ─── Broken-symlink edge case ─────────────────────────────────────────────\n+\n+\n+def test_atomic_replace_broken_symlink_creates_target(tmp_path: Path) -> None:\n+    \"\"\"A symlink pointing at a missing file: the write should create the\n+    real target (resolving via realpath) rather than leaving the dangling\n+    link in place as a regular file.\n+    \"\"\"\n+    missing = tmp_path / \"does_not_exist_yet.yaml\"\n+    link = tmp_path / \"link.yaml\"\n+    link.symlink_to(missing)\n+    assert link.is_symlink()\n+    assert not missing.exists()\n+\n+    tmp = _write_tmp(tmp_path, \"created-through-link\\n\")\n+    atomic_replace(tmp, link)\n+\n+    assert link.is_symlink(), \"symlink must be preserved\"\n+    assert missing.exists(), \"real target should now exist\"\n+    assert missing.read_text(encoding=\"utf-8\") == \"created-through-link\\n\""]},{"file":"tools/memory_tool.py","hunks":["@@ -33,6 +33,8 @@\n from hermes_constants import get_hermes_home\n from typing import Dict, Any, List, Optional\n \n+from utils import atomic_replace\n+\n # fcntl is Unix-only; on Windows use msvcrt for file locking\n msvcrt = None\n try:","@@ -448,7 +450,7 @@ def _write_file(path: Path, entries: List[str]):\n                     f.write(content)\n                     f.flush()\n                     os.fsync(f.fileno())\n-                os.replace(tmp_path, str(path))  # Atomic on same filesystem\n+                atomic_replace(tmp_path, path)\n             except BaseException:\n                 # Clean up temp file on any failure\n                 try:"]},{"file":"tools/skill_manager_tool.py","hunks":["@@ -42,6 +42,8 @@\n from hermes_constants import get_hermes_home, display_hermes_home\n from typing import Dict, Any, Optional, Tuple\n \n+from utils import atomic_replace\n+\n logger = logging.getLogger(__name__)\n \n # Import security scanner — external hub installs always get scanned;","@@ -309,7 +311,7 @@ def _atomic_write_text(file_path: Path, content: str, encoding: str = \"utf-8\") -\n     try:\n         with os.fdopen(fd, \"w\", encoding=encoding) as f:\n             f.write(content)\n-        os.replace(temp_path, file_path)\n+        atomic_replace(temp_path, file_path)\n     except Exception:\n         # Clean up temp file on error\n         try:"]},{"file":"tools/skills_sync.py","hunks":["@@ -28,6 +28,7 @@\n from pathlib import Path\n from hermes_constants import get_hermes_home\n from typing import Dict, List, Tuple\n+from utils import atomic_replace\n \n logger = logging.getLogger(__name__)\n ","@@ -98,7 +99,7 @@ def _write_manifest(entries: Dict[str, str]):\n                 f.write(data)\n                 f.flush()\n                 os.fsync(f.fileno())\n-            os.replace(tmp_path, MANIFEST_FILE)\n+            atomic_replace(tmp_path, MANIFEST_FILE)\n         except BaseException:\n             try:\n                 os.unlink(tmp_path)"]},{"file":"utils.py","hunks":["@@ -58,6 +58,30 @@ def _restore_file_mode(path: Path, mode: \"int | None\") -> None:\n         pass\n \n \n+def atomic_replace(tmp_path: Union[str, Path], target: Union[str, Path]) -> str:\n+    \"\"\"Atomically move *tmp_path* onto *target*, preserving symlinks.\n+\n+    ``os.replace(tmp, target)`` atomically swaps ``tmp`` into place at\n+    ``target``.  When ``target`` is a symlink, the symlink itself is\n+    replaced with a regular file — silently detaching managed deployments\n+    that symlink ``config.yaml`` / ``SOUL.md`` / ``auth.json`` etc. from\n+    ``~/.hermes/`` to a git-tracked profile package or dotfiles repo\n+    (GitHub #16743).\n+\n+    This helper resolves the symlink first so ``os.replace`` writes to\n+    the real file in-place while the symlink survives.  For non-symlink\n+    and non-existent paths the behavior is identical to a plain\n+    ``os.replace`` call.\n+\n+    Returns the resolved real path used for the replace, so callers that\n+    need to re-apply permissions can target it instead of the symlink.\n+    \"\"\"\n+    target_str = str(target)\n+    real_path = os.path.realpath(target_str) if os.path.islink(target_str) else target_str\n+    os.replace(str(tmp_path), real_path)\n+    return real_path\n+\n+\n def atomic_json_write(\n     path: Union[str, Path],\n     data: Any,","@@ -99,8 +123,9 @@ def atomic_json_write(\n             )\n             f.flush()\n             os.fsync(f.fileno())\n-        os.replace(tmp_path, path)\n-        _restore_file_mode(path, original_mode)\n+        # Preserve symlinks — swap in-place on the real file (GitHub #16743).\n+        real_path = atomic_replace(tmp_path, path)\n+        _restore_file_mode(real_path, original_mode)\n     except BaseException:\n         # Intentionally catch BaseException so temp-file cleanup still runs for\n         # KeyboardInterrupt/SystemExit before re-raising the original signal.","@@ -150,8 +175,9 @@ def atomic_yaml_write(\n                 f.write(extra_content)\n             f.flush()\n             os.fsync(f.fileno())\n-        os.replace(tmp_path, path)\n-        _restore_file_mode(path, original_mode)\n+        # Preserve symlinks — swap in-place on the real file (GitHub #16743).\n+        real_path = atomic_replace(tmp_path, path)\n+        _restore_file_mode(real_path, original_mode)\n     except BaseException:\n         # Match atomic_json_write: cleanup must also happen for process-level\n         # interruptions before we re-raise them.\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}