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