diff --git a/tests/hermes_cli/test_atomic_json_write.py b/tests/hermes_cli/test_atomic_json_write.py index 6c3e94f6b..3068cceea 100644 --- a/tests/hermes_cli/test_atomic_json_write.py +++ b/tests/hermes_cli/test_atomic_json_write.py @@ -1,6 +1,7 @@ """Tests for utils.atomic_json_write — crash-safe JSON file writes.""" import json +import os from pathlib import Path from unittest.mock import patch @@ -132,6 +133,38 @@ class TestAtomicJsonWrite: assert result["emoji"] == "🎉" assert result["japanese"] == "日本語" + def test_mode_does_not_crash_without_fchmod(self, tmp_path): + """Regression: os.fchmod is Unix-only and absent on Windows. Passing a + mode must not raise AttributeError when fchmod is unavailable. + + Simulates the Windows os module by removing fchmod from the namespace. + Previously this crashed in `hermes memory setup` while saving the + Hindsight config with mode=0o600 (GitHub: Windows setup traceback). + """ + import utils + + target = tmp_path / "secret.json" + no_fchmod = {k: getattr(os, k) for k in dir(os) if k != "fchmod"} + fake_os = type("FakeOs", (), no_fchmod) + assert not hasattr(fake_os, "fchmod") + + with patch.object(utils, "os", fake_os): + atomic_json_write(target, {"api_key": "secret"}, mode=0o600) + + assert json.loads(target.read_text(encoding="utf-8")) == {"api_key": "secret"} + + def test_mode_applied_when_supported(self, tmp_path): + import stat as stat_mod + + target = tmp_path / "secret.json" + atomic_json_write(target, {"api_key": "secret"}, mode=0o600) + + # os.chmod's effect is platform-dependent (Windows only honors the + # write bit), so only assert the durable mode on POSIX. + if hasattr(os, "fchmod"): + actual = stat_mod.S_IMODE(target.stat().st_mode) + assert actual == 0o600 + def test_concurrent_writes_dont_corrupt(self, tmp_path): """Multiple rapid writes should each produce valid JSON.""" import threading diff --git a/utils.py b/utils.py index cb08ba128..b2c9f1e83 100644 --- a/utils.py +++ b/utils.py @@ -117,7 +117,10 @@ def atomic_json_write( suffix=".tmp", ) try: - if mode is not None: + if mode is not None and hasattr(os, "fchmod"): + # fchmod is Unix-only; Windows' os module has no fchmod. Skipping it + # here is safe — mkstemp already created the temp file as 0o600, and + # the post-replace os.chmod below applies the final mode durably. os.fchmod(fd, mode) with os.fdopen(fd, "w", encoding="utf-8") as f: json.dump(