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 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

View File

@ -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)