* fix: restore mobile chat scrolling and drawer close (#397) - static/style.css: add min-height:0 to .layout and .main (flex shrink chain fix for mobile scroll) - static/style.css: add -webkit-overflow-scrolling:touch, touch-action:pan-y, overscroll-behavior-y:contain to .messages - static/boot.js: call closeMobileSidebar() on new-conversation button onclick and Ctrl+K shortcut - tests/test_mobile_layout.py: 41 new lines covering all three CSS fixes and both JS call sites Original PR by @Jordan-SkyLF * fix: preserve imported session timestamps (#395) - api/models.py: add touch_updated_at: bool = True param to Session.save(); import_cli_session() accepts created_at/updated_at kwargs and saves with touch_updated_at=False - api/routes.py: extract created_at/updated_at from get_cli_sessions() metadata and forward to import_cli_session(); use touch_updated_at=False on post-import save - tests/test_gateway_sync.py: +53 lines — integration test verifying imported session keeps original timestamp and sorts correctly vs newer sessions; also fix: add WebUI session file cleanup in finally block Original PR by @Jordan-SkyLF * fix(profiles): block path traversal in profile switch and delete flows (#399) Master was vulnerable: switch_profile and delete_profile_api joined user-supplied profile names directly into filesystem paths with no validation. An attacker could send '../../etc/passwd' as a profile name to traverse outside the profiles directory. - api/profiles.py: add _resolve_named_profile_home(name) — validates name with ^[a-z0-9][a-z0-9_-]{0,63}$ regex then enforces path containment via candidate.resolve().relative_to(profiles_root); use in switch_profile() - api/profiles.py: add _validate_profile_name() call to delete_profile_api() entry - api/routes.py: add _validate_profile_name() call at HTTP handler level for both /api/profile/switch and /api/profile/delete (fail-fast at API boundary) - tests/test_profile_path_security.py: 3 tests — traversal rejected, valid name passes Cherry-picked commit aae7a30 from @Hinotoi-agent (PR was 62 commits behind master) * feat: add desktop microphone transcription fallback (#396) Mic button now works in browsers that support getUserMedia/MediaRecorder but lack SpeechRecognition (e.g. Firefox desktop, some Chromium builds). - static/boot.js: detect _canRecordAudio (navigator.mediaDevices + getUserMedia + MediaRecorder); keep mic button enabled when either SpeechRecognition or MediaRecorder is available; MediaRecorder fallback records audio, sends blob to /api/transcribe, inserts transcript into the composer; _stopMic() handles all three states (recognition, mediaRecorder, neither) - api/upload.py: add transcribe_audio() helper — saves uploaded blob to temp file, calls transcription_tools.transcribe_audio(), always cleans up temp file - api/routes.py: add /api/transcribe POST handler — CSRF protected, auth-gated, 20MB limit, returns {text:...} or {error:...} - api/helpers.py: change Permissions-Policy microphone=() to microphone=(self) (required to allow getUserMedia in the same origin) - tests/test_voice_transcribe_endpoint.py: 87 new lines — 3 tests with mocked transcription - tests/test_sprint19.py: +1 regression guard (microphone=(self) in Permissions-Policy) - tests/test_sprint20.py: 3 updated tests for new fallback-capability checks Original PR by @Jordan-SkyLF * docs: v0.50.25 release — version badge and CHANGELOG --------- Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
451 lines
16 KiB
Python
451 lines
16 KiB
Python
"""
|
|
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().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().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())
|
|
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}
|