HERMES-GIELIE/api/profiles.py

367 lines
13 KiB
Python
Raw Normal View History

"""
Hermes Web UI -- Profile state management.
Wraps hermes_cli.profiles to provide profile switching for the web UI.
The web UI maintains a process-level "active profile" that determines which
HERMES_HOME directory is used for config, skills, memory, cron, and API keys.
Profile switches update os.environ['HERMES_HOME'] and monkey-patch module-level
cached paths in hermes-agent modules (skills_tool, cron/jobs) that snapshot
HERMES_HOME at import time.
"""
import json
import os
import re
import shutil
import threading
from pathlib import Path
# ── Constants (match hermes_cli.profiles upstream) ─────────────────────────
_PROFILE_ID_RE = re.compile(r'^[a-z0-9][a-z0-9_-]{0,63}$')
_PROFILE_DIRS = [
'memories', 'sessions', 'skills', 'skins',
'logs', 'plans', 'workspace', 'cron',
]
_CLONE_CONFIG_FILES = ['config.yaml', '.env', 'SOUL.md']
# ── Module state ────────────────────────────────────────────────────────────
_active_profile = 'default'
_profile_lock = threading.Lock()
fix: profile switch fails with 'does not exist' when server starts on non-default profile Root cause: _DEFAULT_HERMES_HOME was evaluated at module import time from os.getenv('HERMES_HOME'). HERMES_HOME is a MUTABLE env var -- init_profile_state() at server startup calls _set_hermes_home() which writes to os.environ['HERMES_HOME']. If the sticky active_profile file pointed to e.g. 'webui', HERMES_HOME was set to ~/.hermes/profiles/webui BEFORE api/profiles.py imported. So _DEFAULT_HERMES_HOME resolved to ~/.hermes/profiles/webui. Then switch_profile('webui') computed: home = ~/.hermes/profiles/webui / 'profiles' / 'webui' = ~/.hermes/profiles/webui/profiles/webui -- doesn't exist -> 404 ValueError Fix: replace the one-liner assignment with _resolve_base_hermes_home() which: 1. Checks HERMES_BASE_HOME env var (explicit override) 2. Checks HERMES_HOME -- but if it looks like a profiles/ subdir (parent.name == 'profiles'), walks up two levels to the actual base 3. Falls back to Path.home() / '.hermes' This means the server can start with HERMES_HOME pointing to any profile and _DEFAULT_HERMES_HOME will still correctly point to ~/.hermes. Also fix: api() helper in workspace.js was throwing new Error(await res.text()) which surfaced raw JSON to the UI: 'Switch failed: {"error":"Profile X does not exist."}' Now parses the JSON and extracts j.error so the toast shows clean human-readable text. Regression tests added in test_sprint23.py: - test_profile_switch_base_home_not_subdir: static analysis verifying the resolver - test_api_helper_returns_clean_error_message: verifies api() parses JSON errors - test_profile_switch_resolve_base_home_logic: verifies the profiles/ subdir detection Tests: 426 passed, 0 failed.
2026-04-03 19:29:24 +00:00
def _resolve_base_hermes_home() -> Path:
"""Return the BASE ~/.hermes directory — the root that contains profiles/.
This is intentionally distinct from HERMES_HOME, which tracks the *active
profile's* home and changes on every profile switch. The base dir must
always point to the top-level .hermes regardless of which profile is active.
Resolution order:
1. HERMES_BASE_HOME env var (set explicitly, highest priority)
2. HERMES_HOME env var — but only if it does NOT look like a profile subdir
(i.e. its parent is not named 'profiles'). This handles test isolation
where HERMES_HOME is set to an isolated test state dir.
3. ~/.hermes (always-correct default)
The bug this prevents: if HERMES_HOME has already been mutated to
/home/user/.hermes/profiles/webui (by init_profile_state at startup),
reading it here would make _DEFAULT_HERMES_HOME point to that subdir,
causing switch_profile('webui') to look for
/home/user/.hermes/profiles/webui/profiles/webui — which doesn't exist.
"""
# Explicit override for tests or unusual setups
base_override = os.getenv('HERMES_BASE_HOME', '').strip()
if base_override:
return Path(base_override).expanduser()
hermes_home = os.getenv('HERMES_HOME', '').strip()
if hermes_home:
p = Path(hermes_home).expanduser()
# If HERMES_HOME points to a profiles/ subdir, walk up two levels to the base
if p.parent.name == 'profiles':
return p.parent.parent
# Otherwise trust it (e.g. test isolation sets HERMES_HOME to TEST_STATE_DIR)
return p
return Path.home() / '.hermes'
_DEFAULT_HERMES_HOME = _resolve_base_hermes_home()
def _read_active_profile_file() -> str:
"""Read the sticky active profile from ~/.hermes/active_profile."""
ap_file = _DEFAULT_HERMES_HOME / 'active_profile'
if ap_file.exists():
try:
name = ap_file.read_text().strip()
if name:
return name
except Exception:
pass
return 'default'
# ── Public API ──────────────────────────────────────────────────────────────
def get_active_profile_name() -> str:
"""Return the currently active profile name."""
return _active_profile
def get_active_hermes_home() -> Path:
"""Return the HERMES_HOME path for the currently active profile."""
if _active_profile == 'default':
return _DEFAULT_HERMES_HOME
profile_dir = _DEFAULT_HERMES_HOME / 'profiles' / _active_profile
if profile_dir.is_dir():
return profile_dir
return _DEFAULT_HERMES_HOME
def _set_hermes_home(home: Path):
"""Set HERMES_HOME env var and monkey-patch cached module-level paths."""
os.environ['HERMES_HOME'] = str(home)
# Patch skills_tool module-level cache (snapshots HERMES_HOME at import)
try:
import tools.skills_tool as _sk
_sk.HERMES_HOME = home
_sk.SKILLS_DIR = home / 'skills'
except (ImportError, AttributeError):
pass
# Patch cron/jobs module-level cache
try:
import cron.jobs as _cj
_cj.HERMES_DIR = home
_cj.CRON_DIR = home / 'cron'
_cj.JOBS_FILE = _cj.CRON_DIR / 'jobs.json'
_cj.OUTPUT_DIR = _cj.CRON_DIR / 'output'
except (ImportError, AttributeError):
pass
def _reload_dotenv(home: Path):
"""Load .env from the profile dir into os.environ (additive)."""
env_path = home / '.env'
if not env_path.exists():
return
try:
for line in env_path.read_text().splitlines():
line = line.strip()
if line and not line.startswith('#') and '=' in line:
k, v = line.split('=', 1)
k = k.strip()
v = v.strip().strip('"').strip("'")
if k and v:
os.environ[k] = v
except Exception:
pass
def init_profile_state():
"""Initialize profile state at server startup.
Reads ~/.hermes/active_profile, sets HERMES_HOME env var, patches
module-level cached paths. Called once from config.py after imports.
"""
global _active_profile
_active_profile = _read_active_profile_file()
home = get_active_hermes_home()
_set_hermes_home(home)
_reload_dotenv(home)
def switch_profile(name: str) -> dict:
"""Switch the active profile.
Validates the profile exists, updates process state, patches module caches,
reloads .env, and reloads config.yaml.
Returns: {'profiles': [...], 'active': name}
Raises ValueError if profile doesn't exist or agent is busy.
"""
global _active_profile
# Import here to avoid circular import at module load
from api.config import STREAMS, STREAMS_LOCK, reload_config
# Block if agent is running
with STREAMS_LOCK:
if len(STREAMS) > 0:
raise RuntimeError(
'Cannot switch profiles while an agent is running. '
'Cancel or wait for it to finish.'
)
# Resolve profile directory
if name == 'default':
home = _DEFAULT_HERMES_HOME
else:
home = _DEFAULT_HERMES_HOME / 'profiles' / name
if not home.is_dir():
raise ValueError(f"Profile '{name}' does not exist.")
with _profile_lock:
_active_profile = name
_set_hermes_home(home)
_reload_dotenv(home)
# Write sticky default for CLI consistency
try:
ap_file = _DEFAULT_HERMES_HOME / 'active_profile'
ap_file.write_text(name if name != 'default' else '')
except Exception:
pass
# Reload config.yaml from the new profile
reload_config()
# Return profile-specific defaults so frontend can apply them
fix(review): 5 issues found in agent review of PR #43 BUG-1 (critical): api/profiles.py _DEFAULT_HERMES_HOME used Path.home()/.hermes hardcoded, ignoring the HERMES_HOME env var. conftest.py sets HERMES_HOME to a test-isolated state dir -- but profiles.py bypassed it and read/wrote real ~/.hermes during every test run (active_profile file, .env loading). Fixed by reading os.getenv('HERMES_HOME', ...) at module load time. BUG-7 (medium): api/workspace.py load_workspaces() fell back to the global workspaces.json for ALL profiles when their profile-local file didn't exist yet. New named profiles silently inherited the default profile's workspace list instead of starting clean. Fixed: the global file fallback now only applies to the default profile (migration path); named profiles start with a fresh default entry. BUG-4 (high): test_sessions_list_includes_profile had a vacuous 'if matching:' guard -- if the session wasn't found the assert was silently skipped and the test passed. Fixed with hard assert. Also changed to use /api/session?session_id= directly instead of scanning /api/sessions (which filters out empty Untitled sessions with 0 messages, causing the test to always see an empty match list). BUG-5 / test ordering regression: test_profile_switch_returns_default_model_and_workspace failed with 409 because test_chat_stream_opens_successfully (runs earlier in the suite) starts a real LLM stream that stays alive in STREAMS. Added a wait loop (up to 30s) polling /health active_streams before attempting the profile switch. BUG-8 (low): Removed dead import _profile_default_workspace in switch_profile() -- was imported but never used (get_last_workspace() already delegates to it). Also: test_profile_active_endpoint hardcoded assert data['name'] == 'default' which fails if a prior run left a non-default active_profile on disk. Changed to assert name is a non-empty string (the endpoint contract), not a specific value. Tests: 423 passed, 0 failed.
2026-04-03 19:03:16 +00:00
from api.workspace import get_last_workspace
from api.config import get_config
cfg = get_config()
model_cfg = cfg.get('model', {})
default_model = None
if isinstance(model_cfg, str):
default_model = model_cfg
elif isinstance(model_cfg, dict):
default_model = model_cfg.get('default')
return {
'profiles': list_profiles_api(),
'active': name,
'default_model': default_model,
'default_workspace': get_last_workspace(),
}
def list_profiles_api() -> list:
"""List all profiles with metadata, serialized for JSON response."""
try:
from hermes_cli.profiles import list_profiles
infos = list_profiles()
except ImportError:
# hermes_cli not available -- return just the default
return [_default_profile_dict()]
active = _active_profile
result = []
for p in infos:
result.append({
'name': p.name,
'path': str(p.path),
'is_default': p.is_default,
'is_active': p.name == active,
'gateway_running': p.gateway_running,
'model': p.model,
'provider': p.provider,
'has_env': p.has_env,
'skill_count': p.skill_count,
})
return result
def _default_profile_dict() -> dict:
"""Fallback profile dict when hermes_cli is not importable."""
return {
'name': 'default',
'path': str(_DEFAULT_HERMES_HOME),
'is_default': True,
'is_active': True,
'gateway_running': False,
'model': None,
'provider': None,
'has_env': (_DEFAULT_HERMES_HOME / '.env').exists(),
'skill_count': 0,
}
def _validate_profile_name(name: str):
"""Validate profile name format (matches hermes_cli.profiles upstream)."""
if name == 'default':
raise ValueError("Cannot create a profile named 'default' -- it is the built-in profile.")
fix(review): 4 issues found in agent review of PR #45 BUG-1 (medium): _validate_profile_name() used re.match() with a $ anchor. re.match() with $ is truthy for 'name\n' because match() allows trailing content after the $ in multiline mode. Changed to re.fullmatch() which requires the entire string to match — trailing newlines now correctly rejected. BUG-2 (medium/defense-in-depth): create_profile_api() validated 'name' via _validate_profile_name() but passed clone_from directly to hermes_cli and _create_profile_fallback() without validation. Added clone_from validation inside create_profile_api() (skipping 'default' which is a valid clone source). routes.py already validates it at the HTTP layer; this adds API-layer defense. BUG-3 (low): When hermes_cli is not importable (the exact Docker case this PR targets), list_profiles_api() also returns only the stub default dict and can't find the newly created profile by name. The fallback return was a 2-key dict {name, path} — incomplete vs the 9-key schema everywhere else. Expanded to the full profile dict with all fields so API clients get consistent data regardless of hermes_cli availability. OBS-4 (low/TOCTOU): _create_profile_fallback() checked profile_dir.exists() then called mkdir(exist_ok=True). If a concurrent request created the dir between those two calls, mkdir silently succeeded — defeating the FileExistsError guard. Changed to mkdir(exist_ok=False) so the OS raises FileExistsError atomically if the dir appears in the race window. Tests: 423 passed, 0 failed.
2026-04-03 20:52:37 +00:00
# Use fullmatch (not match) so a trailing newline can't sneak past the $ anchor
if not _PROFILE_ID_RE.fullmatch(name):
raise ValueError(
f"Invalid profile name {name!r}. "
"Must match [a-z0-9][a-z0-9_-]{0,63}"
)
def _create_profile_fallback(name: str, clone_from: str = None,
clone_config: bool = False) -> Path:
"""Create a profile directory without hermes_cli (Docker/standalone fallback)."""
profile_dir = _DEFAULT_HERMES_HOME / 'profiles' / name
if profile_dir.exists():
raise FileExistsError(f"Profile '{name}' already exists.")
fix(review): 4 issues found in agent review of PR #45 BUG-1 (medium): _validate_profile_name() used re.match() with a $ anchor. re.match() with $ is truthy for 'name\n' because match() allows trailing content after the $ in multiline mode. Changed to re.fullmatch() which requires the entire string to match — trailing newlines now correctly rejected. BUG-2 (medium/defense-in-depth): create_profile_api() validated 'name' via _validate_profile_name() but passed clone_from directly to hermes_cli and _create_profile_fallback() without validation. Added clone_from validation inside create_profile_api() (skipping 'default' which is a valid clone source). routes.py already validates it at the HTTP layer; this adds API-layer defense. BUG-3 (low): When hermes_cli is not importable (the exact Docker case this PR targets), list_profiles_api() also returns only the stub default dict and can't find the newly created profile by name. The fallback return was a 2-key dict {name, path} — incomplete vs the 9-key schema everywhere else. Expanded to the full profile dict with all fields so API clients get consistent data regardless of hermes_cli availability. OBS-4 (low/TOCTOU): _create_profile_fallback() checked profile_dir.exists() then called mkdir(exist_ok=True). If a concurrent request created the dir between those two calls, mkdir silently succeeded — defeating the FileExistsError guard. Changed to mkdir(exist_ok=False) so the OS raises FileExistsError atomically if the dir appears in the race window. Tests: 423 passed, 0 failed.
2026-04-03 20:52:37 +00:00
# Bootstrap directory structure (exist_ok=False so a concurrent create raises)
profile_dir.mkdir(parents=True, exist_ok=False)
for subdir in _PROFILE_DIRS:
(profile_dir / subdir).mkdir(parents=True, exist_ok=True)
# Clone config files from source profile if requested
if clone_config and clone_from:
if clone_from == 'default':
source_dir = _DEFAULT_HERMES_HOME
else:
source_dir = _DEFAULT_HERMES_HOME / 'profiles' / clone_from
if source_dir.is_dir():
for filename in _CLONE_CONFIG_FILES:
src = source_dir / filename
if src.exists():
shutil.copy2(src, profile_dir / filename)
return profile_dir
def create_profile_api(name: str, clone_from: str = None,
clone_config: bool = False) -> dict:
"""Create a new profile. Returns the new profile info dict."""
_validate_profile_name(name)
fix(review): 4 issues found in agent review of PR #45 BUG-1 (medium): _validate_profile_name() used re.match() with a $ anchor. re.match() with $ is truthy for 'name\n' because match() allows trailing content after the $ in multiline mode. Changed to re.fullmatch() which requires the entire string to match — trailing newlines now correctly rejected. BUG-2 (medium/defense-in-depth): create_profile_api() validated 'name' via _validate_profile_name() but passed clone_from directly to hermes_cli and _create_profile_fallback() without validation. Added clone_from validation inside create_profile_api() (skipping 'default' which is a valid clone source). routes.py already validates it at the HTTP layer; this adds API-layer defense. BUG-3 (low): When hermes_cli is not importable (the exact Docker case this PR targets), list_profiles_api() also returns only the stub default dict and can't find the newly created profile by name. The fallback return was a 2-key dict {name, path} — incomplete vs the 9-key schema everywhere else. Expanded to the full profile dict with all fields so API clients get consistent data regardless of hermes_cli availability. OBS-4 (low/TOCTOU): _create_profile_fallback() checked profile_dir.exists() then called mkdir(exist_ok=True). If a concurrent request created the dir between those two calls, mkdir silently succeeded — defeating the FileExistsError guard. Changed to mkdir(exist_ok=False) so the OS raises FileExistsError atomically if the dir appears in the race window. Tests: 423 passed, 0 failed.
2026-04-03 20:52:37 +00:00
# Defense-in-depth: validate clone_from here too, even though routes.py
# also validates it. Any caller that bypasses the HTTP layer gets protection.
if clone_from is not None and clone_from != 'default':
_validate_profile_name(clone_from)
try:
from hermes_cli.profiles import create_profile
create_profile(
name,
clone_from=clone_from,
clone_config=clone_config,
clone_all=False,
no_alias=True,
)
except ImportError:
_create_profile_fallback(name, clone_from, clone_config)
fix(review): 4 issues found in agent review of PR #45 BUG-1 (medium): _validate_profile_name() used re.match() with a $ anchor. re.match() with $ is truthy for 'name\n' because match() allows trailing content after the $ in multiline mode. Changed to re.fullmatch() which requires the entire string to match — trailing newlines now correctly rejected. BUG-2 (medium/defense-in-depth): create_profile_api() validated 'name' via _validate_profile_name() but passed clone_from directly to hermes_cli and _create_profile_fallback() without validation. Added clone_from validation inside create_profile_api() (skipping 'default' which is a valid clone source). routes.py already validates it at the HTTP layer; this adds API-layer defense. BUG-3 (low): When hermes_cli is not importable (the exact Docker case this PR targets), list_profiles_api() also returns only the stub default dict and can't find the newly created profile by name. The fallback return was a 2-key dict {name, path} — incomplete vs the 9-key schema everywhere else. Expanded to the full profile dict with all fields so API clients get consistent data regardless of hermes_cli availability. OBS-4 (low/TOCTOU): _create_profile_fallback() checked profile_dir.exists() then called mkdir(exist_ok=True). If a concurrent request created the dir between those two calls, mkdir silently succeeded — defeating the FileExistsError guard. Changed to mkdir(exist_ok=False) so the OS raises FileExistsError atomically if the dir appears in the race window. Tests: 423 passed, 0 failed.
2026-04-03 20:52:37 +00:00
# Find and return the newly created profile info.
# When hermes_cli is not importable, list_profiles_api() also falls back
# to the stub default-only list and won't find the new profile by name.
# In that case, return a complete profile dict directly.
profile_path = _DEFAULT_HERMES_HOME / 'profiles' / name
for p in list_profiles_api():
if p['name'] == name:
return p
fix(review): 4 issues found in agent review of PR #45 BUG-1 (medium): _validate_profile_name() used re.match() with a $ anchor. re.match() with $ is truthy for 'name\n' because match() allows trailing content after the $ in multiline mode. Changed to re.fullmatch() which requires the entire string to match — trailing newlines now correctly rejected. BUG-2 (medium/defense-in-depth): create_profile_api() validated 'name' via _validate_profile_name() but passed clone_from directly to hermes_cli and _create_profile_fallback() without validation. Added clone_from validation inside create_profile_api() (skipping 'default' which is a valid clone source). routes.py already validates it at the HTTP layer; this adds API-layer defense. BUG-3 (low): When hermes_cli is not importable (the exact Docker case this PR targets), list_profiles_api() also returns only the stub default dict and can't find the newly created profile by name. The fallback return was a 2-key dict {name, path} — incomplete vs the 9-key schema everywhere else. Expanded to the full profile dict with all fields so API clients get consistent data regardless of hermes_cli availability. OBS-4 (low/TOCTOU): _create_profile_fallback() checked profile_dir.exists() then called mkdir(exist_ok=True). If a concurrent request created the dir between those two calls, mkdir silently succeeded — defeating the FileExistsError guard. Changed to mkdir(exist_ok=False) so the OS raises FileExistsError atomically if the dir appears in the race window. Tests: 423 passed, 0 failed.
2026-04-03 20:52:37 +00:00
return {
'name': name,
'path': str(profile_path),
'is_default': False,
'is_active': _active_profile == name,
'gateway_running': False,
'model': None,
'provider': None,
'has_env': (profile_path / '.env').exists(),
'skill_count': 0,
}
def delete_profile_api(name: str) -> dict:
"""Delete a profile. Switches to default first if it's the active one."""
if name == 'default':
raise ValueError("Cannot delete the default profile.")
# If deleting the active profile, switch to default first
if _active_profile == name:
try:
switch_profile('default')
except RuntimeError:
raise RuntimeError(
f"Cannot delete active profile '{name}' while an agent is running. "
"Cancel or wait for it to finish."
)
try:
from hermes_cli.profiles import delete_profile
delete_profile(name, yes=True)
except ImportError:
# Manual fallback: just remove the directory
import shutil
profile_dir = _DEFAULT_HERMES_HOME / 'profiles' / name
if profile_dir.is_dir():
shutil.rmtree(str(profile_dir))
else:
raise ValueError(f"Profile '{name}' does not exist.")
return {'ok': True, 'name': name}