fix(constants): use windows native default hermes home

This commit is contained in:
LeonSGP43
2026-05-26 10:32:03 +08:00
committed by Teknium
parent e3313c50a7
commit 40fbb0f3c6
2 changed files with 57 additions and 14 deletions

View File

@ -5,6 +5,7 @@ without risk of circular imports.
""" """
import os import os
import sys
import sysconfig import sysconfig
from contextvars import ContextVar, Token from contextvars import ContextVar, Token
from pathlib import Path from pathlib import Path
@ -40,17 +41,26 @@ def get_hermes_home_override() -> str | None:
return str(override) return str(override)
def get_hermes_home() -> Path: def _get_platform_default_hermes_home() -> Path:
"""Return the Hermes home directory (default: ~/.hermes). """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. This is the single source of truth — all other copies should import this.
When ``HERMES_HOME`` is unset but an ``active_profile`` file indicates When ``HERMES_HOME`` is unset but an ``active_profile`` file indicates
a non-default profile is active, logs a loud one-shot warning to a non-default profile is active, logs a loud one-shot warning to
``errors.log`` so cross-profile data corruption is diagnosable instead ``errors.log`` so cross-profile data corruption is diagnosable instead
of silent. Behavior is unchanged otherwise — we still return 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 callers that import this at load time. Subprocess spawners are
expected to propagate ``HERMES_HOME`` explicitly (see the systemd expected to propagate ``HERMES_HOME`` explicitly (see the systemd
template in ``hermes_cli/gateway.py`` and the kanban dispatcher in template in ``hermes_cli/gateway.py`` and the kanban dispatcher in
@ -69,10 +79,8 @@ def get_hermes_home() -> Path:
global _profile_fallback_warned global _profile_fallback_warned
if not _profile_fallback_warned: if not _profile_fallback_warned:
try: try:
# Inline the default-root resolution from get_default_hermes_root() fallback_home = _get_platform_default_hermes_home()
# to stay import-safe (this function is called from module scope active_path = fallback_home / "active_profile"
# in 30+ files; we cannot afford to trigger logging setup here).
active_path = (Path.home() / ".hermes" / "active_profile")
active = active_path.read_text().strip() if active_path.exists() else "" active = active_path.read_text().strip() if active_path.exists() else ""
except (UnicodeDecodeError, OSError): except (UnicodeDecodeError, OSError):
active = "" active = ""
@ -83,10 +91,9 @@ def get_hermes_home() -> Path:
# module-import time from 30+ sites, often before logging is # module-import time from 30+ sites, often before logging is
# configured, and (b) root-logger propagation would double-emit # configured, and (b) root-logger propagation would double-emit
# on consoles where a StreamHandler is already attached. # on consoles where a StreamHandler is already attached.
import sys
msg = ( msg = (
f"[HERMES_HOME fallback] HERMES_HOME is unset but active " 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"is the DEFAULT profile — not {active!r}. Any data this "
f"process writes will land in the wrong profile. The " f"process writes will land in the wrong profile. The "
f"subprocess spawner should pass HERMES_HOME explicitly " f"subprocess spawner should pass HERMES_HOME explicitly "
@ -98,13 +105,14 @@ def get_hermes_home() -> Path:
except Exception: except Exception:
pass pass
return Path.home() / ".hermes" return _get_platform_default_hermes_home()
def get_default_hermes_root() -> Path: def get_default_hermes_root() -> Path:
"""Return the root Hermes directory for profile-level operations. """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 In Docker or custom deployments where ``HERMES_HOME`` points outside
``~/.hermes`` (e.g. ``/opt/data``), returns ``HERMES_HOME`` directly ``~/.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. 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", "") env_home = os.environ.get("HERMES_HOME", "")
if not env_home: if not env_home:
return native_home return native_home

View File

@ -9,6 +9,7 @@ import hermes_constants
from hermes_constants import ( from hermes_constants import (
VALID_REASONING_EFFORTS, VALID_REASONING_EFFORTS,
get_default_hermes_root, get_default_hermes_root,
get_hermes_home,
is_container, is_container,
parse_reasoning_effort, parse_reasoning_effort,
secure_parent_dir, secure_parent_dir,
@ -68,6 +69,41 @@ class TestGetDefaultHermesRoot:
monkeypatch.setenv("HERMES_HOME", str(profile)) monkeypatch.setenv("HERMES_HOME", str(profile))
assert get_default_hermes_root() == docker_root 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: class TestIsContainer:
"""Tests for is_container() — Docker/Podman detection.""" """Tests for is_container() — Docker/Podman detection."""
@ -262,4 +298,3 @@ class TestSecureParentDir:
assert len(called_with) == 1 assert len(called_with) == 1
assert called_with[0] == (str(real_dir), 0o700) assert called_with[0] == (str(real_dir), 0o700)