Root cause
Authentication & access control introduced
feat: multi-profile support -- create, switch, delete profiles from web UI (Issue #28)
How AI contributed: Direct introductionPublished Apr 21, 2026 in
nesquena/hermes-webuiAll 6 findings in this repository
Additive dotenv reload on profile switch leaves prior profile secrets in os.environ; invariant: switching profiles must drop previously loaded profile keys before loading the next .env.
_reload_dotenv in api/profiles.py copies each profile's dotenv values into the process environment with an os.environ[k] = v loop and never removes the keys it set earlier, so activating profile B leaves profile A's API keys in place. The fix records the keys each reload applied and clears them before loading the next profile.
Root cause
feat: multi-profile support -- create, switch, delete profiles from web UI (Issue #28)
Fix
Tracks keys loaded from each profile, removes those keys from os.environ before loading the next profile, and then applies the new profile's environment values.
Case facts
@@ -0,0 +1,246 @@+"""+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 threading+from pathlib import Path++# ── Module state ────────────────────────────────────────────────────────────+_active_profile = 'default'+_profile_lock = threading.Lock()+_DEFAULT_HERMES_HOME = Path.home() / '.hermes'+++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:@@ -64,19 +64,30 @@ def put(event, data): put('cancel', {'message': 'Cancelled before start'}) return + # Resolve profile home for this agent run (snapshot at start)+ try:+ from api.profiles import get_active_hermes_home+ _profile_home = str(get_active_hermes_home())+ except ImportError:+ _profile_home = os.environ.get('HERMES_HOME', '')+ _set_thread_env( TERMINAL_CWD=str(s.workspace), HERMES_EXEC_ASK='1', HERMES_SESSION_KEY=session_id,+ HERMES_HOME=_profile_home, ) # Still set process-level env as fallback for tools that bypass thread-local with _agent_lock: old_cwd = os.environ.get('TERMINAL_CWD') old_exec_ask = os.environ.get('HERMES_EXEC_ASK') old_session_key = os.environ.get('HERMES_SESSION_KEY')+ old_hermes_home = os.environ.get('HERMES_HOME') os.environ['TERMINAL_CWD'] = str(s.workspace) os.environ['HERMES_EXEC_ASK'] = '1' os.environ['HERMES_SESSION_KEY'] = session_id+ if _profile_home:+ os.environ['HERMES_HOME'] = _profile_home try: def on_token(text):@@ -187,6 +198,8 @@ def on_tool(name, preview, args): else: os.environ['HERMES_EXEC_ASK'] = old_exec_ask if old_session_key is None: os.environ.pop('HERMES_SESSION_KEY', None) else: os.environ['HERMES_SESSION_KEY'] = old_session_key+ if old_hermes_home is None: os.environ.pop('HERMES_HOME', None)+ else: os.environ['HERMES_HOME'] = old_hermes_home except Exception as e: print('[webui] stream error:\n' + traceback.format_exc(), flush=True)@@ -14,6 +14,7 @@ async function switchPanel(name) { if (name === 'skills') await loadSkills(); if (name === 'memory') await loadMemory(); if (name === 'workspaces') await loadWorkspacesPanel();+ if (name === 'profiles') await loadProfilesPanel(); if (name === 'todos') loadTodos(); } @@ -561,6 +562,153 @@ async function switchToWorkspace(path,name){ }catch(e){setStatus('Switch failed: '+e.message);} } +// ── Profile panel + dropdown ──+let _profilesCache = null;++async function loadProfilesPanel() {+ const panel = $('profilesPanel');+ if (!panel) return;+ try {+ const data = await api('/api/profiles');+ _profilesCache = data;+ panel.innerHTML = '';+ if (!data.profiles || !data.profiles.length) {+ panel.innerHTML = '<div style="padding:16px;color:var(--muted);font-size:12px">No profiles found.</div>';+ return;+ }+ for (const p of data.profiles) {+ const card = document.createElement('div');+ card.className = 'profile-card';+ const meta = [];+ if (p.model) meta.push(p.model.split('/').pop());+ if (p.provider) meta.push(p.provider);+ if (p.skill_count) meta.push(p.skill_count + ' skill' + (p.skill_count !== 1 ? 's' : ''));+ if (p.has_env) meta.push('API keys configured');+ const gwDot = p.gateway_running+ ? '<span class="profile-opt-badge running" title="Gateway running"></span>'+ : '<span class="profile-opt-badge stopped" title="Gateway stopped"></span>';+ const isActive = p.name === data.active;+ const activeBadge = isActive ? '<span style="color:var(--link);font-size:10px;font-weight:600;margin-left:6px">ACTIVE</span>' : '';+ card.innerHTML = `+ <div class="profile-card-header">+ <div style="min-width:0;flex:1">+ <div class="profile-card-name${isActive ? ' is-active' : ''}">${gwDot}${esc(p.name)}${p.is_default ? ' <span style="opacity:.5">(default)</span>' : ''}${activeBadge}</div>+ ${meta.length ? `<div class="profile-card-meta">${esc(meta.join(' \u00b7 '))}</div>` : '<div class="profile-card-meta">No configuration</div>'}+ </div>+ <div class="profile-card-actions">+ ${!isActive ? `<button class="ws-action-btn" onclick="switchToProfile('${esc(p.name)}')" title="Switch to this profile">Use</button>` : ''}+ ${!p.is_default ? `<button class="ws-action-btn danger" onclick="deleteProfile('${esc(p.name)}')" title="Delete this profile">✕</button>` : ''}+ </div>+ </div>`;+ panel.appendChild(card);+ }+ } catch (e) {+ panel.innerHTML = `<div style="color:var(--accent);font-size:12px;padding:12px">Error: ${esc(e.message)}</div>`;+ }+}++function renderProfileDropdown(data) {+ const dd = $('profileDropdown');+ if (!dd) return;+ dd.innerHTML = '';+ const profiles = data.profiles || [];+ const active = data.active || 'default';+ for (const p of profiles) {+ const opt = document.createElement('div');+ opt.className = 'profile-opt' + (p.name === active ? ' active' : '');+ const meta = [];+ if (p.model) meta.push(p.model.split('/').pop());+ if (p.skill_count) meta.push(p.skill_count + ' skills');+ const gwDot = `<span class="profile-opt-badge ${p.gateway_running ? 'running' : 'stopped'}"></span>`;+ const checkmark = p.name === active ? ' <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="var(--link)" stroke-width="3" style="vertical-align:-1px"><polyline points="20 6 9 17 4 12"/></svg>' : '';+ opt.innerHTML = `<div class="profile-opt-name">${gwDot}${esc(p.name)}${p.is_default ? ' <span style="opacity:.5;font-weight:400">(default)</span>' : ''}${checkmark}</div>` ++ (meta.length ? `<div class="profile-opt-meta">${esc(meta.join(' \u00b7 '))}</div>` : '');+ opt.onclick = async () => {+ closeProfileDropdown();+ if (p.name === active) return;+ await switchToProfile(p.name);+ };+ 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 = '⚙ Manage profiles';+ mgmt.onclick = () => { closeProfileDropdown(); switchPanel('profiles'); };+ dd.appendChild(mgmt);+}++function toggleProfileDropdown() {+ const dd = $('profileDropdown');@@ -26,6 +26,7 @@ # ── 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/.@@ -120,11 +121,24 @@ def _set_hermes_home(home: Path): def _reload_dotenv(home: Path):- """Load .env from the profile dir into os.environ (additive)."""+ """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().splitlines(): line = line.strip() if line and not line.startswith('#') and '=' in line:@@ -133,8 +147,10 @@ def _reload_dotenv(home: Path): 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:- pass+ _loaded_profile_env_keys = set() def init_profile_state() -> None:@@ -479,9 +479,6 @@ def apply_onboarding_setup(body: dict) -> dict: if api_key: _write_env_file(env_path, {provider_meta["env_var"]: api_key})- # Belt-and-braces: set directly on os.environ so the value is visible to- # any code in the same process that reads it before the next request cycle.- os.environ[provider_meta["env_var"]] = api_key # Reload the hermes_cli provider/config cache so the next streaming call # picks up the new key without requiring a server restart.@@ -491,6 +488,12 @@ def apply_onboarding_setup(body: dict) -> dict: except Exception: pass + # Belt-and-braces: set directly on os.environ AFTER _reload_dotenv so the+ # value survives even if _reload_dotenv cleared it (e.g. when _write_env_file+ # wrote to disk but the profile isolation tracking hasn't seen it yet).+ if api_key:+ os.environ[provider_meta["env_var"]] = api_key+ try: # hermes_cli may cache config at import time; ask it to reload if possible. from hermes_cli.config import reload as _cli_reloadAI-assisted change 75313680f9fa186d89334fac542966699273eec5f523e29f20f3d7f7c51a0bee · Fix ecadce21ab6c9aeffc07e4bedf4a338eb0de8899072c45555a2199e8e03ca00f