""" 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 logging import os import re import shutil import threading from pathlib import Path logger = logging.getLogger(__name__) # ── 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() _loaded_profile_env_keys: set[str] = set() 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(encoding="utf-8").strip() if name: return name except Exception: logger.debug("Failed to read active profile file") 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): logger.debug("Failed to patch skills_tool module") # 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): logger.debug("Failed to patch cron.jobs module") def _reload_dotenv(home: Path): """Load .env from the profile dir into os.environ with profile isolation. Clears env vars that were loaded from the previously active profile before applying the current profile's .env. This prevents API keys and other profile-scoped secrets from leaking across profile switches. """ global _loaded_profile_env_keys # Remove keys loaded from the previous profile first. for key in list(_loaded_profile_env_keys): os.environ.pop(key, None) _loaded_profile_env_keys = set() env_path = home / '.env' if not env_path.exists(): return try: loaded_keys: set[str] = set() for line in env_path.read_text(encoding="utf-8").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 loaded_keys.add(k) _loaded_profile_env_keys = loaded_keys except Exception: _loaded_profile_env_keys = set() logger.debug("Failed to reload dotenv from %s", env_path) def init_profile_state() -> None: """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 = _resolve_named_profile_home(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: logger.debug("Failed to write active profile file") # Reload config.yaml from the new profile reload_config() # Return profile-specific defaults so frontend can apply them 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.") # 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 _profiles_root() -> Path: """Return the canonical root that contains named profiles.""" return (_DEFAULT_HERMES_HOME / 'profiles').resolve() def _resolve_named_profile_home(name: str) -> Path: """Resolve a named profile to a directory under the profiles root. Validates *name* as a logical profile identifier first, then resolves the final filesystem path and enforces containment under ~/.hermes/profiles. """ _validate_profile_name(name) profiles_root = _profiles_root() candidate = (profiles_root / name).resolve() candidate.relative_to(profiles_root) return candidate 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.") # 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 _write_endpoint_to_config(profile_dir: Path, base_url: str = None, api_key: str = None) -> None: """Write custom endpoint fields into config.yaml for a profile.""" if not base_url and not api_key: return config_path = profile_dir / 'config.yaml' try: import yaml as _yaml except ImportError: return cfg = {} if config_path.exists(): try: loaded = _yaml.safe_load(config_path.read_text(encoding="utf-8")) if isinstance(loaded, dict): cfg = loaded except Exception: logger.debug("Failed to load config from %s", config_path) model_section = cfg.get('model', {}) if not isinstance(model_section, dict): model_section = {} if base_url: model_section['base_url'] = base_url if api_key: model_section['api_key'] = api_key cfg['model'] = model_section config_path.write_text(_yaml.dump(cfg, default_flow_style=False, allow_unicode=True)) def create_profile_api(name: str, clone_from: str = None, clone_config: bool = False, base_url: str = None, api_key: str = None) -> dict: """Create a new profile. Returns the new profile info dict.""" _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. 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) # Resolve the profile directory from the profile list when possible. # hermes_cli and the webui runtime do not always agree on the exact root, # so we prefer the path returned by list_profiles_api() and fall back to the # standard profile location only if the profile cannot be found there yet. profile_path = _DEFAULT_HERMES_HOME / 'profiles' / name for p in list_profiles_api(): if p['name'] == name: try: profile_path = Path(p.get('path') or profile_path) except Exception: logger.debug("Failed to parse profile path") break profile_path.mkdir(parents=True, exist_ok=True) _write_endpoint_to_config(profile_path, base_url=base_url, api_key=api_key) # 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. for p in list_profiles_api(): if p['name'] == name: return p 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.") _validate_profile_name(name) # 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 = _resolve_named_profile_home(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}