diff --git a/api/profiles.py b/api/profiles.py index 27057792..c1d4aaa9 100644 --- a/api/profiles.py +++ b/api/profiles.py @@ -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/; " + "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) diff --git a/tests/test_issue2698_isolated_hermes_home.py b/tests/test_issue2698_isolated_hermes_home.py index d685342c..232094f1 100644 --- a/tests/test_issue2698_isolated_hermes_home.py +++ b/tests/test_issue2698_isolated_hermes_home.py @@ -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."""