""" 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() # Thread-local profile context: set per-request by server.py, cleared after. # Enables per-client profile isolation (issue #798) — each HTTP request thread # reads its own profile from the hermes_profile cookie instead of the # process-global _active_profile. _tls = threading.local() 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. Priority: 1. Thread-local (set per-request from hermes_profile cookie) — issue #798 2. Process-level default (_active_profile) """ tls_name = getattr(_tls, 'profile', None) if tls_name is not None: return tls_name return _active_profile def set_request_profile(name: str) -> None: """Set the per-request profile context for this thread. Called by server.py at the start of each request when a hermes_profile cookie is present. Always paired with clear_request_profile() in a finally block so the thread-local is released after the request. """ _tls.profile = name def clear_request_profile() -> None: """Clear the per-request profile context for this thread. Called by server.py in the finally block of do_GET / do_POST. Safe to call even if set_request_profile() was never called. """ _tls.profile = None def get_active_hermes_home() -> Path: """Return the HERMES_HOME path for the currently active profile. Uses get_active_profile_name() so per-request TLS context (issue #798) is respected, not just the process-level global. """ name = get_active_profile_name() if name == 'default': return _DEFAULT_HERMES_HOME profile_dir = _DEFAULT_HERMES_HOME / 'profiles' / name if profile_dir.is_dir(): return profile_dir return _DEFAULT_HERMES_HOME def get_hermes_home_for_profile(name: str) -> Path: """Return the HERMES_HOME Path for *name* without mutating any process state. Safe to call from per-request context (streaming, session creation) because it reads only the filesystem — it never touches os.environ, module-level cached paths, or the process-level _active_profile global. Falls back to _DEFAULT_HERMES_HOME (same as 'default') when *name* is None, empty, 'default', or does not match the profile-name format (rejects path traversal such as '../../etc'). """ if not name or name == 'default' or not _PROFILE_ID_RE.match(name): return _DEFAULT_HERMES_HOME profile_dir = _DEFAULT_HERMES_HOME / 'profiles' / name 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, *, process_wide: bool = True) -> dict: """Switch the active profile. Validates the profile exists, updates process state, patches module caches, reloads .env, and reloads config.yaml. Args: name: Profile name to switch to. process_wide: If True (default), updates the process-global _active_profile. Set to False for per-client switches from the 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. """ 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: if process_wide: global _active_profile _active_profile = name _set_hermes_home(home) _reload_dotenv(home) if process_wide: # Write sticky default for CLI consistency try: ap_file = _DEFAULT_HERMES_HOME / 'active_profile' ap_file.write_text(name if name != 'default' else '', encoding='utf-8') 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. # For process_wide=False (per-client switch), read the target profile's # config.yaml directly from disk rather than from _cfg_cache (process-global), # since reload_config() was intentionally skipped. from api.workspace import get_last_workspace if process_wide: from api.config import get_config cfg = get_config() else: # Direct disk read — does not touch _cfg_cache try: import yaml as _yaml cfg_path = home / 'config.yaml' cfg = _yaml.safe_load(cfg_path.read_text(encoding='utf-8')) if cfg_path.exists() else {} if not isinstance(cfg, dict): cfg = {} except Exception: cfg = {} 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 = get_active_profile_name() 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), encoding='utf-8') 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}