fix(profiles): honor isolated HERMES_HOME profile mode (#2698)
This commit is contained in:
1 parent
99d5db35c5
commit
963f7c3864
4 files changed
+345
-21
No files matched your search
+83
-2
@@ -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/<name>
|
||||
# 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/<name>),
|
||||
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)
|
||||
|
||||
+10
-2
@@ -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":
|
||||
|
||||
+35
-17
@@ -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 = `
|
||||
<div class="profile-card-header">
|
||||
<div style="min-width:0;flex:1">
|
||||
<div class="profile-card-name">Profiles vs workspaces</div>
|
||||
<div class="profile-card-meta">Use profiles for how the agent works; use workspaces for what files it works on.</div>
|
||||
</div>
|
||||
</div>`;
|
||||
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 = `
|
||||
<div class="profile-card-header">
|
||||
<div style="min-width:0;flex:1">
|
||||
<div class="profile-card-name">Profiles vs workspaces</div>
|
||||
<div class="profile-card-meta">Use profiles for how the agent works; use workspaces for what files it works on.</div>
|
||||
</div>
|
||||
</div>`;
|
||||
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();
|
||||
|
||||
@@ -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
|
||||
Reference in new issue
Block a user