fix(profiles): honor isolated HERMES_HOME profile mode (#2698)

This commit is contained in:
Rod Boev authored and nesquena-hermes committed 2026-06-20 00:48:19 +00:00
1 parent 99d5db35c5
commit 963f7c3864
4 files changed
+345 -21

No files matched your search

+83 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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