From 16553be59d6ec4e1fbdadce2877b1a437f0528ae Mon Sep 17 00:00:00 2001 From: Nathan Esquenazi Date: Fri, 3 Apr 2026 13:00:28 -0700 Subject: [PATCH 1/3] fix: profile creation fallback when hermes_cli unavailable (Docker) When hermes-agent is not discoverable (common in Docker), create_profile_api() raised a hard RuntimeError while list and delete already had manual fallbacks. Changes: - Add _create_profile_fallback() that bootstraps profile directory structure directly (matching upstream hermes_cli.profiles: 8 subdirs + config clone) - Extract _validate_profile_name() so validation works without hermes_cli - Add constants _PROFILE_ID_RE, _PROFILE_DIRS, _CLONE_CONFIG_FILES matching upstream hermes-agent - Remove :ro from docker-compose.yml hermes home mount so profiles dir is writable inside the container Closes #44 Co-Authored-By: Claude Opus 4.6 (1M context) --- api/profiles.py | 72 ++++++++++++++++++++++++++++++++++++++-------- docker-compose.yml | 4 +-- 2 files changed, 62 insertions(+), 14 deletions(-) diff --git a/api/profiles.py b/api/profiles.py index f74cd91..579ad78 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,22 +255,60 @@ 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.") + if not _PROFILE_ID_RE.match(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 + profile_dir.mkdir(parents=True, exist_ok=True) + 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.""" - try: - from hermes_cli.profiles import create_profile, validate_profile_name - except ImportError: - raise RuntimeError('Profile management requires hermes-agent to be installed.') + _validate_profile_name(name) - validate_profile_name(name) - create_profile( - name, - clone_from=clone_from, - clone_config=clone_config, - clone_all=False, - no_alias=True, - ) + 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) # Find and return the newly created profile info for p in list_profiles_api(): 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 From e6663596ce5001bdae2d26e377b7a3976a6dda05 Mon Sep 17 00:00:00 2001 From: Nathan Esquenazi Date: Fri, 3 Apr 2026 20:52:37 +0000 Subject: [PATCH 2/3] fix(review): 4 issues found in agent review of PR #45 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BUG-1 (medium): _validate_profile_name() used re.match() with a $ anchor. re.match() with $ is truthy for 'name\n' because match() allows trailing content after the $ in multiline mode. Changed to re.fullmatch() which requires the entire string to match — trailing newlines now correctly rejected. BUG-2 (medium/defense-in-depth): create_profile_api() validated 'name' via _validate_profile_name() but passed clone_from directly to hermes_cli and _create_profile_fallback() without validation. Added clone_from validation inside create_profile_api() (skipping 'default' which is a valid clone source). routes.py already validates it at the HTTP layer; this adds API-layer defense. BUG-3 (low): When hermes_cli is not importable (the exact Docker case this PR targets), list_profiles_api() also returns only the stub default dict and can't find the newly created profile by name. The fallback return was a 2-key dict {name, path} — incomplete vs the 9-key schema everywhere else. Expanded to the full profile dict with all fields so API clients get consistent data regardless of hermes_cli availability. OBS-4 (low/TOCTOU): _create_profile_fallback() checked profile_dir.exists() then called mkdir(exist_ok=True). If a concurrent request created the dir between those two calls, mkdir silently succeeded — defeating the FileExistsError guard. Changed to mkdir(exist_ok=False) so the OS raises FileExistsError atomically if the dir appears in the race window. Tests: 423 passed, 0 failed. --- api/profiles.py | 29 ++++++++++++++++++++++++----- 1 file changed, 24 insertions(+), 5 deletions(-) diff --git a/api/profiles.py b/api/profiles.py index 579ad78..3f393a0 100644 --- a/api/profiles.py +++ b/api/profiles.py @@ -259,7 +259,8 @@ 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.") - if not _PROFILE_ID_RE.match(name): + # 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}" @@ -273,8 +274,8 @@ def _create_profile_fallback(name: str, clone_from: str = None, if profile_dir.exists(): raise FileExistsError(f"Profile '{name}' already exists.") - # Bootstrap directory structure - profile_dir.mkdir(parents=True, exist_ok=True) + # 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) @@ -297,6 +298,10 @@ 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 @@ -310,11 +315,25 @@ def create_profile_api(name: str, clone_from: str = None, except ImportError: _create_profile_fallback(name, clone_from, clone_config) - # 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: From 94b080fa1ec3a37bec43f4ee292de2b44bbd5b36 Mon Sep 17 00:00:00 2001 From: Nathan Esquenazi Date: Fri, 3 Apr 2026 14:00:46 -0700 Subject: [PATCH 3/3] docs: v0.27 release notes, version bump for profile creation fallback Co-Authored-By: Claude Opus 4.6 (1M context) --- CHANGELOG.md | 29 ++++++++++++++++++++++++++++- SPRINTS.md | 4 ++-- static/index.html | 2 +- 3 files changed, 31 insertions(+), 4 deletions(-) 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/static/index.html b/static/index.html index 1bd22f9..4903dce 100644 --- a/static/index.html +++ b/static/index.html @@ -13,7 +13,7 @@