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