fix(#2698): pin isolated runtime home to configured profile

This commit is contained in:
Rod Boev authored and nesquena-hermes committed 2026-06-20 00:48:19 +00:00
1 parent 8da03dbbad
commit 498a5e73e3
2 files changed
+91 -4

No files matched your search

+21 -4
View File
@@ -40,6 +40,7 @@ _CLONE_CONFIG_FILES = ['config.yaml', '.env', 'SOUL.md']
# base home, which would disable isolation detection. Snapshot it here at import
# time (before init_profile_state runs) and use the snapshot in the detector.
_INITIAL_HERMES_HOME = os.getenv('HERMES_HOME', '').strip()
_ISOLATED_SYMLINK_WARNING_EMITTED = False
# ── Module state ────────────────────────────────────────────────────────────
_active_profile = 'default'
@@ -152,6 +153,15 @@ def _is_isolated_profile_mode() -> bool:
# i.e., parent dir is named 'profiles' and grandparent exists
if p.parent.name == 'profiles' and p.parent.parent.exists():
return True
if p.is_symlink():
global _ISOLATED_SYMLINK_WARNING_EMITTED
if not _ISOLATED_SYMLINK_WARNING_EMITTED:
logger.warning(
"HERMES_HOME symlink %s does not literally match */profiles/<name>; "
"isolated profile mode is disabled unless the literal profile path is used.",
p,
)
_ISOLATED_SYMLINK_WARNING_EMITTED = True
return False
@@ -325,9 +335,12 @@ def get_active_profile_name() -> str:
"""Return the currently active profile name.
Priority:
1. Thread-local (set per-request from hermes_profile cookie) — issue #798
2. Process-level default (_active_profile)
1. Isolated-profile deployment name from the configured HERMES_HOME path
2. Thread-local (set per-request from hermes_profile cookie) — issue #798
3. Process-level default (_active_profile)
"""
if _is_isolated_profile_mode():
return _isolated_profile_name()
tls_name = getattr(_tls, 'profile', None)
if tls_name is not None:
return tls_name
@@ -1009,8 +1022,12 @@ def init_profile_state() -> None:
module-level cached paths. Called once from config.py after imports.
"""
global _active_profile
_active_profile = _read_active_profile_file()
home = get_active_hermes_home()
if _is_isolated_profile_mode():
_active_profile = _isolated_profile_name()
home = Path(_INITIAL_HERMES_HOME).expanduser()
else:
_active_profile = _read_active_profile_file()
home = get_active_hermes_home()
_set_hermes_home(home)
install_cron_scheduler_profile_isolation()
_reload_dotenv(home)
@@ -16,9 +16,14 @@ import pytest
import api.profiles as _profiles_mod
from api.profiles import (
_is_isolated_profile_mode,
clear_request_profile,
list_profiles_api,
create_profile_api,
delete_profile_api,
get_active_hermes_home,
get_active_profile_name,
init_profile_state,
set_request_profile,
switch_profile,
)
@@ -187,6 +192,71 @@ class TestListProfilesInIsolatedMode:
assert len(profiles) == 1
class TestIsolatedRuntimePinning:
"""Test that isolated mode pins both detection and active runtime home."""
def test_get_active_profile_name_ignores_tls_and_global_in_isolated_mode(self, temp_single_profile):
"""Isolated mode must ignore request TLS and process-global active profile."""
base_home = temp_single_profile.parent.parent
env_dict = {
"HERMES_HOME": str(temp_single_profile),
"HERMES_BASE_HOME": "",
}
with mock.patch.dict(os.environ, env_dict, clear=False):
with mock.patch("api.profiles._DEFAULT_HERMES_HOME", base_home):
with mock.patch("api.profiles._INITIAL_HERMES_HOME", str(temp_single_profile)):
with mock.patch("api.profiles._active_profile", "other_profile"):
set_request_profile("tls_profile")
try:
assert get_active_profile_name() == "user1"
finally:
clear_request_profile()
@pytest.mark.parametrize("active_profile_contents", [None, "user2"])
def test_init_profile_state_pins_runtime_home_to_isolated_profile(
self,
temp_single_profile,
monkeypatch,
active_profile_contents,
):
"""init_profile_state must ignore the base active_profile file in isolated mode."""
base_home = temp_single_profile.parent.parent
other_profile = base_home / "profiles" / "user2"
other_profile.mkdir()
for subdir in ["memories", "sessions", "skills", "skins", "logs", "plans", "workspace", "cron"]:
(other_profile / subdir).mkdir(exist_ok=True)
active_profile_file = base_home / "active_profile"
if active_profile_contents is None:
active_profile_file.unlink(missing_ok=True)
else:
active_profile_file.write_text(active_profile_contents, encoding="utf-8")
monkeypatch.setenv("HERMES_HOME", str(temp_single_profile))
monkeypatch.setenv("HERMES_BASE_HOME", "")
monkeypatch.setattr(_profiles_mod, "_DEFAULT_HERMES_HOME", base_home)
monkeypatch.setattr(_profiles_mod, "_INITIAL_HERMES_HOME", str(temp_single_profile))
monkeypatch.setattr(_profiles_mod, "_active_profile", "default")
monkeypatch.setattr(_profiles_mod, "install_cron_scheduler_profile_isolation", lambda: None)
reloaded = []
real_reload_dotenv = _profiles_mod._reload_dotenv
def _record_reload(home):
reloaded.append(home)
return real_reload_dotenv(home)
monkeypatch.setattr(_profiles_mod, "_reload_dotenv", _record_reload)
init_profile_state()
assert _profiles_mod._active_profile == "user1"
assert get_active_profile_name() == "user1"
assert get_active_hermes_home() == temp_single_profile
assert Path(os.environ["HERMES_HOME"]) == temp_single_profile
assert reloaded == [temp_single_profile]
class TestProfileMutationsInIsolatedMode:
"""Test that create/delete/switch are rejected (403) in isolated mode."""