fix(profiles): return 403 for isolated-mode guards, fix fallback identity (#2698)
This commit is contained in:
parent
963f7c3864
commit
36dbe1a415
3 changed files with 54 additions and 20 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue