From 40fbb0f3c6fe32b69e74615c6a6b160b377a2179 Mon Sep 17 00:00:00 2001 From: LeonSGP43 Date: Tue, 26 May 2026 10:32:03 +0800 Subject: [PATCH] fix(constants): use windows native default hermes home --- hermes_constants.py | 34 +++++++++++++++++++------------ tests/test_hermes_constants.py | 37 +++++++++++++++++++++++++++++++++- 2 files changed, 57 insertions(+), 14 deletions(-) diff --git a/hermes_constants.py b/hermes_constants.py index 3ec977441..b9d633ba8 100644 --- a/hermes_constants.py +++ b/hermes_constants.py @@ -5,6 +5,7 @@ without risk of circular imports. """ import os +import sys import sysconfig from contextvars import ContextVar, Token from pathlib import Path @@ -40,17 +41,26 @@ def get_hermes_home_override() -> str | None: return str(override) -def get_hermes_home() -> Path: - """Return the Hermes home directory (default: ~/.hermes). +def _get_platform_default_hermes_home() -> Path: + """Return the platform-native default Hermes home path.""" + if sys.platform == "win32": + local_appdata = os.environ.get("LOCALAPPDATA", "").strip() + base = Path(local_appdata) if local_appdata else Path.home() / "AppData" / "Local" + return base / "hermes" + return Path.home() / ".hermes" - Reads HERMES_HOME env var, falls back to ~/.hermes. + +def get_hermes_home() -> Path: + """Return the Hermes home directory (default: platform-native path). + + Reads HERMES_HOME env var, falls back to the platform-native default. This is the single source of truth — all other copies should import this. When ``HERMES_HOME`` is unset but an ``active_profile`` file indicates a non-default profile is active, logs a loud one-shot warning to ``errors.log`` so cross-profile data corruption is diagnosable instead of silent. Behavior is unchanged otherwise — we still return - ``~/.hermes`` — because raising here would brick 30+ module-level + the platform-native default — because raising here would brick 30+ module-level callers that import this at load time. Subprocess spawners are expected to propagate ``HERMES_HOME`` explicitly (see the systemd template in ``hermes_cli/gateway.py`` and the kanban dispatcher in @@ -69,10 +79,8 @@ def get_hermes_home() -> Path: global _profile_fallback_warned if not _profile_fallback_warned: try: - # Inline the default-root resolution from get_default_hermes_root() - # to stay import-safe (this function is called from module scope - # in 30+ files; we cannot afford to trigger logging setup here). - active_path = (Path.home() / ".hermes" / "active_profile") + fallback_home = _get_platform_default_hermes_home() + active_path = fallback_home / "active_profile" active = active_path.read_text().strip() if active_path.exists() else "" except (UnicodeDecodeError, OSError): active = "" @@ -83,10 +91,9 @@ def get_hermes_home() -> Path: # module-import time from 30+ sites, often before logging is # configured, and (b) root-logger propagation would double-emit # on consoles where a StreamHandler is already attached. - import sys msg = ( f"[HERMES_HOME fallback] HERMES_HOME is unset but active " - f"profile is {active!r}. Falling back to ~/.hermes, which " + f"profile is {active!r}. Falling back to {fallback_home}, which " f"is the DEFAULT profile — not {active!r}. Any data this " f"process writes will land in the wrong profile. The " f"subprocess spawner should pass HERMES_HOME explicitly " @@ -98,13 +105,14 @@ def get_hermes_home() -> Path: except Exception: pass - return Path.home() / ".hermes" + return _get_platform_default_hermes_home() def get_default_hermes_root() -> Path: """Return the root Hermes directory for profile-level operations. - In standard deployments this is ``~/.hermes``. + In standard deployments this is the platform-native Hermes home + (``~/.hermes`` on POSIX, ``%LOCALAPPDATA%\\hermes`` on native Windows). In Docker or custom deployments where ``HERMES_HOME`` points outside ``~/.hermes`` (e.g. ``/opt/data``), returns ``HERMES_HOME`` directly @@ -117,7 +125,7 @@ def get_default_hermes_root() -> Path: Import-safe — no dependencies beyond stdlib. """ - native_home = Path.home() / ".hermes" + native_home = _get_platform_default_hermes_home() env_home = os.environ.get("HERMES_HOME", "") if not env_home: return native_home diff --git a/tests/test_hermes_constants.py b/tests/test_hermes_constants.py index 3bd31c2bf..de347e23e 100644 --- a/tests/test_hermes_constants.py +++ b/tests/test_hermes_constants.py @@ -9,6 +9,7 @@ import hermes_constants from hermes_constants import ( VALID_REASONING_EFFORTS, get_default_hermes_root, + get_hermes_home, is_container, parse_reasoning_effort, secure_parent_dir, @@ -68,6 +69,41 @@ class TestGetDefaultHermesRoot: monkeypatch.setenv("HERMES_HOME", str(profile)) assert get_default_hermes_root() == docker_root + def test_no_hermes_home_returns_localappdata_root_on_windows(self, tmp_path, monkeypatch): + """Native Windows falls back to %LOCALAPPDATA%\\hermes, not ~/.hermes.""" + local_appdata = tmp_path / "LocalAppData" + monkeypatch.delenv("HERMES_HOME", raising=False) + monkeypatch.setenv("LOCALAPPDATA", str(local_appdata)) + monkeypatch.setattr(Path, "home", lambda: tmp_path / "Home") + monkeypatch.setattr(hermes_constants.sys, "platform", "win32") + + assert get_default_hermes_root() == local_appdata / "hermes" + + def test_no_hermes_home_uses_windows_path_when_localappdata_missing(self, tmp_path, monkeypatch): + """Windows fallback still uses AppData/Local/hermes without LOCALAPPDATA.""" + home = tmp_path / "Home" + monkeypatch.delenv("HERMES_HOME", raising=False) + monkeypatch.delenv("LOCALAPPDATA", raising=False) + monkeypatch.setattr(Path, "home", lambda: home) + monkeypatch.setattr(hermes_constants.sys, "platform", "win32") + + assert get_default_hermes_root() == home / "AppData" / "Local" / "hermes" + + +class TestGetHermesHome: + """Tests for get_hermes_home() platform-aware fallback.""" + + def test_windows_fallback_uses_localappdata(self, tmp_path, monkeypatch): + """When HERMES_HOME is unset on Windows, use %LOCALAPPDATA%\\hermes.""" + local_appdata = tmp_path / "LocalAppData" + monkeypatch.delenv("HERMES_HOME", raising=False) + monkeypatch.setenv("LOCALAPPDATA", str(local_appdata)) + monkeypatch.setattr(Path, "home", lambda: tmp_path / "Home") + monkeypatch.setattr(hermes_constants.sys, "platform", "win32") + monkeypatch.setattr(hermes_constants, "_profile_fallback_warned", False) + + assert get_hermes_home() == local_appdata / "hermes" + class TestIsContainer: """Tests for is_container() — Docker/Podman detection.""" @@ -262,4 +298,3 @@ class TestSecureParentDir: assert len(called_with) == 1 assert called_with[0] == (str(real_dir), 0o700) -