diff --git a/CHANGELOG.md b/CHANGELOG.md index 5548aa4..939b4d8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,33 @@ --- +## [v0.27] Profile Creation Fallback for Docker (Issue #44) +*April 3, 2026 | 426 tests* + +### Bug Fixes +- **Profile creation works without hermes-agent.** In Docker containers where + `hermes_cli` is not importable, profile creation now falls back to a local + implementation that creates the directory structure and optionally clones + config files. Previously returned `RuntimeError` with "hermes-agent required". +- **Name validation uses `fullmatch()`.** Prevents trailing-newline bypass of + the `$` anchor in `re.match()`. Not reachable from the web UI (name is + stripped), but fixed for defense-in-depth. +- **`clone_from` validated in `create_profile_api()`.** Defense-in-depth: + prevents path traversal if called by a non-HTTP client. +- **Fallback return uses full 9-key schema.** Previously returned only 2 keys + (`name`, `path`), inconsistent with the normal response shape. +- **Atomic directory creation.** `mkdir(exist_ok=False)` prevents TOCTOU race + on concurrent profile creates. + +### Architecture +- `api/profiles.py`: `_validate_profile_name()`, `_create_profile_fallback()`, + `_PROFILE_ID_RE`, `_PROFILE_DIRS`, `_CLONE_CONFIG_FILES` constants matching + upstream `hermes_cli.profiles`. +- `docker-compose.yml`: Removed `:ro` from `~/.hermes` mount (required for + profile writes). Localhost-only binding preserved. + +--- + ## [v0.26] Profile System Polish -- 10 Post-Sprint-23 Fixes *April 3, 2026 | 426 tests* @@ -877,4 +904,4 @@ Three-panel layout: sessions sidebar, chat area, workspace panel. --- -*Last updated: v0.26, April 3, 2026 | Tests: 426* +*Last updated: v0.27, April 3, 2026 | Tests: 426* diff --git a/SPRINTS.md b/SPRINTS.md index 6d104c2..e50b6c6 100644 --- a/SPRINTS.md +++ b/SPRINTS.md @@ -1,6 +1,6 @@ # Hermes Web UI -- Forward Sprint Plan -> Current state: v0.26 | 426 tests | Daily driver ready +> Current state: v0.27 | 426 tests | Daily driver ready > This document plans the path from here to two targets: > > Target A: 1:1 feature parity with the Hermes CLI (everything you can do from the @@ -663,5 +663,5 @@ and switchToProfile() didn't refresh workspaces or sessions. --- *Last updated: April 3, 2026* -*Current version: v0.26 | 426 tests* +*Current version: v0.27 | 426 tests* *Next sprint: Sprint 24 (Desktop Application)* diff --git a/api/profiles.py b/api/profiles.py index f74cd91..3f393a0 100644 --- a/api/profiles.py +++ b/api/profiles.py @@ -10,9 +10,19 @@ 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() @@ -245,28 +255,85 @@ def _default_profile_dict() -> dict: } +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 _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 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) + # 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, validate_profile_name + 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: - raise RuntimeError('Profile management requires hermes-agent to be installed.') + _create_profile_fallback(name, clone_from, clone_config) - validate_profile_name(name) - create_profile( - name, - clone_from=clone_from, - clone_config=clone_config, - clone_all=False, - no_alias=True, - ) - - # Find and return the newly created profile info + # 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 - return {'name': name, 'path': str(_DEFAULT_HERMES_HOME / 'profiles' / name)} + 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: diff --git a/docker-compose.yml b/docker-compose.yml index 82a1f51..2b4fded 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -8,8 +8,8 @@ services: volumes: # Persist session data, settings, and projects across restarts - hermes-data:/data - # Mount hermes-agent for full agent features (optional) - - ${HERMES_HOME:-${HOME}/.hermes}:/root/.hermes:ro + # Mount hermes home for agent features and profile management + - ${HERMES_HOME:-${HOME}/.hermes}:/root/.hermes environment: - HERMES_WEBUI_HOST=0.0.0.0 - HERMES_WEBUI_PORT=8787 diff --git a/static/index.html b/static/index.html index 1bd22f9..4903dce 100644 --- a/static/index.html +++ b/static/index.html @@ -13,7 +13,7 @@