From 963f7c386468d2b71041deb72cd0a4cee904efdd Mon Sep 17 00:00:00 2001 From: Rod Boev Date: Thu, 18 Jun 2026 19:48:50 -0400 Subject: [PATCH 01/21] fix(profiles): honor isolated HERMES_HOME profile mode (#2698) --- api/profiles.py | 85 +++++++- api/routes.py | 12 +- static/panels.js | 52 +++-- tests/test_issue2698_isolated_hermes_home.py | 217 +++++++++++++++++++ 4 files changed, 345 insertions(+), 21 deletions(-) create mode 100644 tests/test_issue2698_isolated_hermes_home.py diff --git a/api/profiles.py b/api/profiles.py index 53965ec2..24e60d7c 100644 --- a/api/profiles.py +++ b/api/profiles.py @@ -116,6 +116,33 @@ def _unwrap_profile_home_to_base(home: Path) -> Path: return home +def _is_isolated_profile_mode() -> bool: + """Detect isolated single-profile mode from HERMES_HOME env var. + + Returns True when HERMES_HOME points at a concrete profile subdirectory + (e.g., ~/.hermes/profiles/user1) rather than the base home (~/.hermes). + This indicates a single-profile deployment where the WebUI should pin to + that profile and reject cross-profile operations. + + HERMES_BASE_HOME env var, if set, overrides and forces non-isolated mode + (allows deployments to explicitly opt out of isolation detection). + """ + # Explicit HERMES_BASE_HOME opt-out takes precedence + if os.getenv('HERMES_BASE_HOME', '').strip(): + return False + + hermes_home = os.getenv('HERMES_HOME', '').strip() + if not hermes_home: + return False + + p = Path(hermes_home).expanduser() + # Check if this path looks like ~/.hermes/profiles/ + # i.e., parent dir is named 'profiles' and grandparent exists + if p.parent.name == 'profiles' and p.parent.parent.exists(): + return True + return False + + def _resolve_base_hermes_home() -> Path: """Return the BASE ~/.hermes directory — the root that contains profiles/. @@ -978,6 +1005,9 @@ def switch_profile(name: str, *, process_wide: bool = True) -> dict: Validates the profile exists, updates process state, patches module caches, reloads .env, and reloads config.yaml. + In isolated profile mode, switching to a different profile is rejected (403). + Switching to the isolated profile itself is allowed (idempotent). + Args: name: Profile name to switch to. process_wide: If True (default), updates the process-global @@ -989,6 +1019,15 @@ def switch_profile(name: str, *, process_wide: bool = True) -> dict: """ global _active_profile + # In isolated profile mode, reject switching to other profiles + if _is_isolated_profile_mode(): + active = get_active_profile_name() + if name != active: + raise ValueError( + f"Profile switching is not allowed in isolated profile mode. " + f"Currently pinned to profile '{active}'." + ) + # Import here to avoid circular import at module load from api.config import STREAMS, STREAMS_LOCK, reload_config @@ -1281,6 +1320,9 @@ def _build_profile_rows_fast() -> list | None: def list_profiles_api() -> list: """List all profiles with metadata, serialized for JSON response. + In isolated profile mode (HERMES_HOME points to ~/.hermes/profiles/), + returns only that single profile and skips other profiles entirely. + Fast path: build the rows from upstream's cheap per-profile helpers and skip ``find_alias_for_profile`` (whose result the WebUI discards) — see ``_build_profile_rows_fast``. Results are cached for a short TTL so rapid @@ -1292,6 +1334,35 @@ def list_profiles_api() -> list: global _LIST_PROFILES_CACHE now = time.time() + # In isolated profile mode, return only the active (isolated) profile + if _is_isolated_profile_mode(): + active = get_active_profile_name() + try: + from hermes_cli.profiles import list_profiles + infos = list_profiles() + # Find the current profile and return only that one + for p in infos: + if p.name == active: + enabled_count, total_count = _get_profile_skills_stats(p.path) + return [{ + 'name': p.name, + 'path': str(p.path), + 'is_default': p.is_default, + 'is_active': True, # Always true in isolated mode + 'gateway_running': p.gateway_running, + 'model': p.model, + 'provider': p.provider, + 'has_env': p.has_env, + 'visible': _profile_visible_from_meta(p.path), + 'skill_count': enabled_count, + 'enabled_skills': enabled_count, + 'total_skills': total_count, + }] + except ImportError: + pass + # Fallback to default-only in isolated mode + return [_default_profile_dict()] + with _LIST_PROFILES_CACHE_LOCK: cached = _LIST_PROFILES_CACHE if cached is not None and now - cached[1] < _LIST_PROFILES_CACHE_TTL: @@ -1709,7 +1780,12 @@ def create_profile_api(name: str, clone_from: str = None, api_key: str = None, default_model: str = None, model_provider: str = None) -> dict: - """Create a new profile. Returns the new profile info dict.""" + """Create a new profile. Returns the new profile info dict. + + In isolated profile mode, profile creation is rejected (403). + """ + if _is_isolated_profile_mode(): + raise ValueError("Profile creation is not allowed in isolated profile mode.") _validate_profile_name(name) # Defense-in-depth: validate clone_from here too, even though routes.py # also validates it. Any caller that bypasses the HTTP layer gets protection. @@ -1808,7 +1884,12 @@ def create_profile_api(name: str, clone_from: str = None, def delete_profile_api(name: str) -> dict: - """Delete a profile. Switches to default first if it's the active one.""" + """Delete a profile. Switches to default first if it's the active one. + + In isolated profile mode, profile deletion is rejected (403). + """ + if _is_isolated_profile_mode(): + raise ValueError("Profile deletion is not allowed in isolated profile mode.") if _is_root_profile(name): raise ValueError("Cannot delete the default profile.") _validate_profile_name(name) diff --git a/api/routes.py b/api/routes.py index 94c431bd..e579465a 100644 --- a/api/routes.py +++ b/api/routes.py @@ -8586,11 +8586,19 @@ def handle_get(handler, parsed) -> bool: # ── Profile API (GET) ── if parsed.path == "/api/profiles": - from api.profiles import list_profiles_api, get_active_profile_name + from api.profiles import ( + list_profiles_api, + get_active_profile_name, + _is_isolated_profile_mode, + ) return j( handler, - {"profiles": list_profiles_api(), "active": get_active_profile_name()}, + { + "profiles": list_profiles_api(), + "active": get_active_profile_name(), + "single_profile_mode": _is_isolated_profile_mode(), + }, ) if parsed.path == "/api/profile/active": diff --git a/static/panels.js b/static/panels.js index 9aa3e35e..f834ca01 100644 --- a/static/panels.js +++ b/static/panels.js @@ -5566,17 +5566,28 @@ async function loadProfilesPanel() { const data = await api('/api/profiles'); _profilesCache = data; panel.innerHTML = ''; - const explainer = document.createElement('div'); - explainer.className = 'profile-card profile-help-card'; - explainer.innerHTML = ` -
-
-
Profiles vs workspaces
-
Use profiles for how the agent works; use workspaces for what files it works on.
-
-
`; - explainer.onclick = () => _renderProfileConceptHelp(data.active || 'default'); - panel.appendChild(explainer); + + // Hide "New profile" button in single profile mode + const newProfileBtn = document.querySelector('[onclick="openProfileCreate()"]'); + if (newProfileBtn) { + newProfileBtn.style.display = data.single_profile_mode ? 'none' : ''; + } + + // In single profile mode, don't show the explanatory card + if (!data.single_profile_mode) { + const explainer = document.createElement('div'); + explainer.className = 'profile-card profile-help-card'; + explainer.innerHTML = ` +
+
+
Profiles vs workspaces
+
Use profiles for how the agent works; use workspaces for what files it works on.
+
+
`; + explainer.onclick = () => _renderProfileConceptHelp(data.active || 'default'); + panel.appendChild(explainer); + } + if (!data.profiles || !data.profiles.length) { const emptyMsg = document.createElement('div'); emptyMsg.style.cssText = 'padding:16px;color:var(--muted);font-size:12px'; @@ -5774,12 +5785,14 @@ function renderProfileDropdown(data) { }; dd.appendChild(opt); } - // Divider + Manage link - const div = document.createElement('div'); div.className = 'ws-divider'; dd.appendChild(div); - const mgmt = document.createElement('div'); mgmt.className = 'profile-opt ws-manage'; - mgmt.innerHTML = `${li('settings',12)} ${esc(t('manage_profiles'))}`; - mgmt.onclick = () => { closeProfileDropdown(); mobileSwitchPanel('profiles'); }; - dd.appendChild(mgmt); + // Divider + Manage link (hidden in single profile mode) + if (!data.single_profile_mode) { + const div = document.createElement('div'); div.className = 'ws-divider'; dd.appendChild(div); + const mgmt = document.createElement('div'); mgmt.className = 'profile-opt ws-manage'; + mgmt.innerHTML = `${li('settings',12)} ${esc(t('manage_profiles'))}`; + mgmt.onclick = () => { closeProfileDropdown(); mobileSwitchPanel('profiles'); }; + dd.appendChild(mgmt); + } } function toggleProfileDropdown() { @@ -5789,6 +5802,11 @@ function toggleProfileDropdown() { closeWsDropdown(); // close workspace dropdown if open if(typeof closeModelDropdown==='function') closeModelDropdown(); api('/api/profiles').then(data => { + // In single profile mode, don't show profile dropdown at all + if (data.single_profile_mode) { + closeProfileDropdown(); + return; + } renderProfileDropdown(data); dd.classList.add('open'); _positionProfileDropdown(); diff --git a/tests/test_issue2698_isolated_hermes_home.py b/tests/test_issue2698_isolated_hermes_home.py new file mode 100644 index 00000000..aaf03dc5 --- /dev/null +++ b/tests/test_issue2698_isolated_hermes_home.py @@ -0,0 +1,217 @@ +""" +Tests for issue #2698: HERMES_HOME isolated profile mode. + +When HERMES_HOME points at a specific profile directory like ~/.hermes/profiles/user1, +the WebUI should pin to that single profile: list only it, reject create/switch/delete +of other profiles, and hide multi-profile UI affordances. +""" + +import json +import os +import tempfile +from pathlib import Path +from unittest import mock + +import pytest + +from api.profiles import ( + _is_isolated_profile_mode, + list_profiles_api, + create_profile_api, + delete_profile_api, + _DEFAULT_HERMES_HOME, +) + + +@pytest.fixture +def temp_hermes_home(): + """Create a temporary .hermes directory structure for testing.""" + with tempfile.TemporaryDirectory() as tmpdir: + home = Path(tmpdir) / ".hermes" + home.mkdir() + profiles_root = home / "profiles" + profiles_root.mkdir() + yield home + + +@pytest.fixture +def temp_single_profile(): + """Create a temporary .hermes/profiles/user1 structure for isolated mode.""" + with tempfile.TemporaryDirectory() as tmpdir: + home = Path(tmpdir) / ".hermes" + home.mkdir() + profiles_root = home / "profiles" + profiles_root.mkdir() + user1 = profiles_root / "user1" + user1.mkdir() + # Create required subdirs + for subdir in ["memories", "sessions", "skills", "skins", "logs", "plans", "workspace", "cron"]: + (user1 / subdir).mkdir(exist_ok=True) + yield user1 + + +class TestIsolatedProfileModeDetection: + """Test _is_isolated_profile_mode() helper.""" + + def test_normal_mode_when_hermes_home_is_base(self, temp_hermes_home): + """Normal mode when HERMES_HOME points to base ~/.hermes.""" + with mock.patch.dict(os.environ, {"HERMES_HOME": str(temp_hermes_home)}): + with mock.patch("api.profiles._DEFAULT_HERMES_HOME", temp_hermes_home): + # Re-import to capture the patched env var during _resolve_base_hermes_home + # For now, test the function directly with the base home + isolated = _is_isolated_profile_mode() + assert isolated is False + + def test_isolated_mode_when_hermes_home_is_profile_subdir(self, temp_single_profile): + """Isolated mode when HERMES_HOME points to ~/.hermes/profiles/user1.""" + # Ensure we're in the fixture context where temp_single_profile exists + base_home = temp_single_profile.parent.parent + assert temp_single_profile.exists(), f"Test fixture path doesn't exist: {temp_single_profile}" + assert temp_single_profile.parent.name == "profiles", f"Parent not named 'profiles': {temp_single_profile.parent}" + + # Save and restore env to ensure clean test + saved_hermes_home = os.environ.get("HERMES_HOME") + saved_hermes_base_home = os.environ.get("HERMES_BASE_HOME") + try: + os.environ["HERMES_HOME"] = str(temp_single_profile) + # Clear HERMES_BASE_HOME to allow isolation detection + os.environ.pop("HERMES_BASE_HOME", None) + + # _is_isolated_profile_mode() reads HERMES_HOME from env and checks if + # its parent is named 'profiles'. + isolated = _is_isolated_profile_mode() + assert isolated is True, f"Expected isolated mode for {temp_single_profile}" + finally: + # Restore original env + if saved_hermes_home is not None: + os.environ["HERMES_HOME"] = saved_hermes_home + else: + os.environ.pop("HERMES_HOME", None) + if saved_hermes_base_home is not None: + os.environ["HERMES_BASE_HOME"] = saved_hermes_base_home + + def test_hermes_base_home_override_forces_normal_mode(self, temp_single_profile): + """HERMES_BASE_HOME env var override forces normal mode even with profile subdir.""" + base_home = temp_single_profile.parent.parent + with mock.patch.dict( + os.environ, + { + "HERMES_HOME": str(temp_single_profile), + "HERMES_BASE_HOME": str(base_home), + }, + ): + with mock.patch("api.profiles._DEFAULT_HERMES_HOME", base_home): + isolated = _is_isolated_profile_mode() + assert isolated is False + + +class TestListProfilesInIsolatedMode: + """Test list_profiles_api() returns only isolated profile when in isolated mode.""" + + def test_list_returns_all_profiles_in_normal_mode(self, temp_hermes_home): + """Normal mode lists all profiles.""" + # Create a few test profiles + profiles_root = temp_hermes_home / "profiles" + (profiles_root / "user1").mkdir() + (profiles_root / "user2").mkdir() + (profiles_root / "user3").mkdir() + + # Create required subdirs for each + for prof_dir in profiles_root.iterdir(): + if prof_dir.is_dir(): + for subdir in ["memories", "sessions", "skills", "skins", "logs", "plans", "workspace", "cron"]: + (prof_dir / subdir).mkdir(exist_ok=True) + + with mock.patch.dict(os.environ, {"HERMES_HOME": str(temp_hermes_home)}): + with mock.patch("api.profiles._DEFAULT_HERMES_HOME", temp_hermes_home): + with mock.patch("api.profiles._is_isolated_profile_mode", return_value=False): + profiles = list_profiles_api() + # Should have at least 'default' plus the created profiles + names = [p["name"] for p in profiles] + assert "user1" in names + assert "user2" in names + assert "user3" in names + + def test_list_returns_only_isolated_profile_in_isolated_mode(self, temp_single_profile): + """Isolated mode lists only the configured profile.""" + base_home = temp_single_profile.parent.parent + # Create other profiles that should be hidden + other_profiles = base_home / "profiles" + (other_profiles / "user2").mkdir() + (other_profiles / "user3").mkdir() + for prof_dir in [other_profiles / "user2", other_profiles / "user3"]: + for subdir in ["memories", "sessions", "skills", "skins", "logs", "plans", "workspace", "cron"]: + (prof_dir / subdir).mkdir(exist_ok=True) + + with mock.patch.dict(os.environ, {"HERMES_HOME": str(temp_single_profile)}): + with mock.patch("api.profiles._DEFAULT_HERMES_HOME", base_home): + with mock.patch("api.profiles._is_isolated_profile_mode", return_value=True): + with mock.patch("api.profiles._resolve_base_hermes_home", return_value=base_home): + with mock.patch("api.profiles.get_active_profile_name", return_value="user1"): + profiles = list_profiles_api() + # Should only have user1 + assert len(profiles) == 1 + assert profiles[0]["name"] == "user1" + + def test_list_includes_single_profile_mode_flag(self, temp_single_profile): + """Response includes single_profile_mode: true in isolated mode.""" + base_home = temp_single_profile.parent.parent + with mock.patch.dict(os.environ, {"HERMES_HOME": str(temp_single_profile)}): + with mock.patch("api.profiles._DEFAULT_HERMES_HOME", base_home): + with mock.patch("api.profiles._is_isolated_profile_mode", return_value=True): + with mock.patch("api.profiles._resolve_base_hermes_home", return_value=base_home): + with mock.patch("api.profiles.get_active_profile_name", return_value="user1"): + profiles = list_profiles_api() + # Check for single_profile_mode flag in response structure + # For now, profiles should be a list; the flag will be in routes.py response + assert len(profiles) == 1 + + +class TestProfileMutationsInIsolatedMode: + """Test that create/delete/switch are rejected (403) in isolated mode.""" + + def test_create_profile_rejected_in_isolated_mode(self, temp_single_profile): + """create_profile_api should reject creation in isolated mode.""" + base_home = temp_single_profile.parent.parent + with mock.patch.dict(os.environ, {"HERMES_HOME": str(temp_single_profile)}): + with mock.patch("api.profiles._DEFAULT_HERMES_HOME", base_home): + with mock.patch("api.profiles._is_isolated_profile_mode", return_value=True): + with pytest.raises(ValueError, match=".*isolated.*|.*single.*|.*403"): + create_profile_api("newprofile") + + def test_delete_profile_rejected_in_isolated_mode(self, temp_single_profile): + """delete_profile_api should reject deletion in isolated mode.""" + base_home = temp_single_profile.parent.parent + with mock.patch.dict(os.environ, {"HERMES_HOME": str(temp_single_profile)}): + with mock.patch("api.profiles._DEFAULT_HERMES_HOME", base_home): + with mock.patch("api.profiles._is_isolated_profile_mode", return_value=True): + with pytest.raises(ValueError, match=".*isolated.*|.*single.*|.*403"): + delete_profile_api("user1") + + +class TestNormalModePreservation: + """Test that normal mode behavior is completely unchanged.""" + + def test_normal_mode_profile_operations_work(self, temp_hermes_home): + """Normal mode allows profile creation and deletion.""" + profiles_root = temp_hermes_home / "profiles" + + with mock.patch.dict(os.environ, {"HERMES_HOME": str(temp_hermes_home)}): + with mock.patch("api.profiles._DEFAULT_HERMES_HOME", temp_hermes_home): + with mock.patch("api.profiles._is_isolated_profile_mode", return_value=False): + # Normal mode should not raise errors for create/delete operations + # (though they may fail for other reasons in this test environment) + try: + # Just verify the isolation guard doesn't trigger + from api.profiles import create_profile_api + # The actual call might fail due to missing hermes_cli, + # but should NOT fail with an "isolated mode" error + try: + create_profile_api("testprof1") + except ValueError as e: + # Should be a different error, not about isolation + assert "isolated" not in str(e).lower() + assert "single" not in str(e).lower() + except ImportError: + # hermes_cli not available, skip + pass From 36dbe1a415a2c9dfbcdcb271fc84b00894978c6e Mon Sep 17 00:00:00 2001 From: Rod Boev Date: Thu, 18 Jun 2026 20:09:49 -0400 Subject: [PATCH 02/21] fix(profiles): return 403 for isolated-mode guards, fix fallback identity (#2698) --- api/profiles.py | 27 ++++++++++--- api/routes.py | 6 +++ tests/test_issue2698_isolated_hermes_home.py | 41 +++++++++++++------- 3 files changed, 54 insertions(+), 20 deletions(-) diff --git a/api/profiles.py b/api/profiles.py index 24e60d7c..31def3d3 100644 --- a/api/profiles.py +++ b/api/profiles.py @@ -1015,7 +1015,7 @@ def switch_profile(name: str, *, process_wide: bool = True) -> dict: WebUI where the profile is managed via cookie + thread-local (#798). Returns: {'profiles': [...], 'active': name} - Raises ValueError if profile doesn't exist or agent is busy. + Raises PermissionError if profile doesn't exist or agent is busy. """ global _active_profile @@ -1023,7 +1023,7 @@ def switch_profile(name: str, *, process_wide: bool = True) -> dict: if _is_isolated_profile_mode(): active = get_active_profile_name() if name != active: - raise ValueError( + raise PermissionError( f"Profile switching is not allowed in isolated profile mode. " f"Currently pinned to profile '{active}'." ) @@ -1337,6 +1337,7 @@ def list_profiles_api() -> list: # In isolated profile mode, return only the active (isolated) profile if _is_isolated_profile_mode(): active = get_active_profile_name() + hermes_home = get_active_hermes_home() try: from hermes_cli.profiles import list_profiles infos = list_profiles() @@ -1360,8 +1361,22 @@ def list_profiles_api() -> list: }] except ImportError: pass - # Fallback to default-only in isolated mode - return [_default_profile_dict()] + # Fallback: construct profile dict with actual active name and hermes_home path + enabled_count, total_count = _get_profile_skills_stats(hermes_home) + return [{ + 'name': active, + 'path': str(hermes_home), + 'is_default': False, + 'is_active': True, + 'gateway_running': False, + 'model': None, + 'provider': None, + 'has_env': (hermes_home / '.env').exists(), + 'visible': _profile_visible_from_meta(hermes_home), + 'skill_count': enabled_count, + 'enabled_skills': enabled_count, + 'total_skills': total_count, + }] with _LIST_PROFILES_CACHE_LOCK: cached = _LIST_PROFILES_CACHE @@ -1785,7 +1800,7 @@ def create_profile_api(name: str, clone_from: str = None, In isolated profile mode, profile creation is rejected (403). """ if _is_isolated_profile_mode(): - raise ValueError("Profile creation is not allowed in isolated profile mode.") + raise PermissionError("Profile creation is not allowed in isolated profile mode.") _validate_profile_name(name) # Defense-in-depth: validate clone_from here too, even though routes.py # also validates it. Any caller that bypasses the HTTP layer gets protection. @@ -1889,7 +1904,7 @@ def delete_profile_api(name: str) -> dict: In isolated profile mode, profile deletion is rejected (403). """ if _is_isolated_profile_mode(): - raise ValueError("Profile deletion is not allowed in isolated profile mode.") + raise PermissionError("Profile deletion is not allowed in isolated profile mode.") if _is_root_profile(name): raise ValueError("Cannot delete the default profile.") _validate_profile_name(name) diff --git a/api/routes.py b/api/routes.py index e579465a..b8249cbc 100644 --- a/api/routes.py +++ b/api/routes.py @@ -10072,6 +10072,8 @@ def handle_post(handler, parsed) -> bool: return j(handler, result, extra_headers={ 'Set-Cookie': build_profile_cookie(name, handler), }) + except PermissionError as e: + return bad(handler, _sanitize_error(e), 403) except (ValueError, FileNotFoundError) as e: return bad(handler, _sanitize_error(e), 404) except RuntimeError as e: @@ -10112,6 +10114,8 @@ def handle_post(handler, parsed) -> bool: model_provider=model_provider, ) return j(handler, {"ok": True, "profile": result}) + except PermissionError as e: + return bad(handler, str(e), 403) except (ValueError, FileExistsError, RuntimeError) as e: return bad(handler, str(e)) @@ -10125,6 +10129,8 @@ def handle_post(handler, parsed) -> bool: _validate_profile_name(name) result = delete_profile_api(name) return j(handler, result) + except PermissionError as e: + return bad(handler, _sanitize_error(e), 403) except (ValueError, FileNotFoundError) as e: return bad(handler, _sanitize_error(e)) except RuntimeError as e: diff --git a/tests/test_issue2698_isolated_hermes_home.py b/tests/test_issue2698_isolated_hermes_home.py index aaf03dc5..aaf09fb8 100644 --- a/tests/test_issue2698_isolated_hermes_home.py +++ b/tests/test_issue2698_isolated_hermes_home.py @@ -143,15 +143,18 @@ class TestListProfilesInIsolatedMode: for subdir in ["memories", "sessions", "skills", "skins", "logs", "plans", "workspace", "cron"]: (prof_dir / subdir).mkdir(exist_ok=True) - with mock.patch.dict(os.environ, {"HERMES_HOME": str(temp_single_profile)}): + # Clear HERMES_BASE_HOME to allow isolated mode detection to fire + 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._is_isolated_profile_mode", return_value=True): - with mock.patch("api.profiles._resolve_base_hermes_home", return_value=base_home): - with mock.patch("api.profiles.get_active_profile_name", return_value="user1"): - profiles = list_profiles_api() - # Should only have user1 - assert len(profiles) == 1 - assert profiles[0]["name"] == "user1" + with mock.patch("api.profiles.get_active_profile_name", return_value="user1"): + profiles = list_profiles_api() + # Should only have user1 + assert len(profiles) == 1 + assert profiles[0]["name"] == "user1" def test_list_includes_single_profile_mode_flag(self, temp_single_profile): """Response includes single_profile_mode: true in isolated mode.""" @@ -173,19 +176,29 @@ class TestProfileMutationsInIsolatedMode: def test_create_profile_rejected_in_isolated_mode(self, temp_single_profile): """create_profile_api should reject creation in isolated mode.""" base_home = temp_single_profile.parent.parent - with mock.patch.dict(os.environ, {"HERMES_HOME": str(temp_single_profile)}): + # Clear HERMES_BASE_HOME to allow isolated mode detection to fire + 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._is_isolated_profile_mode", return_value=True): - with pytest.raises(ValueError, match=".*isolated.*|.*single.*|.*403"): + with mock.patch("api.profiles.get_active_profile_name", return_value="user1"): + with pytest.raises(PermissionError, match=".*isolated.*|.*single.*"): create_profile_api("newprofile") def test_delete_profile_rejected_in_isolated_mode(self, temp_single_profile): """delete_profile_api should reject deletion in isolated mode.""" base_home = temp_single_profile.parent.parent - with mock.patch.dict(os.environ, {"HERMES_HOME": str(temp_single_profile)}): + # Clear HERMES_BASE_HOME to allow isolated mode detection to fire + 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._is_isolated_profile_mode", return_value=True): - with pytest.raises(ValueError, match=".*isolated.*|.*single.*|.*403"): + with mock.patch("api.profiles.get_active_profile_name", return_value="user1"): + with pytest.raises(PermissionError, match=".*isolated.*|.*single.*"): delete_profile_api("user1") From 94c3d2028afea7b26fd88167c6eb4005c8753883 Mon Sep 17 00:00:00 2001 From: Rod Boev Date: Thu, 18 Jun 2026 20:21:22 -0400 Subject: [PATCH 03/21] fix(tests): remove unused imports and variables (#2698) --- tests/test_issue2698_isolated_hermes_home.py | 5 ----- 1 file changed, 5 deletions(-) diff --git a/tests/test_issue2698_isolated_hermes_home.py b/tests/test_issue2698_isolated_hermes_home.py index aaf09fb8..2e35fdf2 100644 --- a/tests/test_issue2698_isolated_hermes_home.py +++ b/tests/test_issue2698_isolated_hermes_home.py @@ -6,7 +6,6 @@ the WebUI should pin to that single profile: list only it, reject create/switch/ of other profiles, and hide multi-profile UI affordances. """ -import json import os import tempfile from pathlib import Path @@ -19,7 +18,6 @@ from api.profiles import ( list_profiles_api, create_profile_api, delete_profile_api, - _DEFAULT_HERMES_HOME, ) @@ -65,7 +63,6 @@ class TestIsolatedProfileModeDetection: def test_isolated_mode_when_hermes_home_is_profile_subdir(self, temp_single_profile): """Isolated mode when HERMES_HOME points to ~/.hermes/profiles/user1.""" # Ensure we're in the fixture context where temp_single_profile exists - base_home = temp_single_profile.parent.parent assert temp_single_profile.exists(), f"Test fixture path doesn't exist: {temp_single_profile}" assert temp_single_profile.parent.name == "profiles", f"Parent not named 'profiles': {temp_single_profile.parent}" @@ -207,8 +204,6 @@ class TestNormalModePreservation: def test_normal_mode_profile_operations_work(self, temp_hermes_home): """Normal mode allows profile creation and deletion.""" - profiles_root = temp_hermes_home / "profiles" - with mock.patch.dict(os.environ, {"HERMES_HOME": str(temp_hermes_home)}): with mock.patch("api.profiles._DEFAULT_HERMES_HOME", temp_hermes_home): with mock.patch("api.profiles._is_isolated_profile_mode", return_value=False): From bf7208f19b0c25b615022ea848ea3dd4882655c6 Mon Sep 17 00:00:00 2001 From: Rod Boev Date: Thu, 18 Jun 2026 20:30:58 -0400 Subject: [PATCH 04/21] fix(test): mock agent.skill_utils to fix CI import errors (#2698) --- tests/test_issue2698_isolated_hermes_home.py | 53 ++++++++++++++------ 1 file changed, 38 insertions(+), 15 deletions(-) diff --git a/tests/test_issue2698_isolated_hermes_home.py b/tests/test_issue2698_isolated_hermes_home.py index 2e35fdf2..b1448a06 100644 --- a/tests/test_issue2698_isolated_hermes_home.py +++ b/tests/test_issue2698_isolated_hermes_home.py @@ -122,12 +122,31 @@ class TestListProfilesInIsolatedMode: with mock.patch.dict(os.environ, {"HERMES_HOME": str(temp_hermes_home)}): with mock.patch("api.profiles._DEFAULT_HERMES_HOME", temp_hermes_home): with mock.patch("api.profiles._is_isolated_profile_mode", return_value=False): - profiles = list_profiles_api() - # Should have at least 'default' plus the created profiles - names = [p["name"] for p in profiles] - assert "user1" in names - assert "user2" in names - assert "user3" in names + # Mock _get_profile_skills_stats to avoid importing agent.skill_utils + with mock.patch("api.profiles._get_profile_skills_stats", return_value=(0, 0)): + # Mock _build_profile_rows_fast to return the expected profiles + def mock_build_profiles(): + return [ + {'name': 'default', 'path': str(temp_hermes_home), 'is_default': True, + 'is_active': False, 'gateway_running': False, 'model': None, 'provider': None, + 'has_env': False, 'visible': True, 'skill_count': 0, 'enabled_skills': 0, 'total_skills': 0}, + {'name': 'user1', 'path': str(profiles_root / 'user1'), 'is_default': False, + 'is_active': False, 'gateway_running': False, 'model': None, 'provider': None, + 'has_env': False, 'visible': True, 'skill_count': 0, 'enabled_skills': 0, 'total_skills': 0}, + {'name': 'user2', 'path': str(profiles_root / 'user2'), 'is_default': False, + 'is_active': False, 'gateway_running': False, 'model': None, 'provider': None, + 'has_env': False, 'visible': True, 'skill_count': 0, 'enabled_skills': 0, 'total_skills': 0}, + {'name': 'user3', 'path': str(profiles_root / 'user3'), 'is_default': False, + 'is_active': False, 'gateway_running': False, 'model': None, 'provider': None, + 'has_env': False, 'visible': True, 'skill_count': 0, 'enabled_skills': 0, 'total_skills': 0}, + ] + with mock.patch("api.profiles._build_profile_rows_fast", side_effect=mock_build_profiles): + profiles = list_profiles_api() + # Should have at least 'default' plus the created profiles + names = [p["name"] for p in profiles] + assert "user1" in names + assert "user2" in names + assert "user3" in names def test_list_returns_only_isolated_profile_in_isolated_mode(self, temp_single_profile): """Isolated mode lists only the configured profile.""" @@ -147,11 +166,13 @@ class TestListProfilesInIsolatedMode: } 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.get_active_profile_name", return_value="user1"): - profiles = list_profiles_api() - # Should only have user1 - assert len(profiles) == 1 - assert profiles[0]["name"] == "user1" + # Mock _get_profile_skills_stats to avoid importing agent.skill_utils + with mock.patch("api.profiles._get_profile_skills_stats", return_value=(0, 0)): + with mock.patch("api.profiles.get_active_profile_name", return_value="user1"): + profiles = list_profiles_api() + # Should only have user1 + assert len(profiles) == 1 + assert profiles[0]["name"] == "user1" def test_list_includes_single_profile_mode_flag(self, temp_single_profile): """Response includes single_profile_mode: true in isolated mode.""" @@ -161,10 +182,12 @@ class TestListProfilesInIsolatedMode: with mock.patch("api.profiles._is_isolated_profile_mode", return_value=True): with mock.patch("api.profiles._resolve_base_hermes_home", return_value=base_home): with mock.patch("api.profiles.get_active_profile_name", return_value="user1"): - profiles = list_profiles_api() - # Check for single_profile_mode flag in response structure - # For now, profiles should be a list; the flag will be in routes.py response - assert len(profiles) == 1 + # Mock _get_profile_skills_stats to avoid importing agent.skill_utils + with mock.patch("api.profiles._get_profile_skills_stats", return_value=(0, 0)): + profiles = list_profiles_api() + # Check for single_profile_mode flag in response structure + # For now, profiles should be a list; the flag will be in routes.py response + assert len(profiles) == 1 class TestProfileMutationsInIsolatedMode: From 35eced6f3570beec2b3813f7024a4b82c118c9b1 Mon Sep 17 00:00:00 2001 From: Rod Boev Date: Thu, 18 Jun 2026 20:36:36 -0400 Subject: [PATCH 05/21] fix(profiles): broaden except clause and sanitize error in isolated mode (#2698) --- api/profiles.py | 5 +++-- api/routes.py | 2 +- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/api/profiles.py b/api/profiles.py index 31def3d3..7d769c72 100644 --- a/api/profiles.py +++ b/api/profiles.py @@ -1015,7 +1015,8 @@ def switch_profile(name: str, *, process_wide: bool = True) -> dict: WebUI where the profile is managed via cookie + thread-local (#798). Returns: {'profiles': [...], 'active': name} - Raises PermissionError if profile doesn't exist or agent is busy. + Raises ValueError when profile doesn't exist, RuntimeError when agent is running, + PermissionError in isolated mode for cross-profile switches. """ global _active_profile @@ -1359,7 +1360,7 @@ def list_profiles_api() -> list: 'enabled_skills': enabled_count, 'total_skills': total_count, }] - except ImportError: + except (ImportError, OSError, PermissionError): pass # Fallback: construct profile dict with actual active name and hermes_home path enabled_count, total_count = _get_profile_skills_stats(hermes_home) diff --git a/api/routes.py b/api/routes.py index b8249cbc..bfa74dc6 100644 --- a/api/routes.py +++ b/api/routes.py @@ -10115,7 +10115,7 @@ def handle_post(handler, parsed) -> bool: ) return j(handler, {"ok": True, "profile": result}) except PermissionError as e: - return bad(handler, str(e), 403) + return bad(handler, _sanitize_error(e), 403) except (ValueError, FileExistsError, RuntimeError) as e: return bad(handler, str(e)) From a40ed2dbb866ec55029c20f23cee3de5c8f89efe Mon Sep 17 00:00:00 2001 From: Rod Boev Date: Thu, 18 Jun 2026 20:44:10 -0400 Subject: [PATCH 06/21] fix(test): clear profile cache between tests to prevent cross-test leakage (#2698) --- tests/test_issue2698_isolated_hermes_home.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/tests/test_issue2698_isolated_hermes_home.py b/tests/test_issue2698_isolated_hermes_home.py index b1448a06..2d62c197 100644 --- a/tests/test_issue2698_isolated_hermes_home.py +++ b/tests/test_issue2698_isolated_hermes_home.py @@ -13,6 +13,7 @@ from unittest import mock import pytest +import api.profiles as _profiles_mod from api.profiles import ( _is_isolated_profile_mode, list_profiles_api, @@ -21,6 +22,14 @@ from api.profiles import ( ) +@pytest.fixture(autouse=True) +def _clear_profile_cache(): + """Clear the profile list cache before and after each test to prevent cross-test leakage.""" + _profiles_mod._LIST_PROFILES_CACHE = None + yield + _profiles_mod._LIST_PROFILES_CACHE = None + + @pytest.fixture def temp_hermes_home(): """Create a temporary .hermes directory structure for testing.""" From a2ca2ff0c2dcc7974da7fa7672c3f6e6d652843d Mon Sep 17 00:00:00 2001 From: Rod Boev Date: Thu, 18 Jun 2026 20:51:20 -0400 Subject: [PATCH 07/21] fix(profiles): snapshot HERMES_HOME at import time for isolation detection (#2698) --- api/profiles.py | 14 +++- tests/test_issue2698_isolated_hermes_home.py | 87 +++++++++----------- 2 files changed, 52 insertions(+), 49 deletions(-) diff --git a/api/profiles.py b/api/profiles.py index 7d769c72..1fc27b31 100644 --- a/api/profiles.py +++ b/api/profiles.py @@ -33,6 +33,14 @@ _PROFILE_DIRS = [ ] _CLONE_CONFIG_FILES = ['config.yaml', '.env', 'SOUL.md'] +# ── Snapshot initial HERMES_HOME before init_profile_state() mutates it ────── +# _is_isolated_profile_mode() needs to detect whether HERMES_HOME *at startup* +# points to a profile subdirectory (e.g., ~/.hermes/profiles/user1), not the +# base home (~/.hermes). But init_profile_state() overwrites HERMES_HOME to the +# 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() + # ── Module state ──────────────────────────────────────────────────────────── _active_profile = 'default' _profile_lock = threading.Lock() @@ -126,12 +134,16 @@ def _is_isolated_profile_mode() -> bool: HERMES_BASE_HOME env var, if set, overrides and forces non-isolated mode (allows deployments to explicitly opt out of isolation detection). + + Uses _INITIAL_HERMES_HOME (snapshotted at import time) to detect isolation, + not the current os.environ value. init_profile_state() overwrites HERMES_HOME + at startup, which would disable isolation detection if we read it here. """ # Explicit HERMES_BASE_HOME opt-out takes precedence if os.getenv('HERMES_BASE_HOME', '').strip(): return False - hermes_home = os.getenv('HERMES_HOME', '').strip() + hermes_home = _INITIAL_HERMES_HOME if not hermes_home: return False diff --git a/tests/test_issue2698_isolated_hermes_home.py b/tests/test_issue2698_isolated_hermes_home.py index 2d62c197..24d7817c 100644 --- a/tests/test_issue2698_isolated_hermes_home.py +++ b/tests/test_issue2698_isolated_hermes_home.py @@ -64,10 +64,9 @@ class TestIsolatedProfileModeDetection: """Normal mode when HERMES_HOME points to base ~/.hermes.""" with mock.patch.dict(os.environ, {"HERMES_HOME": str(temp_hermes_home)}): with mock.patch("api.profiles._DEFAULT_HERMES_HOME", temp_hermes_home): - # Re-import to capture the patched env var during _resolve_base_hermes_home - # For now, test the function directly with the base home - isolated = _is_isolated_profile_mode() - assert isolated is False + with mock.patch("api.profiles._INITIAL_HERMES_HOME", str(temp_hermes_home)): + isolated = _is_isolated_profile_mode() + assert isolated is False def test_isolated_mode_when_hermes_home_is_profile_subdir(self, temp_single_profile): """Isolated mode when HERMES_HOME points to ~/.hermes/profiles/user1.""" @@ -75,26 +74,13 @@ class TestIsolatedProfileModeDetection: assert temp_single_profile.exists(), f"Test fixture path doesn't exist: {temp_single_profile}" assert temp_single_profile.parent.name == "profiles", f"Parent not named 'profiles': {temp_single_profile.parent}" - # Save and restore env to ensure clean test - saved_hermes_home = os.environ.get("HERMES_HOME") - saved_hermes_base_home = os.environ.get("HERMES_BASE_HOME") - try: - os.environ["HERMES_HOME"] = str(temp_single_profile) - # Clear HERMES_BASE_HOME to allow isolation detection - os.environ.pop("HERMES_BASE_HOME", None) - - # _is_isolated_profile_mode() reads HERMES_HOME from env and checks if - # its parent is named 'profiles'. - isolated = _is_isolated_profile_mode() - assert isolated is True, f"Expected isolated mode for {temp_single_profile}" - finally: - # Restore original env - if saved_hermes_home is not None: - os.environ["HERMES_HOME"] = saved_hermes_home - else: - os.environ.pop("HERMES_HOME", None) - if saved_hermes_base_home is not None: - os.environ["HERMES_BASE_HOME"] = saved_hermes_base_home + # _is_isolated_profile_mode() uses _INITIAL_HERMES_HOME (snapshotted at import time), + # not the current os.environ value. Patch both for this test. + with mock.patch.dict(os.environ, {"HERMES_HOME": str(temp_single_profile)}, clear=False): + with mock.patch.dict(os.environ, {"HERMES_BASE_HOME": ""}, clear=False): + with mock.patch("api.profiles._INITIAL_HERMES_HOME", str(temp_single_profile)): + isolated = _is_isolated_profile_mode() + assert isolated is True, f"Expected isolated mode for {temp_single_profile}" def test_hermes_base_home_override_forces_normal_mode(self, temp_single_profile): """HERMES_BASE_HOME env var override forces normal mode even with profile subdir.""" @@ -107,8 +93,9 @@ class TestIsolatedProfileModeDetection: }, ): with mock.patch("api.profiles._DEFAULT_HERMES_HOME", base_home): - isolated = _is_isolated_profile_mode() - assert isolated is False + with mock.patch("api.profiles._INITIAL_HERMES_HOME", str(temp_single_profile)): + isolated = _is_isolated_profile_mode() + assert isolated is False class TestListProfilesInIsolatedMode: @@ -175,28 +162,30 @@ class TestListProfilesInIsolatedMode: } with mock.patch.dict(os.environ, env_dict, clear=False): with mock.patch("api.profiles._DEFAULT_HERMES_HOME", base_home): - # Mock _get_profile_skills_stats to avoid importing agent.skill_utils - with mock.patch("api.profiles._get_profile_skills_stats", return_value=(0, 0)): - with mock.patch("api.profiles.get_active_profile_name", return_value="user1"): - profiles = list_profiles_api() - # Should only have user1 - assert len(profiles) == 1 - assert profiles[0]["name"] == "user1" + with mock.patch("api.profiles._INITIAL_HERMES_HOME", str(temp_single_profile)): + # Mock _get_profile_skills_stats to avoid importing agent.skill_utils + with mock.patch("api.profiles._get_profile_skills_stats", return_value=(0, 0)): + with mock.patch("api.profiles.get_active_profile_name", return_value="user1"): + profiles = list_profiles_api() + # Should only have user1 + assert len(profiles) == 1 + assert profiles[0]["name"] == "user1" def test_list_includes_single_profile_mode_flag(self, temp_single_profile): """Response includes single_profile_mode: true in isolated mode.""" base_home = temp_single_profile.parent.parent with mock.patch.dict(os.environ, {"HERMES_HOME": str(temp_single_profile)}): with mock.patch("api.profiles._DEFAULT_HERMES_HOME", base_home): - with mock.patch("api.profiles._is_isolated_profile_mode", return_value=True): - with mock.patch("api.profiles._resolve_base_hermes_home", return_value=base_home): - with mock.patch("api.profiles.get_active_profile_name", return_value="user1"): - # Mock _get_profile_skills_stats to avoid importing agent.skill_utils - with mock.patch("api.profiles._get_profile_skills_stats", return_value=(0, 0)): - profiles = list_profiles_api() - # Check for single_profile_mode flag in response structure - # For now, profiles should be a list; the flag will be in routes.py response - assert len(profiles) == 1 + with mock.patch("api.profiles._INITIAL_HERMES_HOME", str(temp_single_profile)): + with mock.patch("api.profiles._is_isolated_profile_mode", return_value=True): + with mock.patch("api.profiles._resolve_base_hermes_home", return_value=base_home): + with mock.patch("api.profiles.get_active_profile_name", return_value="user1"): + # Mock _get_profile_skills_stats to avoid importing agent.skill_utils + with mock.patch("api.profiles._get_profile_skills_stats", return_value=(0, 0)): + profiles = list_profiles_api() + # Check for single_profile_mode flag in response structure + # For now, profiles should be a list; the flag will be in routes.py response + assert len(profiles) == 1 class TestProfileMutationsInIsolatedMode: @@ -212,9 +201,10 @@ class TestProfileMutationsInIsolatedMode: } 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.get_active_profile_name", return_value="user1"): - with pytest.raises(PermissionError, match=".*isolated.*|.*single.*"): - create_profile_api("newprofile") + with mock.patch("api.profiles._INITIAL_HERMES_HOME", str(temp_single_profile)): + with mock.patch("api.profiles.get_active_profile_name", return_value="user1"): + with pytest.raises(PermissionError, match=".*isolated.*|.*single.*"): + create_profile_api("newprofile") def test_delete_profile_rejected_in_isolated_mode(self, temp_single_profile): """delete_profile_api should reject deletion in isolated mode.""" @@ -226,9 +216,10 @@ class TestProfileMutationsInIsolatedMode: } 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.get_active_profile_name", return_value="user1"): - with pytest.raises(PermissionError, match=".*isolated.*|.*single.*"): - delete_profile_api("user1") + with mock.patch("api.profiles._INITIAL_HERMES_HOME", str(temp_single_profile)): + with mock.patch("api.profiles.get_active_profile_name", return_value="user1"): + with pytest.raises(PermissionError, match=".*isolated.*|.*single.*"): + delete_profile_api("user1") class TestNormalModePreservation: From 0291af5f8538153ff1b01dadf1273200ac357b91 Mon Sep 17 00:00:00 2001 From: Rod Boev Date: Thu, 18 Jun 2026 21:07:47 -0400 Subject: [PATCH 08/21] Derive isolated profile name from HERMES_HOME path, not process-level default --- api/profiles.py | 9 ++++-- tests/test_issue2698_isolated_hermes_home.py | 32 +++++++++----------- 2 files changed, 21 insertions(+), 20 deletions(-) diff --git a/api/profiles.py b/api/profiles.py index 1fc27b31..a54a71d4 100644 --- a/api/profiles.py +++ b/api/profiles.py @@ -155,6 +155,11 @@ def _is_isolated_profile_mode() -> bool: return False +def _isolated_profile_name() -> str: + """Return the profile directory name from _INITIAL_HERMES_HOME.""" + return Path(_INITIAL_HERMES_HOME).expanduser().name + + def _resolve_base_hermes_home() -> Path: """Return the BASE ~/.hermes directory — the root that contains profiles/. @@ -1034,7 +1039,7 @@ def switch_profile(name: str, *, process_wide: bool = True) -> dict: # In isolated profile mode, reject switching to other profiles if _is_isolated_profile_mode(): - active = get_active_profile_name() + active = _isolated_profile_name() if name != active: raise PermissionError( f"Profile switching is not allowed in isolated profile mode. " @@ -1349,7 +1354,7 @@ def list_profiles_api() -> list: # In isolated profile mode, return only the active (isolated) profile if _is_isolated_profile_mode(): - active = get_active_profile_name() + active = _isolated_profile_name() hermes_home = get_active_hermes_home() try: from hermes_cli.profiles import list_profiles diff --git a/tests/test_issue2698_isolated_hermes_home.py b/tests/test_issue2698_isolated_hermes_home.py index 24d7817c..53db9802 100644 --- a/tests/test_issue2698_isolated_hermes_home.py +++ b/tests/test_issue2698_isolated_hermes_home.py @@ -165,11 +165,10 @@ class TestListProfilesInIsolatedMode: with mock.patch("api.profiles._INITIAL_HERMES_HOME", str(temp_single_profile)): # Mock _get_profile_skills_stats to avoid importing agent.skill_utils with mock.patch("api.profiles._get_profile_skills_stats", return_value=(0, 0)): - with mock.patch("api.profiles.get_active_profile_name", return_value="user1"): - profiles = list_profiles_api() - # Should only have user1 - assert len(profiles) == 1 - assert profiles[0]["name"] == "user1" + profiles = list_profiles_api() + # Should only have user1 + assert len(profiles) == 1 + assert profiles[0]["name"] == "user1" def test_list_includes_single_profile_mode_flag(self, temp_single_profile): """Response includes single_profile_mode: true in isolated mode.""" @@ -179,13 +178,12 @@ class TestListProfilesInIsolatedMode: with mock.patch("api.profiles._INITIAL_HERMES_HOME", str(temp_single_profile)): with mock.patch("api.profiles._is_isolated_profile_mode", return_value=True): with mock.patch("api.profiles._resolve_base_hermes_home", return_value=base_home): - with mock.patch("api.profiles.get_active_profile_name", return_value="user1"): - # Mock _get_profile_skills_stats to avoid importing agent.skill_utils - with mock.patch("api.profiles._get_profile_skills_stats", return_value=(0, 0)): - profiles = list_profiles_api() - # Check for single_profile_mode flag in response structure - # For now, profiles should be a list; the flag will be in routes.py response - assert len(profiles) == 1 + # Mock _get_profile_skills_stats to avoid importing agent.skill_utils + with mock.patch("api.profiles._get_profile_skills_stats", return_value=(0, 0)): + profiles = list_profiles_api() + # Check for single_profile_mode flag in response structure + # For now, profiles should be a list; the flag will be in routes.py response + assert len(profiles) == 1 class TestProfileMutationsInIsolatedMode: @@ -202,9 +200,8 @@ class TestProfileMutationsInIsolatedMode: 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.get_active_profile_name", return_value="user1"): - with pytest.raises(PermissionError, match=".*isolated.*|.*single.*"): - create_profile_api("newprofile") + with pytest.raises(PermissionError, match=".*isolated.*|.*single.*"): + create_profile_api("newprofile") def test_delete_profile_rejected_in_isolated_mode(self, temp_single_profile): """delete_profile_api should reject deletion in isolated mode.""" @@ -217,9 +214,8 @@ class TestProfileMutationsInIsolatedMode: 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.get_active_profile_name", return_value="user1"): - with pytest.raises(PermissionError, match=".*isolated.*|.*single.*"): - delete_profile_api("user1") + with pytest.raises(PermissionError, match=".*isolated.*|.*single.*"): + delete_profile_api("user1") class TestNormalModePreservation: From d267646b9bfaeb8bd4a25086ae43df96bb1c14a1 Mon Sep 17 00:00:00 2001 From: Rod Boev Date: Thu, 18 Jun 2026 21:10:31 -0400 Subject: [PATCH 09/21] Add switch_profile isolation guard tests --- tests/test_issue2698_isolated_hermes_home.py | 34 ++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/tests/test_issue2698_isolated_hermes_home.py b/tests/test_issue2698_isolated_hermes_home.py index 53db9802..d685342c 100644 --- a/tests/test_issue2698_isolated_hermes_home.py +++ b/tests/test_issue2698_isolated_hermes_home.py @@ -19,6 +19,7 @@ from api.profiles import ( list_profiles_api, create_profile_api, delete_profile_api, + switch_profile, ) @@ -217,6 +218,39 @@ class TestProfileMutationsInIsolatedMode: with pytest.raises(PermissionError, match=".*isolated.*|.*single.*"): delete_profile_api("user1") + def test_switch_to_different_profile_rejected(self, temp_single_profile): + """switch_profile should reject switching to another profile in isolated mode.""" + 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 pytest.raises(PermissionError, match=".*isolated.*|.*pinned.*"): + switch_profile("other_user") + + def test_switch_to_same_profile_idempotent(self, temp_single_profile): + """switch_profile to the isolated profile itself should pass through.""" + 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)): + # Should not raise PermissionError; may fail downstream + # for other reasons (missing hermes_cli), but the isolation + # guard must pass for the same-name case. + try: + switch_profile("user1") + except PermissionError: + pytest.fail("switch_profile should allow switching to the isolated profile itself") + except (ImportError, ValueError, RuntimeError): + pass # expected in test env without hermes_cli + class TestNormalModePreservation: """Test that normal mode behavior is completely unchanged.""" From 8da03dbbad0755726590cb61917418b323dce3d0 Mon Sep 17 00:00:00 2001 From: Rod Boev Date: Thu, 18 Jun 2026 21:21:23 -0400 Subject: [PATCH 10/21] Use HERMES_HOME path directly for isolated profile home resolution --- api/profiles.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/profiles.py b/api/profiles.py index a54a71d4..27057792 100644 --- a/api/profiles.py +++ b/api/profiles.py @@ -1355,7 +1355,7 @@ def list_profiles_api() -> list: # In isolated profile mode, return only the active (isolated) profile if _is_isolated_profile_mode(): active = _isolated_profile_name() - hermes_home = get_active_hermes_home() + hermes_home = Path(_INITIAL_HERMES_HOME).expanduser() try: from hermes_cli.profiles import list_profiles infos = list_profiles() From 498a5e73e39059ec333fb3f903567b367c2b7575 Mon Sep 17 00:00:00 2001 From: Rod Boev Date: Thu, 18 Jun 2026 23:05:49 -0400 Subject: [PATCH 11/21] fix(#2698): pin isolated runtime home to configured profile --- api/profiles.py | 25 +++++-- tests/test_issue2698_isolated_hermes_home.py | 70 ++++++++++++++++++++ 2 files changed, 91 insertions(+), 4 deletions(-) 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.""" From c00bfab9f51bde4015eac859f930d157a7e2353f Mon Sep 17 00:00:00 2001 From: Rod Boev Date: Thu, 18 Jun 2026 23:15:39 -0400 Subject: [PATCH 12/21] fix(#2698): keep isolated default-named profiles pinned --- api/profiles.py | 2 ++ tests/test_issue2698_isolated_hermes_home.py | 16 ++++++++++++++++ 2 files changed, 18 insertions(+) diff --git a/api/profiles.py b/api/profiles.py index c1d4aaa9..470d9d81 100644 --- a/api/profiles.py +++ b/api/profiles.py @@ -388,6 +388,8 @@ def get_active_hermes_home() -> Path: Uses get_active_profile_name() so per-request TLS context (issue #798) is respected, not just the process-level global. """ + if _is_isolated_profile_mode(): + return Path(_INITIAL_HERMES_HOME).expanduser() return _resolve_profile_home_for_name(get_active_profile_name()) diff --git a/tests/test_issue2698_isolated_hermes_home.py b/tests/test_issue2698_isolated_hermes_home.py index 232094f1..16d972ae 100644 --- a/tests/test_issue2698_isolated_hermes_home.py +++ b/tests/test_issue2698_isolated_hermes_home.py @@ -256,6 +256,22 @@ class TestIsolatedRuntimePinning: assert Path(os.environ["HERMES_HOME"]) == temp_single_profile assert reloaded == [temp_single_profile] + def test_get_active_hermes_home_keeps_profiles_default_pinned(self, temp_hermes_home, monkeypatch): + """An isolated profiles/default path must not collapse back to the base home.""" + isolated_default = temp_hermes_home / "profiles" / "default" + isolated_default.mkdir() + for subdir in ["memories", "sessions", "skills", "skins", "logs", "plans", "workspace", "cron"]: + (isolated_default / subdir).mkdir(exist_ok=True) + + monkeypatch.setenv("HERMES_HOME", str(isolated_default)) + monkeypatch.setenv("HERMES_BASE_HOME", "") + monkeypatch.setattr(_profiles_mod, "_DEFAULT_HERMES_HOME", temp_hermes_home) + monkeypatch.setattr(_profiles_mod, "_INITIAL_HERMES_HOME", str(isolated_default)) + monkeypatch.setattr(_profiles_mod, "_active_profile", "other_profile") + + assert get_active_profile_name() == "default" + assert get_active_hermes_home() == isolated_default + class TestProfileMutationsInIsolatedMode: """Test that create/delete/switch are rejected (403) in isolated mode.""" From f086158c10df2f526c015799afc3be522f68e355 Mon Sep 17 00:00:00 2001 From: Rod Boev Date: Fri, 19 Jun 2026 19:26:05 -0400 Subject: [PATCH 13/21] fix(#2698): keep isolated deployments on their own state and profile roots --- README.md | 1 + api/config.py | 3 +- api/profiles.py | 29 ++++++---- api/routes.py | 19 +++++-- bootstrap.py | 6 ++- tests/test_bootstrap_foreground.py | 45 ++++++++++++++++ ...est_issue1611_session_profile_filtering.py | 16 ++++++ tests/test_issue2698_isolated_hermes_home.py | 54 +++++++++++++++++-- tests/test_issue4449_state_dir_contract.py | 34 ++++++++++++ 9 files changed, 187 insertions(+), 20 deletions(-) create mode 100644 tests/test_issue4449_state_dir_contract.py diff --git a/README.md b/README.md index 8cfcf82b..72982f29 100644 --- a/README.md +++ b/README.md @@ -348,6 +348,7 @@ Full list of environment variables: | `HERMES_WEBUI_EXTENSION_STYLESHEET_URLS` | *(unset)* | Optional comma-separated same-origin stylesheet URLs to inject; see [WebUI Extensions](docs/EXTENSIONS.md) | | `HERMES_HOME` | Windows: `%LOCALAPPDATA%\hermes`; POSIX: `~/.hermes` | Base directory for Hermes state (affects all paths) | | `HERMES_CONFIG_PATH` | `$HERMES_HOME/config.yaml` | Path to Hermes config file | +| `HERMES_WEBUI_SERVER_CWD` | *(unset)* | Working directory for the server process. Defaults to the agent dir; point it at a writable workspace when the agent dir is read-only so fallback relative writes land somewhere writable | | `HERMES_WEBUI_AGENT_CACHE_MAX` | `25` | Max live agent instances kept warm in the in-memory LRU. Each pins a full conversation transcript, so this is the dominant lever on resident memory — lower it on installs with many long sessions to cap RAM (at the cost of more cold reloads) | | `HERMES_WEBUI_SESSIONS_MAX` | `100` | Max compact `Session` objects held in the in-memory LRU. Lighter than the agent cache; lower it on installs with hundreds of sessions | diff --git a/api/config.py b/api/config.py index ba469670..46860e5e 100644 --- a/api/config.py +++ b/api/config.py @@ -70,9 +70,10 @@ TLS_ENABLED = TLS_CERT is not None and TLS_KEY is not None # ── State directory (env-overridable, never inside repo) ────────────────────── _DEFAULT_HERMES_HOME = _platform_default_hermes_home() +_DEFAULT_STATE_HOME = Path(os.getenv("HERMES_HOME") or _DEFAULT_HERMES_HOME).expanduser() STATE_DIR = ( - Path(os.getenv("HERMES_WEBUI_STATE_DIR", str(_DEFAULT_HERMES_HOME / "webui"))) + Path(os.getenv("HERMES_WEBUI_STATE_DIR", str(_DEFAULT_STATE_HOME / "webui"))) .expanduser() .resolve() ) diff --git a/api/profiles.py b/api/profiles.py index 470d9d81..e2e90e46 100644 --- a/api/profiles.py +++ b/api/profiles.py @@ -133,17 +133,18 @@ def _is_isolated_profile_mode() -> bool: This indicates a single-profile deployment where the WebUI should pin to that profile and reject cross-profile operations. - HERMES_BASE_HOME env var, if set, overrides and forces non-isolated mode - (allows deployments to explicitly opt out of isolation detection). + Isolation is derived only from the literal ``*/profiles/`` shape of + HERMES_HOME at startup. A normal multi-profile deployment points + HERMES_HOME at the base home (``~/.hermes``), which does not match the + shape, so isolation stays off. We deliberately do not key this off + HERMES_BASE_HOME: that variable normally points at the base home already, + so using it as an opt-out silently disables legitimate isolated-profile + deployments. Uses _INITIAL_HERMES_HOME (snapshotted at import time) to detect isolation, not the current os.environ value. init_profile_state() overwrites HERMES_HOME at startup, which would disable isolation detection if we read it here. """ - # Explicit HERMES_BASE_HOME opt-out takes precedence - if os.getenv('HERMES_BASE_HOME', '').strip(): - return False - hermes_home = _INITIAL_HERMES_HOME if not hermes_home: return False @@ -375,6 +376,10 @@ def _resolve_profile_home_for_name(name: str) -> Path: names fall back to the base home so traversal-shaped cookie values cannot influence filesystem paths. """ + # In isolated mode, the pinned profile name, including a literal + # "default", must resolve to the configured startup HERMES_HOME. + if _is_isolated_profile_mode() and name == _isolated_profile_name(): + return Path(_INITIAL_HERMES_HOME).expanduser() if not name or _is_root_profile(name): return _DEFAULT_HERMES_HOME if not _PROFILE_ID_RE.fullmatch(name): @@ -1378,9 +1383,15 @@ def list_profiles_api() -> list: try: from hermes_cli.profiles import list_profiles infos = list_profiles() - # Find the current profile and return only that one + # When the isolated profile is literally named "default", upstream + # can surface the base-home row first. Only trust a row whose path + # resolves to the same directory as the isolated startup home. for p in infos: - if p.name == active: + try: + same_home = Path(p.path).expanduser().resolve() == hermes_home.resolve() + except OSError: + same_home = False + if p.name == active and same_home: enabled_count, total_count = _get_profile_skills_stats(p.path) return [{ 'name': p.name, @@ -1403,7 +1414,7 @@ def list_profiles_api() -> list: return [{ 'name': active, 'path': str(hermes_home), - 'is_default': False, + 'is_default': _is_root_profile(active), 'is_active': True, 'gateway_running': False, 'model': None, diff --git a/api/routes.py b/api/routes.py index bfa74dc6..ba3ab815 100644 --- a/api/routes.py +++ b/api/routes.py @@ -429,6 +429,7 @@ def _visible_pinned_lineage_ids(session_rows) -> set[str]: # module keep resolving without per-call-site refactors. from api.profiles import ( # noqa: F401, E402 (re-export) _profiles_match, + _is_isolated_profile_mode, get_active_profile_name, get_active_profile_name as _get_active_profile_name, ) @@ -445,6 +446,11 @@ def _all_profiles_query_flag(parsed_url) -> bool: return raw in ('1', 'true', 'yes', 'on') +def _all_profiles_enabled(parsed_url) -> bool: + """Enable aggregate profile reads only when the request asks and mode allows it.""" + return _all_profiles_query_flag(parsed_url) and not _is_isolated_profile_mode() + + def _session_visible_to_active_profile(session_profile, handler=None) -> bool: """Return whether a detail-load session belongs to the active profile. @@ -1927,7 +1933,7 @@ def _build_session_list_cache_payload( other_profile_count = 0 else: scoped = [s for s in merged if _profiles_match(s.get("profile"), active_profile)] - other_profile_count = len(merged) - len(scoped) + other_profile_count = 0 if _is_isolated_profile_mode() else len(merged) - len(scoped) diag_stage("messaging_dedupe") scoped = _keep_latest_messaging_session_per_source( scoped, @@ -8175,7 +8181,7 @@ def handle_get(handler, parsed) -> bool: show_cron_sessions = bool(settings.get("show_cron_sessions")) agent_session_source_filter = settings.get("agent_session_source_filter") active_profile = get_active_profile_name() - all_profiles = _all_profiles_query_flag(parsed) + all_profiles = _all_profiles_enabled(parsed) key = _session_list_cache_key( active_profile=active_profile, all_profiles=all_profiles, @@ -8213,17 +8219,20 @@ def handle_get(handler, parsed) -> bool: from api.profiles import get_active_profile_name active_profile = get_active_profile_name() all_projects = load_projects() - all_profiles = _all_profiles_query_flag(parsed) + isolated_profile_mode = _is_isolated_profile_mode() + all_profiles = _all_profiles_enabled(parsed) if all_profiles: scoped = all_projects + other_profile_count = 0 else: scoped = [p for p in all_projects if _profiles_match(p.get("profile"), active_profile)] + other_profile_count = 0 if isolated_profile_mode else len(all_projects) - len(scoped) return j(handler, { "projects": scoped, "all_profiles": all_profiles, "active_profile": active_profile, - "other_profile_count": len(all_projects) - len(scoped), + "other_profile_count": other_profile_count, }) if parsed.path == "/api/prompts": @@ -11191,7 +11200,7 @@ def _handle_sessions_search(handler, parsed): content_search = qs.get("content", ["1"])[0] == "1" from api.profiles import get_active_profile_name active_profile = get_active_profile_name() - all_profiles = _all_profiles_query_flag(parsed) + all_profiles = _all_profiles_enabled(parsed) sessions = all_sessions() if not all_profiles: sessions = [ diff --git a/bootstrap.py b/bootstrap.py index 9bccf09e..e7e92273 100755 --- a/bootstrap.py +++ b/bootstrap.py @@ -494,7 +494,8 @@ def main() -> int: python_exe = ensure_python_has_webui_deps(discover_launcher_python(agent_dir), agent_dir) state_dir = Path( - os.getenv("HERMES_WEBUI_STATE_DIR", str(Path.home() / ".hermes" / "webui")) + os.getenv("HERMES_WEBUI_STATE_DIR") + or Path(os.getenv("HERMES_HOME") or (Path.home() / ".hermes")) / "webui" ).expanduser() state_dir.mkdir(parents=True, exist_ok=True) @@ -505,7 +506,8 @@ def main() -> int: if agent_dir: os.environ["HERMES_WEBUI_AGENT_DIR"] = str(agent_dir) - server_cwd = str(agent_dir or REPO_ROOT) + # Let operators move fallback relative writes out of a read-only agent dir. + server_cwd = os.environ.get("HERMES_WEBUI_SERVER_CWD", "").strip() or str(agent_dir or REPO_ROOT) server_path = str(REPO_ROOT / "server.py") # Scheme the server will advertise (HTTPS when TLS cert+key are configured). scheme = "https" if _tls_probe_enabled() else "http" diff --git a/tests/test_bootstrap_foreground.py b/tests/test_bootstrap_foreground.py index cb2169e7..f0bd98a1 100644 --- a/tests/test_bootstrap_foreground.py +++ b/tests/test_bootstrap_foreground.py @@ -92,6 +92,9 @@ def clean_env(monkeypatch): "HERMES_WEBUI_HOST", "HERMES_WEBUI_PORT", "HERMES_WEBUI_AGENT_DIR", + "HERMES_WEBUI_STATE_DIR", + "HERMES_WEBUI_SERVER_CWD", + "HERMES_HOME", ): monkeypatch.delenv(name, raising=False) @@ -346,6 +349,27 @@ class TestMainForegroundRouting: bs.main() assert len(execv_calls) == 1 + def test_foreground_defaults_state_dir_to_hermes_home_webui(self, stub_main_dependencies, clean_env, monkeypatch, tmp_path): + bs = stub_main_dependencies + hermes_home = tmp_path / ".hermes" / "profiles" / "webui" + hermes_home.mkdir(parents=True, exist_ok=True) + monkeypatch.setenv("HERMES_HOME", str(hermes_home)) + monkeypatch.delenv("HERMES_WEBUI_STATE_DIR", raising=False) + monkeypatch.setattr(sys, "argv", ["bootstrap.py", "--foreground"]) + monkeypatch.setattr(sys, "platform", "linux") + monkeypatch.setattr(os, "chdir", lambda p: None) + monkeypatch.setattr(os, "access", lambda path, mode: True) + + def fake_execv(*_args): + raise SystemExit(0) + + monkeypatch.setattr(os, "execv", fake_execv) + + with pytest.raises(SystemExit): + bs.main() + + assert os.environ["HERMES_WEBUI_STATE_DIR"] == str(hermes_home / "webui") + class TestForegroundEnvAndCwd: """The post-execv server.py inherits os.environ and cwd from us.""" @@ -384,6 +408,27 @@ class TestForegroundEnvAndCwd: assert len(chdir_calls) == 1 assert chdir_calls[0] == str(agent_dir) + def test_foreground_chdirs_to_server_cwd_override_before_launch(self, setup, monkeypatch, clean_env, tmp_path): + bs, _agent_dir = setup + workspace = tmp_path / "workspace" + workspace.mkdir() + monkeypatch.setenv("HERMES_WEBUI_SERVER_CWD", str(workspace)) + monkeypatch.setattr(sys, "argv", ["bootstrap.py", "--foreground"]) + monkeypatch.setattr(sys, "platform", "linux") + + chdir_calls = [] + monkeypatch.setattr(os, "chdir", lambda p: chdir_calls.append(p)) + + def fake_execv(*_args): + raise SystemExit(0) + + monkeypatch.setattr(os, "execv", fake_execv) + + with pytest.raises(SystemExit): + bs.main() + + assert chdir_calls == [str(workspace)] + def test_foreground_exports_resolved_env_vars(self, setup, monkeypatch, clean_env): bs, agent_dir = setup monkeypatch.setattr(sys, "argv", [ diff --git a/tests/test_issue1611_session_profile_filtering.py b/tests/test_issue1611_session_profile_filtering.py index 0ca9cd03..3d68551d 100644 --- a/tests/test_issue1611_session_profile_filtering.py +++ b/tests/test_issue1611_session_profile_filtering.py @@ -106,6 +106,22 @@ def test_all_profiles_query_flag_false_values(): assert _all_profiles_query_flag(u) is False, f"path {path!r} should be false" +def test_all_profiles_enabled_in_normal_mode(monkeypatch): + """The aggregate toggle still works outside isolated-profile mode.""" + import api.routes as routes + + monkeypatch.setattr(routes, "_is_isolated_profile_mode", lambda: False) + assert routes._all_profiles_enabled(urlparse('/api/sessions?all_profiles=1')) is True + + +def test_all_profiles_disabled_in_isolated_mode(monkeypatch): + """An isolated deployment must ignore all_profiles=1 aggregate requests.""" + import api.routes as routes + + monkeypatch.setattr(routes, "_is_isolated_profile_mode", lambda: True) + assert routes._all_profiles_enabled(urlparse('/api/sessions?all_profiles=1')) is False + + # ── No client-side CLI bypass ────────────────────────────────────────────── diff --git a/tests/test_issue2698_isolated_hermes_home.py b/tests/test_issue2698_isolated_hermes_home.py index 16d972ae..000168ef 100644 --- a/tests/test_issue2698_isolated_hermes_home.py +++ b/tests/test_issue2698_isolated_hermes_home.py @@ -88,8 +88,8 @@ class TestIsolatedProfileModeDetection: isolated = _is_isolated_profile_mode() assert isolated is True, f"Expected isolated mode for {temp_single_profile}" - def test_hermes_base_home_override_forces_normal_mode(self, temp_single_profile): - """HERMES_BASE_HOME env var override forces normal mode even with profile subdir.""" + def test_hermes_base_home_does_not_disable_isolation(self, temp_single_profile): + """HERMES_BASE_HOME must not disable isolation for a profiles/ path.""" base_home = temp_single_profile.parent.parent with mock.patch.dict( os.environ, @@ -101,7 +101,7 @@ class TestIsolatedProfileModeDetection: with mock.patch("api.profiles._DEFAULT_HERMES_HOME", base_home): with mock.patch("api.profiles._INITIAL_HERMES_HOME", str(temp_single_profile)): isolated = _is_isolated_profile_mode() - assert isolated is False + assert isolated is True class TestListProfilesInIsolatedMode: @@ -272,6 +272,54 @@ class TestIsolatedRuntimePinning: assert get_active_profile_name() == "default" assert get_active_hermes_home() == isolated_default + def test_explicit_profile_resolution_for_isolated_default_uses_pinned_home(self, temp_hermes_home, monkeypatch): + """Explicit default-profile resolution must stay on the isolated home.""" + from api.profiles import get_hermes_home_for_profile, _resolve_profile_home_for_name + + isolated_default = temp_hermes_home / "profiles" / "default" + isolated_default.mkdir() + for subdir in ["memories", "sessions", "skills", "skins", "logs", "plans", "workspace", "cron"]: + (isolated_default / subdir).mkdir(exist_ok=True) + + monkeypatch.setenv("HERMES_HOME", str(isolated_default)) + monkeypatch.setattr(_profiles_mod, "_DEFAULT_HERMES_HOME", temp_hermes_home) + monkeypatch.setattr(_profiles_mod, "_INITIAL_HERMES_HOME", str(isolated_default)) + + assert _resolve_profile_home_for_name("default") == isolated_default + assert get_hermes_home_for_profile("default") == isolated_default + assert get_hermes_home_for_profile("default") != temp_hermes_home + + def test_list_profiles_prefers_matching_isolated_default_home(self, temp_hermes_home, monkeypatch): + """The isolated profiles/default row must not collapse to the base-home row.""" + isolated_default = temp_hermes_home / "profiles" / "default" + isolated_default.mkdir(parents=True) + for subdir in ["memories", "sessions", "skills", "skins", "logs", "plans", "workspace", "cron"]: + (isolated_default / subdir).mkdir(exist_ok=True) + + class _Info: + def __init__(self, name, path, is_default): + self.name = name + self.path = path + self.is_default = is_default + self.gateway_running = False + self.model = None + self.provider = None + self.has_env = False + + monkeypatch.setenv("HERMES_HOME", str(isolated_default)) + monkeypatch.setattr(_profiles_mod, "_DEFAULT_HERMES_HOME", temp_hermes_home) + monkeypatch.setattr(_profiles_mod, "_INITIAL_HERMES_HOME", str(isolated_default)) + monkeypatch.setattr(_profiles_mod, "_get_profile_skills_stats", lambda _path: (0, 0)) + + with mock.patch("hermes_cli.profiles.list_profiles", return_value=[ + _Info("default", temp_hermes_home, True), + _Info("default", isolated_default, False), + ]): + rows = list_profiles_api() + + assert len(rows) == 1 + assert Path(rows[0]["path"]) == isolated_default + class TestProfileMutationsInIsolatedMode: """Test that create/delete/switch are rejected (403) in isolated mode.""" diff --git a/tests/test_issue4449_state_dir_contract.py b/tests/test_issue4449_state_dir_contract.py new file mode 100644 index 00000000..e6ccdfbe --- /dev/null +++ b/tests/test_issue4449_state_dir_contract.py @@ -0,0 +1,34 @@ +"""Regression tests for the #4449 state-dir contract folded into #4454.""" + +from __future__ import annotations + +import importlib +import os +from pathlib import Path + +import api.config as config + + +def test_config_state_dir_defaults_to_hermes_home_webui(tmp_path): + hermes_home = tmp_path / ".hermes" / "profiles" / "isolated" + hermes_home.mkdir(parents=True) + + old_home = os.environ.get("HERMES_HOME") + old_state_dir = os.environ.get("HERMES_WEBUI_STATE_DIR") + try: + os.environ["HERMES_HOME"] = str(hermes_home) + os.environ.pop("HERMES_WEBUI_STATE_DIR", None) + + reloaded = importlib.reload(config) + + assert reloaded.STATE_DIR == (hermes_home / "webui").resolve() + finally: + if old_home is None: + os.environ.pop("HERMES_HOME", None) + else: + os.environ["HERMES_HOME"] = old_home + if old_state_dir is None: + os.environ.pop("HERMES_WEBUI_STATE_DIR", None) + else: + os.environ["HERMES_WEBUI_STATE_DIR"] = old_state_dir + importlib.reload(config) From 41f2015a327c077e150459789cf1270637d0658a Mon Sep 17 00:00:00 2001 From: Rod Boev Date: Fri, 19 Jun 2026 19:38:09 -0400 Subject: [PATCH 14/21] fix(#2698): close isolated profile escape hatches --- api/profiles.py | 10 ++- api/routes.py | 2 + static/panels.js | 5 +- tests/test_issue2698_isolated_hermes_home.py | 76 ++++++++++++++++++++ tests/test_issue3623_profile_visibility.py | 11 +++ 5 files changed, 101 insertions(+), 3 deletions(-) diff --git a/api/profiles.py b/api/profiles.py index e2e90e46..c18cfe9f 100644 --- a/api/profiles.py +++ b/api/profiles.py @@ -442,6 +442,14 @@ def _home_for_scheduled_cron_job(job: dict) -> Path: fall back to the server default rather than crashing every scheduler tick. """ raw = str((job or {}).get('profile') or '').strip() + if _is_isolated_profile_mode(): + active = _isolated_profile_name() + if raw and not _profiles_match(raw, active): + logger.warning( + "Cron job %s references profile %r outside isolated profile %r; falling back to isolated home", + (job or {}).get('id', '?'), raw, active, + ) + return get_active_hermes_home() if not raw: return get_active_hermes_home() if _is_root_profile(raw): @@ -1414,7 +1422,7 @@ def list_profiles_api() -> list: return [{ 'name': active, 'path': str(hermes_home), - 'is_default': _is_root_profile(active), + 'is_default': active == 'default', 'is_active': True, 'gateway_running': False, 'model': None, diff --git a/api/routes.py b/api/routes.py index ba3ab815..6fc086f2 100644 --- a/api/routes.py +++ b/api/routes.py @@ -18088,6 +18088,8 @@ def _handle_session_import_cli(handler, body): if requested_profile == "": return bad(handler, "invalid profile", 400) allow_all_profiles = _request_wants_all_profiles_import(body) + if allow_all_profiles and _is_isolated_profile_mode(): + return bad(handler, "all_profiles import is not allowed in isolated profile mode", 403) if allow_all_profiles and not requested_profile: return bad(handler, "profile is required for all_profiles import", 400) diff --git a/static/panels.js b/static/panels.js index f834ca01..5c2a884f 100644 --- a/static/panels.js +++ b/static/panels.js @@ -5706,8 +5706,9 @@ function _setProfileHeaderButtons(mode, p, activeName){ if (mode === 'read') { const isActive = p && p.name === activeName; const isDefault = !!(p && p.is_default); - if (isActive) hide(actBtn); else show(actBtn); - if (isDefault) hide(delBtn); else show(delBtn); + const singleProfileMode = !!(_profilesCache && _profilesCache.single_profile_mode); + if (isActive || singleProfileMode) hide(actBtn); else show(actBtn); + if (isDefault || singleProfileMode) hide(delBtn); else show(delBtn); hide(cancelBtn); hide(saveBtn); } else if (mode === 'create') { hide(actBtn); hide(delBtn); show(cancelBtn); show(saveBtn); diff --git a/tests/test_issue2698_isolated_hermes_home.py b/tests/test_issue2698_isolated_hermes_home.py index 000168ef..8b8e3e47 100644 --- a/tests/test_issue2698_isolated_hermes_home.py +++ b/tests/test_issue2698_isolated_hermes_home.py @@ -7,6 +7,8 @@ of other profiles, and hide multi-profile UI affordances. """ import os +import io +import sys import tempfile from pathlib import Path from unittest import mock @@ -320,6 +322,27 @@ class TestIsolatedRuntimePinning: assert len(rows) == 1 assert Path(rows[0]["path"]) == isolated_default + def test_list_profiles_isolated_fallback_does_not_reenter_root_profile_lookup(self, temp_single_profile, monkeypatch): + """The isolated fallback must not recurse back through _is_root_profile.""" + base_home = temp_single_profile.parent.parent + 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, "_get_profile_skills_stats", lambda _path: (0, 0)) + monkeypatch.setattr( + _profiles_mod, + "_is_root_profile", + lambda _name: (_ for _ in ()).throw(AssertionError("_is_root_profile should not run in isolated fallback")), + ) + + with mock.patch.dict(sys.modules, {"hermes_cli": None, "hermes_cli.profiles": None}): + rows = list_profiles_api() + + assert len(rows) == 1 + assert rows[0]["name"] == "user1" + assert rows[0]["is_default"] is False + class TestProfileMutationsInIsolatedMode: """Test that create/delete/switch are rejected (403) in isolated mode.""" @@ -385,6 +408,52 @@ class TestProfileMutationsInIsolatedMode: except (ImportError, ValueError, RuntimeError): pass # expected in test env without hermes_cli + def test_scheduled_cron_jobs_stay_pinned_to_isolated_home(self, temp_single_profile, monkeypatch): + """Scheduler jobs must not resolve foreign profile homes in isolated mode.""" + base_home = temp_single_profile.parent.parent + 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)) + + assert _profiles_mod._home_for_scheduled_cron_job({"id": "job2698", "profile": "other"}) == temp_single_profile + + def test_cli_import_all_profiles_is_rejected_in_isolated_mode(self, monkeypatch): + """Direct all_profiles CLI imports must not bypass isolated-profile boundaries.""" + import api.routes as routes + + captured = {} + + class _Handler: + def __init__(self): + self.wfile = io.BytesIO() + + def send_response(self, status): + self.status = status + + def send_header(self, key, value): + pass + + def end_headers(self): + pass + + monkeypatch.setattr(routes, "_is_isolated_profile_mode", lambda: True) + monkeypatch.setattr( + routes, + "bad", + lambda h, m, c=400: (captured.__setitem__("bad", (m, c)), True)[1], + ) + + routes._handle_session_import_cli( + _Handler(), + {"session_id": "foreign-cli-2698", "all_profiles": 1, "profile": "other"}, + ) + + assert captured["bad"] == ( + "all_profiles import is not allowed in isolated profile mode", + 403, + ) + class TestNormalModePreservation: """Test that normal mode behavior is completely unchanged.""" @@ -410,3 +479,10 @@ class TestNormalModePreservation: except ImportError: # hermes_cli not available, skip pass + + +def test_profiles_panel_hides_delete_controls_in_single_profile_mode(): + panels_js = (Path(__file__).resolve().parents[1] / "static" / "panels.js").read_text(encoding="utf-8") + + assert "const singleProfileMode = !!(_profilesCache && _profilesCache.single_profile_mode);" in panels_js + assert "if (isDefault || singleProfileMode) hide(delBtn); else show(delBtn);" in panels_js diff --git a/tests/test_issue3623_profile_visibility.py b/tests/test_issue3623_profile_visibility.py index fbe2884f..7a86bbe9 100644 --- a/tests/test_issue3623_profile_visibility.py +++ b/tests/test_issue3623_profile_visibility.py @@ -5,6 +5,8 @@ import types from pathlib import Path from types import SimpleNamespace +import pytest + REPO_ROOT = Path(__file__).resolve().parents[1] @@ -21,6 +23,15 @@ def _profile_row(name: str, path: Path, *, is_default: bool = False): ) +@pytest.fixture(autouse=True) +def _clear_profile_rows_cache(): + import api.profiles as profiles + + profiles._LIST_PROFILES_CACHE = None + yield + profiles._LIST_PROFILES_CACHE = None + + def _install_fake_hermes_profiles(monkeypatch, rows): hermes_cli = types.ModuleType("hermes_cli") profiles_mod = types.ModuleType("hermes_cli.profiles") From c7375c60c4b8ac229ad42601235cb9f42b2d35aa Mon Sep 17 00:00:00 2001 From: Rod Boev Date: Fri, 19 Jun 2026 19:45:57 -0400 Subject: [PATCH 15/21] fix(#2698): clamp isolated profile lookups to the pinned home --- api/profiles.py | 15 +++++++++++---- tests/test_issue2698_isolated_hermes_home.py | 13 +++++++++++++ 2 files changed, 24 insertions(+), 4 deletions(-) diff --git a/api/profiles.py b/api/profiles.py index c18cfe9f..b0462108 100644 --- a/api/profiles.py +++ b/api/profiles.py @@ -376,10 +376,17 @@ def _resolve_profile_home_for_name(name: str) -> Path: names fall back to the base home so traversal-shaped cookie values cannot influence filesystem paths. """ - # In isolated mode, the pinned profile name, including a literal - # "default", must resolve to the configured startup HERMES_HOME. - if _is_isolated_profile_mode() and name == _isolated_profile_name(): - return Path(_INITIAL_HERMES_HOME).expanduser() + # In isolated mode, every logical profile lookup clamps to the configured + # startup HERMES_HOME so callers cannot resolve a foreign profile path. + if _is_isolated_profile_mode(): + isolated_name = _isolated_profile_name() + isolated_home = Path(_INITIAL_HERMES_HOME).expanduser() + if name and not _profiles_match(name, isolated_name): + logger.warning( + "Ignoring profile lookup %r in isolated profile mode; using pinned profile %r", + name, isolated_name, + ) + return isolated_home if not name or _is_root_profile(name): return _DEFAULT_HERMES_HOME if not _PROFILE_ID_RE.fullmatch(name): diff --git a/tests/test_issue2698_isolated_hermes_home.py b/tests/test_issue2698_isolated_hermes_home.py index 8b8e3e47..94750b89 100644 --- a/tests/test_issue2698_isolated_hermes_home.py +++ b/tests/test_issue2698_isolated_hermes_home.py @@ -291,6 +291,19 @@ class TestIsolatedRuntimePinning: assert get_hermes_home_for_profile("default") == isolated_default assert get_hermes_home_for_profile("default") != temp_hermes_home + def test_explicit_foreign_profile_resolution_stays_on_isolated_home(self, temp_single_profile, monkeypatch): + """Explicit profile lookups must not escape the isolated startup home.""" + from api.profiles import get_hermes_home_for_profile, _resolve_profile_home_for_name + + base_home = temp_single_profile.parent.parent + 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)) + + assert _resolve_profile_home_for_name("other_profile") == temp_single_profile + assert get_hermes_home_for_profile("other_profile") == temp_single_profile + def test_list_profiles_prefers_matching_isolated_default_home(self, temp_hermes_home, monkeypatch): """The isolated profiles/default row must not collapse to the base-home row.""" isolated_default = temp_hermes_home / "profiles" / "default" From e77ca8dbac2ceed22fecf558e2314966f5c014b7 Mon Sep 17 00:00:00 2001 From: Rod Boev Date: Fri, 19 Jun 2026 20:03:16 -0400 Subject: [PATCH 16/21] fix(#2698): keep the pinned home across isolated fallbacks --- api/profiles.py | 6 +- bootstrap.py | 2 + tests/test_bootstrap_foreground.py | 22 +++++++ tests/test_issue2698_isolated_hermes_home.py | 63 ++++++++++++++++++++ 4 files changed, 92 insertions(+), 1 deletion(-) diff --git a/api/profiles.py b/api/profiles.py index b0462108..cc8481b4 100644 --- a/api/profiles.py +++ b/api/profiles.py @@ -507,6 +507,8 @@ def install_cron_scheduler_profile_isolation() -> None: return original(job, *args, **kwargs) finally: event_profile = str((job or {}).get("profile") or "").strip() or None + if _is_isolated_profile_mode(): + event_profile = _isolated_profile_name() try: publish_session_list_changed("cron_complete", profile=event_profile) except TypeError: @@ -1102,7 +1104,9 @@ def switch_profile(name: str, *, process_wide: bool = True) -> dict: ) # Resolve profile directory - if _is_root_profile(name): + if _is_isolated_profile_mode(): + home = Path(_INITIAL_HERMES_HOME).expanduser() + elif _is_root_profile(name): home = _DEFAULT_HERMES_HOME else: home = _resolve_named_profile_home(name) diff --git a/bootstrap.py b/bootstrap.py index e7e92273..f8f0ced2 100755 --- a/bootstrap.py +++ b/bootstrap.py @@ -549,6 +549,8 @@ def main() -> int: # for SO_EXCLUSIVEADDRUSE. _CREATE_NEW_PROCESS_GROUP = getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0) subprocess.Popen([python_exe, server_path], + cwd=server_cwd, + env=os.environ.copy(), creationflags=_CREATE_NEW_PROCESS_GROUP) sys.exit(0) os.execv(python_exe, [python_exe, server_path]) diff --git a/tests/test_bootstrap_foreground.py b/tests/test_bootstrap_foreground.py index f0bd98a1..4aa200ae 100644 --- a/tests/test_bootstrap_foreground.py +++ b/tests/test_bootstrap_foreground.py @@ -429,6 +429,28 @@ class TestForegroundEnvAndCwd: assert chdir_calls == [str(workspace)] + def test_windows_foreground_popen_uses_server_cwd_override(self, setup, monkeypatch, clean_env, tmp_path): + bs, _agent_dir = setup + workspace = tmp_path / "workspace-win" + workspace.mkdir() + monkeypatch.setenv("HERMES_WEBUI_SERVER_CWD", str(workspace)) + monkeypatch.setattr(sys, "argv", ["bootstrap.py", "--foreground"]) + monkeypatch.setattr(sys, "platform", "win32") + monkeypatch.setattr(os, "chdir", lambda p: None) + + popen_calls = [] + + def fake_popen(*args, **kwargs): + popen_calls.append((args, kwargs)) + return None + + monkeypatch.setattr(subprocess, "Popen", fake_popen) + + with pytest.raises(SystemExit): + bs.main() + + assert popen_calls[0][1]["cwd"] == str(workspace) + def test_foreground_exports_resolved_env_vars(self, setup, monkeypatch, clean_env): bs, agent_dir = setup monkeypatch.setattr(sys, "argv", [ diff --git a/tests/test_issue2698_isolated_hermes_home.py b/tests/test_issue2698_isolated_hermes_home.py index 94750b89..46a9ae3e 100644 --- a/tests/test_issue2698_isolated_hermes_home.py +++ b/tests/test_issue2698_isolated_hermes_home.py @@ -10,6 +10,7 @@ import os import io import sys import tempfile +import types from pathlib import Path from unittest import mock @@ -421,6 +422,28 @@ class TestProfileMutationsInIsolatedMode: except (ImportError, ValueError, RuntimeError): pass # expected in test env without hermes_cli + def test_switch_to_same_default_profile_keeps_pinned_home(self, temp_hermes_home, monkeypatch, tmp_path): + """A same-name switch for isolated profiles/default must keep using the pinned home.""" + isolated_default = temp_hermes_home / "profiles" / "default" + isolated_default.mkdir(parents=True) + (isolated_default / "workspace").mkdir() + base_workspace = tmp_path / "base-workspace" + isolated_workspace = tmp_path / "isolated-workspace" + base_workspace.mkdir() + isolated_workspace.mkdir() + (temp_hermes_home / "config.yaml").write_text(f"workspace: {base_workspace}\n", encoding="utf-8") + (isolated_default / "config.yaml").write_text(f"workspace: {isolated_workspace}\n", encoding="utf-8") + + monkeypatch.setenv("HERMES_HOME", str(isolated_default)) + monkeypatch.setenv("HERMES_BASE_HOME", "") + monkeypatch.setattr(_profiles_mod, "_DEFAULT_HERMES_HOME", temp_hermes_home) + monkeypatch.setattr(_profiles_mod, "_INITIAL_HERMES_HOME", str(isolated_default)) + monkeypatch.setattr(_profiles_mod, "list_profiles_api", lambda: []) + + result = switch_profile("default", process_wide=False) + + assert result["default_workspace"] == str(isolated_workspace.resolve()) + def test_scheduled_cron_jobs_stay_pinned_to_isolated_home(self, temp_single_profile, monkeypatch): """Scheduler jobs must not resolve foreign profile homes in isolated mode.""" base_home = temp_single_profile.parent.parent @@ -467,6 +490,46 @@ class TestProfileMutationsInIsolatedMode: 403, ) + def test_scheduler_publishes_isolated_profile_after_foreign_job_profile(self, temp_single_profile, monkeypatch): + """Scheduled cron completion must publish the isolated profile identity.""" + events = [] + base_home = temp_single_profile.parent.parent + + cron_pkg = types.ModuleType("cron") + cron_pkg.__path__ = [] + cron_scheduler = types.ModuleType("cron.scheduler") + cron_scheduler.run_job = lambda job: events.append(("run", job["id"])) or "ok" + + class _Ctx: + def __init__(self, home): + self.home = str(home) + + def __enter__(self): + events.append(("enter", self.home)) + return self + + def __exit__(self, exc_type, exc, tb): + events.append(("exit", self.home)) + return False + + monkeypatch.setitem(sys.modules, "cron", cron_pkg) + monkeypatch.setitem(sys.modules, "cron.scheduler", cron_scheduler) + 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, "cron_profile_context_for_home", _Ctx) + monkeypatch.setattr( + _profiles_mod, + "publish_session_list_changed", + lambda reason, profile=None: events.append(("publish", reason, profile)), + ) + + _profiles_mod.install_cron_scheduler_profile_isolation() + + assert cron_scheduler.run_job({"id": "job2698", "profile": "other"}) == "ok" + assert events[-1] == ("publish", "cron_complete", "user1") + class TestNormalModePreservation: """Test that normal mode behavior is completely unchanged.""" From 0952fcc606c3b0cb603961e87805f146c7babc62 Mon Sep 17 00:00:00 2001 From: nesquena-hermes Date: Sat, 20 Jun 2026 00:43:12 +0000 Subject: [PATCH 17/21] fix(routes): drop redundant local _is_isolated_profile_mode import (#4454 gate) The /api/profiles handler re-imported _is_isolated_profile_mode locally inside handle_get, which made the name function-local for the ENTIRE handle_get scope -> the earlier use at the /api/projects branch (routes.py:8000) hit UnboundLocalError (F823), failing 6 test shards + lint. It's already a module-level re-export (routes.py:429), so the local re-import is redundant; removing it lets every in-function use resolve to the module global. Also drop an unused 'from pathlib import Path' in the new #4449 state-dir test. Co-authored-by: rodboev --- api/routes.py | 6 +----- tests/test_issue4449_state_dir_contract.py | 1 - 2 files changed, 1 insertion(+), 6 deletions(-) diff --git a/api/routes.py b/api/routes.py index 6fc086f2..fb93e965 100644 --- a/api/routes.py +++ b/api/routes.py @@ -8595,11 +8595,7 @@ def handle_get(handler, parsed) -> bool: # ── Profile API (GET) ── if parsed.path == "/api/profiles": - from api.profiles import ( - list_profiles_api, - get_active_profile_name, - _is_isolated_profile_mode, - ) + from api.profiles import list_profiles_api return j( handler, diff --git a/tests/test_issue4449_state_dir_contract.py b/tests/test_issue4449_state_dir_contract.py index e6ccdfbe..81ae37c8 100644 --- a/tests/test_issue4449_state_dir_contract.py +++ b/tests/test_issue4449_state_dir_contract.py @@ -4,7 +4,6 @@ from __future__ import annotations import importlib import os -from pathlib import Path import api.config as config From 8b598ac13317f388c4a297655e9dac0a90e95578 Mon Sep 17 00:00:00 2001 From: nesquena-hermes Date: Sat, 20 Jun 2026 00:49:06 +0000 Subject: [PATCH 18/21] changelog: stamp v0.51.528 (Release SM) for #2698 isolated HERMES_HOME mode --- CHANGELOG.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 562ab413..e586578c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,12 @@ ## [Unreleased] +## [v0.51.528] — 2026-06-20 — Release SM (isolated HERMES_HOME single-profile mode) + +### Added + +- **Pin the WebUI to a single profile by pointing `HERMES_HOME` at a profile directory (#2698).** When `HERMES_HOME` resolves to `~/.hermes/profiles/` at startup, the WebUI now runs in isolated single-profile mode: it lists only that profile, rejects cross-profile switch/create/delete (403), scopes the WebUI state directory to `/webui` (so even a profile literally named `default` gets its own sessions/state rather than sharing the root profile's), forces `?all_profiles=1` aggregate reads off (no cross-profile session/project leak), and hides the multi-profile UI affordances (New-profile button, profile dropdown, Manage link). A normal install — `HERMES_HOME` unset or pointing at the base `~/.hermes` — is unaffected: isolation stays off and the state directory and multi-profile behavior are unchanged. Thanks @rodboev (state-dir contract folded in from @tomtong2015's #4449). + ## [v0.51.527] — 2026-06-19 — Release SL (completion notifications survive a backgrounded tab) ### Fixed From 4d4b11b43c9f4e01c559fd71c3c949e04ae2d8a3 Mon Sep 17 00:00:00 2001 From: nesquena-hermes Date: Sat, 20 Jun 2026 00:51:08 +0000 Subject: [PATCH 19/21] test+docs(#4449): backward-compat state-dir tests + upgrade migration note (Opus gate) Opus flagged a real upgrade gap on the #4449 state-dir contract folded into #4454: a deployment that relocated HERMES_HOME to a non-default base WITHOUT setting HERMES_WEBUI_STATE_DIR sees WebUI state move to $HERMES_HOME/webui on upgrade (empty app at the new address). Add (1) README note documenting the move + how to keep the old location, and (2) two backward-compat regression tests pinning the invariants: HERMES_HOME-unset stays at the platform default (normal installs don't relocate), and HERMES_WEBUI_STATE_DIR takes precedence. --- README.md | 2 +- tests/test_issue4449_state_dir_contract.py | 56 ++++++++++++++++++++++ 2 files changed, 57 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 72982f29..e5348d96 100644 --- a/README.md +++ b/README.md @@ -337,7 +337,7 @@ Full list of environment variables: | `HERMES_WEBUI_PYTHON` | auto-discovered | Python executable | | `HERMES_WEBUI_HOST` | `127.0.0.1` | Bind address (`0.0.0.0` for all IPv4, `::` for all IPv6, `::1` for IPv6 loopback) | | `HERMES_WEBUI_PORT` | `8787` | Port | -| `HERMES_WEBUI_STATE_DIR` | `$HERMES_HOME/webui` (Windows default `%LOCALAPPDATA%\hermes\webui`, POSIX default `~/.hermes/webui`) | Where sessions and state are stored | +| `HERMES_WEBUI_STATE_DIR` | `$HERMES_HOME/webui` (Windows default `%LOCALAPPDATA%\hermes\webui`, POSIX default `~/.hermes/webui`) | Where sessions and state are stored. **Note (upgrade):** the default now follows `HERMES_HOME` — if you previously relocated `HERMES_HOME` to a non-default base **without** setting `HERMES_WEBUI_STATE_DIR`, your WebUI state now resolves to `$HERMES_HOME/webui` instead of the old platform-default `~/.hermes/webui`. To keep using the old location, set `HERMES_WEBUI_STATE_DIR` to it (or move the directory). Installs with `HERMES_HOME` unset or at the default base are unaffected. | | `HERMES_WEBUI_DEFAULT_WORKSPACE` | `~/workspace` | Default workspace | | `HERMES_WEBUI_DEFAULT_MODEL` | *(provider default)* | Optional model override; leave unset to use the active Hermes provider default | | `HERMES_WEBUI_PASSWORD` | *(unset)* | Set to enable password authentication | diff --git a/tests/test_issue4449_state_dir_contract.py b/tests/test_issue4449_state_dir_contract.py index 81ae37c8..43922851 100644 --- a/tests/test_issue4449_state_dir_contract.py +++ b/tests/test_issue4449_state_dir_contract.py @@ -31,3 +31,59 @@ def test_config_state_dir_defaults_to_hermes_home_webui(tmp_path): else: os.environ["HERMES_WEBUI_STATE_DIR"] = old_state_dir importlib.reload(config) + + +def test_config_state_dir_unchanged_for_normal_install_hermes_home_unset(tmp_path): + """Backward-compat: with HERMES_HOME unset, STATE_DIR stays at the platform + default `<~/.hermes>/webui` — a normal install's state must NOT relocate + (the #4449/#4454 state-dir move only affects an explicitly-set HERMES_HOME).""" + old_home = os.environ.get("HERMES_HOME") + old_state_dir = os.environ.get("HERMES_WEBUI_STATE_DIR") + try: + os.environ.pop("HERMES_HOME", None) + os.environ.pop("HERMES_WEBUI_STATE_DIR", None) + + reloaded = importlib.reload(config) + + # _platform_default_hermes_home() drives the default; STATE_DIR must be + # that base + /webui, exactly as on master before the #4449 change. + expected = (reloaded._platform_default_hermes_home() / "webui").resolve() + assert reloaded.STATE_DIR == expected + finally: + if old_home is None: + os.environ.pop("HERMES_HOME", None) + else: + os.environ["HERMES_HOME"] = old_home + if old_state_dir is None: + os.environ.pop("HERMES_WEBUI_STATE_DIR", None) + else: + os.environ["HERMES_WEBUI_STATE_DIR"] = old_state_dir + importlib.reload(config) + + +def test_config_state_dir_explicit_override_takes_precedence(tmp_path): + """HERMES_WEBUI_STATE_DIR always wins over the HERMES_HOME-derived default, + so an operator who pinned a state dir keeps it even in isolated mode.""" + hermes_home = tmp_path / ".hermes" / "profiles" / "isolated" + hermes_home.mkdir(parents=True) + explicit = tmp_path / "custom-state" + + old_home = os.environ.get("HERMES_HOME") + old_state_dir = os.environ.get("HERMES_WEBUI_STATE_DIR") + try: + os.environ["HERMES_HOME"] = str(hermes_home) + os.environ["HERMES_WEBUI_STATE_DIR"] = str(explicit) + + reloaded = importlib.reload(config) + + assert reloaded.STATE_DIR == explicit.resolve() + finally: + if old_home is None: + os.environ.pop("HERMES_HOME", None) + else: + os.environ["HERMES_HOME"] = old_home + if old_state_dir is None: + os.environ.pop("HERMES_WEBUI_STATE_DIR", None) + else: + os.environ["HERMES_WEBUI_STATE_DIR"] = old_state_dir + importlib.reload(config) From 7cd464efa677250b1c85e055e112cb3b668c2de8 Mon Sep 17 00:00:00 2001 From: nesquena-hermes Date: Sat, 20 Jun 2026 01:22:26 +0000 Subject: [PATCH 20/21] test(#4449): isolate state-dir contract tests via subprocess (fix suite-ordering leak) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The reload-based state-dir tests (importlib.reload(api.config) with mutated env) leaked recomputed STATE_DIR/SESSION_DIR globals + stale references into later tests in the full sequential run -> 17 spurious failures in cancel/stream/ health/state-isolation suites (all green in isolation). Restoring env + reloading couldn't fully undo it (fixture/monkeypatch teardown ordering + other modules holding by-value STATE_DIR refs). Rewrite the 3 tests to import api.config in a fresh SUBPROCESS under the target env and read STATE_DIR from stdout — truly isolated, can't pollute the test process. Same assertions (HERMES_HOME-derived default, unset->platform default w/ cross-check, STATE_DIR override precedence). --- tests/test_issue4449_state_dir_contract.py | 133 +++++++++++---------- 1 file changed, 69 insertions(+), 64 deletions(-) diff --git a/tests/test_issue4449_state_dir_contract.py b/tests/test_issue4449_state_dir_contract.py index 43922851..82671698 100644 --- a/tests/test_issue4449_state_dir_contract.py +++ b/tests/test_issue4449_state_dir_contract.py @@ -1,64 +1,83 @@ -"""Regression tests for the #4449 state-dir contract folded into #4454.""" +"""Regression tests for the #4449 state-dir contract folded into #4454. + +`api.config.STATE_DIR` is a module-level global computed from the environment at +import time. Exercising how it's derived requires importing config under a +specific HERMES_HOME / HERMES_WEBUI_STATE_DIR. We do this in a SUBPROCESS rather +than `importlib.reload(config)` in-process: reloading config inside the shared +pytest process leaks recomputed globals (STATE_DIR/SESSION_DIR) and stale +references into later tests (the cancel/stream/health/state-isolation suites), +which is fragile no matter how carefully env is restored. A subprocess gives a +truly isolated import and can't pollute the test process. +""" from __future__ import annotations -import importlib import os +import subprocess +import sys +from pathlib import Path -import api.config as config +REPO_ROOT = Path(__file__).resolve().parents[1] + +_PROBE = ( + "import api.config as c; " + "print(c.STATE_DIR)" +) + + +def _state_dir_for_env(**env_overrides) -> Path: + """Import api.config in a fresh subprocess under the given env and return + the resolved STATE_DIR it computes. None-valued overrides unset the var.""" + env = dict(os.environ) + # Start from a clean slate for the two vars under test so the parent + # process's pytest values don't bleed in. + env.pop("HERMES_HOME", None) + env.pop("HERMES_WEBUI_STATE_DIR", None) + for key, value in env_overrides.items(): + if value is None: + env.pop(key, None) + else: + env[key] = str(value) + out = subprocess.run( + [sys.executable, "-c", _PROBE], + cwd=str(REPO_ROOT), + env=env, + capture_output=True, + text=True, + timeout=60, + ) + assert out.returncode == 0, f"probe failed: {out.stderr}" + return Path(out.stdout.strip()).resolve() + + +def _platform_default_state_dir() -> Path: + """What STATE_DIR resolves to with HERMES_HOME + STATE_DIR both unset.""" + return _state_dir_for_env() def test_config_state_dir_defaults_to_hermes_home_webui(tmp_path): hermes_home = tmp_path / ".hermes" / "profiles" / "isolated" hermes_home.mkdir(parents=True) - old_home = os.environ.get("HERMES_HOME") - old_state_dir = os.environ.get("HERMES_WEBUI_STATE_DIR") - try: - os.environ["HERMES_HOME"] = str(hermes_home) - os.environ.pop("HERMES_WEBUI_STATE_DIR", None) - - reloaded = importlib.reload(config) - - assert reloaded.STATE_DIR == (hermes_home / "webui").resolve() - finally: - if old_home is None: - os.environ.pop("HERMES_HOME", None) - else: - os.environ["HERMES_HOME"] = old_home - if old_state_dir is None: - os.environ.pop("HERMES_WEBUI_STATE_DIR", None) - else: - os.environ["HERMES_WEBUI_STATE_DIR"] = old_state_dir - importlib.reload(config) + state_dir = _state_dir_for_env(HERMES_HOME=hermes_home) + assert state_dir == (hermes_home / "webui").resolve() -def test_config_state_dir_unchanged_for_normal_install_hermes_home_unset(tmp_path): +def test_config_state_dir_unchanged_for_normal_install_hermes_home_unset(): """Backward-compat: with HERMES_HOME unset, STATE_DIR stays at the platform default `<~/.hermes>/webui` — a normal install's state must NOT relocate - (the #4449/#4454 state-dir move only affects an explicitly-set HERMES_HOME).""" - old_home = os.environ.get("HERMES_HOME") - old_state_dir = os.environ.get("HERMES_WEBUI_STATE_DIR") - try: - os.environ.pop("HERMES_HOME", None) - os.environ.pop("HERMES_WEBUI_STATE_DIR", None) + (the #4449/#4454 state-dir move only affects an explicitly-set HERMES_HOME). - reloaded = importlib.reload(config) - - # _platform_default_hermes_home() drives the default; STATE_DIR must be - # that base + /webui, exactly as on master before the #4449 change. - expected = (reloaded._platform_default_hermes_home() / "webui").resolve() - assert reloaded.STATE_DIR == expected - finally: - if old_home is None: - os.environ.pop("HERMES_HOME", None) - else: - os.environ["HERMES_HOME"] = old_home - if old_state_dir is None: - os.environ.pop("HERMES_WEBUI_STATE_DIR", None) - else: - os.environ["HERMES_WEBUI_STATE_DIR"] = old_state_dir - importlib.reload(config) + Cross-check: the unset-HERMES_HOME result must NOT equal the result of + pointing HERMES_HOME at an arbitrary other base — i.e. the default is + genuinely the platform home, not whatever the test environment injected.""" + default = _platform_default_state_dir() + assert default.name == "webui" + # Pointing HERMES_HOME elsewhere produces a DIFFERENT dir, proving the unset + # case resolves to the platform default rather than echoing an injected base. + elsewhere = _state_dir_for_env(HERMES_HOME="/tmp/hermes-4449-elsewhere-base") + assert elsewhere == Path("/tmp/hermes-4449-elsewhere-base/webui").resolve() + assert default != elsewhere def test_config_state_dir_explicit_override_takes_precedence(tmp_path): @@ -68,22 +87,8 @@ def test_config_state_dir_explicit_override_takes_precedence(tmp_path): hermes_home.mkdir(parents=True) explicit = tmp_path / "custom-state" - old_home = os.environ.get("HERMES_HOME") - old_state_dir = os.environ.get("HERMES_WEBUI_STATE_DIR") - try: - os.environ["HERMES_HOME"] = str(hermes_home) - os.environ["HERMES_WEBUI_STATE_DIR"] = str(explicit) - - reloaded = importlib.reload(config) - - assert reloaded.STATE_DIR == explicit.resolve() - finally: - if old_home is None: - os.environ.pop("HERMES_HOME", None) - else: - os.environ["HERMES_HOME"] = old_home - if old_state_dir is None: - os.environ.pop("HERMES_WEBUI_STATE_DIR", None) - else: - os.environ["HERMES_WEBUI_STATE_DIR"] = old_state_dir - importlib.reload(config) + state_dir = _state_dir_for_env( + HERMES_HOME=hermes_home, + HERMES_WEBUI_STATE_DIR=explicit, + ) + assert state_dir == explicit.resolve() From 078fb995893737aad3c3319f4f51dbd7917ec720 Mon Sep 17 00:00:00 2001 From: nesquena-hermes Date: Sat, 20 Jun 2026 01:47:22 +0000 Subject: [PATCH 21/21] test(#2698): stub hermes_cli.profiles via sys.modules, not mock.patch (CI shard-2 fix) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI failed on shard 2 across ALL python versions (deterministic, not a flake): test_list_profiles_prefers_matching_isolated_default_home did mock.patch('hermes_cli.profiles.list_profiles', ...), whose string target forces a REAL import of hermes_cli — not installed on the WebUI CI path (bundled agent absent) -> ModuleNotFoundError. Passed locally only because hermes_cli is importable here. Per the never-import-agent/hermes_cli-in-a-WebUI-test rule: inject a stub hermes_cli + hermes_cli.profiles into sys.modules so list_profiles_api()'s internal resolves to the stub at the WebUI boundary. Verified: all 24 tests pass with hermes_cli import BLOCKED (CI simulation). --- tests/test_issue2698_isolated_hermes_home.py | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/tests/test_issue2698_isolated_hermes_home.py b/tests/test_issue2698_isolated_hermes_home.py index 46a9ae3e..dae1026d 100644 --- a/tests/test_issue2698_isolated_hermes_home.py +++ b/tests/test_issue2698_isolated_hermes_home.py @@ -327,11 +327,22 @@ class TestIsolatedRuntimePinning: monkeypatch.setattr(_profiles_mod, "_INITIAL_HERMES_HOME", str(isolated_default)) monkeypatch.setattr(_profiles_mod, "_get_profile_skills_stats", lambda _path: (0, 0)) - with mock.patch("hermes_cli.profiles.list_profiles", return_value=[ + # Inject a STUB hermes_cli.profiles into sys.modules rather than + # mock.patch("hermes_cli.profiles.list_profiles", ...): that string + # target forces a real import of hermes_cli, which is NOT installed in + # CI (the bundled agent isn't on the WebUI test path) -> ModuleNotFoundError. + # list_profiles_api() does `from hermes_cli.profiles import list_profiles` + # internally, so a stub module satisfies it at the WebUI boundary. (#4454 CI) + stub_profiles = types.ModuleType("hermes_cli.profiles") + stub_profiles.list_profiles = lambda: [ _Info("default", temp_hermes_home, True), _Info("default", isolated_default, False), - ]): - rows = list_profiles_api() + ] + stub_pkg = sys.modules.get("hermes_cli") or types.ModuleType("hermes_cli") + monkeypatch.setitem(sys.modules, "hermes_cli", stub_pkg) + monkeypatch.setitem(sys.modules, "hermes_cli.profiles", stub_profiles) + + rows = list_profiles_api() assert len(rows) == 1 assert Path(rows[0]["path"]) == isolated_default