Merge pull request #45 from nesquena/fix/profile-creation-docker-fallback
fix: profile creation fallback for Docker (#44)
This commit is contained in:
29
CHANGELOG.md
29
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
|
## [v0.26] Profile System Polish -- 10 Post-Sprint-23 Fixes
|
||||||
*April 3, 2026 | 426 tests*
|
*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*
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Hermes Web UI -- Forward Sprint Plan
|
# 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:
|
> 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
|
> 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*
|
*Last updated: April 3, 2026*
|
||||||
*Current version: v0.26 | 426 tests*
|
*Current version: v0.27 | 426 tests*
|
||||||
*Next sprint: Sprint 24 (Desktop Application)*
|
*Next sprint: Sprint 24 (Desktop Application)*
|
||||||
|
|||||||
@@ -10,9 +10,19 @@ HERMES_HOME at import time.
|
|||||||
"""
|
"""
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
|
import re
|
||||||
|
import shutil
|
||||||
import threading
|
import threading
|
||||||
from pathlib import Path
|
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 ────────────────────────────────────────────────────────────
|
# ── Module state ────────────────────────────────────────────────────────────
|
||||||
_active_profile = 'default'
|
_active_profile = 'default'
|
||||||
_profile_lock = threading.Lock()
|
_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,
|
def create_profile_api(name: str, clone_from: str = None,
|
||||||
clone_config: bool = False) -> dict:
|
clone_config: bool = False) -> dict:
|
||||||
"""Create a new profile. Returns the new profile info 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:
|
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:
|
except ImportError:
|
||||||
raise RuntimeError('Profile management requires hermes-agent to be installed.')
|
_create_profile_fallback(name, clone_from, clone_config)
|
||||||
|
|
||||||
validate_profile_name(name)
|
# Find and return the newly created profile info.
|
||||||
create_profile(
|
# When hermes_cli is not importable, list_profiles_api() also falls back
|
||||||
name,
|
# to the stub default-only list and won't find the new profile by name.
|
||||||
clone_from=clone_from,
|
# In that case, return a complete profile dict directly.
|
||||||
clone_config=clone_config,
|
profile_path = _DEFAULT_HERMES_HOME / 'profiles' / name
|
||||||
clone_all=False,
|
|
||||||
no_alias=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Find and return the newly created profile info
|
|
||||||
for p in list_profiles_api():
|
for p in list_profiles_api():
|
||||||
if p['name'] == name:
|
if p['name'] == name:
|
||||||
return p
|
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:
|
def delete_profile_api(name: str) -> dict:
|
||||||
|
|||||||
@@ -8,8 +8,8 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
# Persist session data, settings, and projects across restarts
|
# Persist session data, settings, and projects across restarts
|
||||||
- hermes-data:/data
|
- hermes-data:/data
|
||||||
# Mount hermes-agent for full agent features (optional)
|
# Mount hermes home for agent features and profile management
|
||||||
- ${HERMES_HOME:-${HOME}/.hermes}:/root/.hermes:ro
|
- ${HERMES_HOME:-${HOME}/.hermes}:/root/.hermes
|
||||||
environment:
|
environment:
|
||||||
- HERMES_WEBUI_HOST=0.0.0.0
|
- HERMES_WEBUI_HOST=0.0.0.0
|
||||||
- HERMES_WEBUI_PORT=8787
|
- HERMES_WEBUI_PORT=8787
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
<body>
|
<body>
|
||||||
<div class="layout">
|
<div class="layout">
|
||||||
<aside class="sidebar">
|
<aside class="sidebar">
|
||||||
<div class="sidebar-header"><div class="logo">H</div><div><h1 style="margin:0;font-size:15px;font-weight:700;letter-spacing:-.01em">Hermes</h1><div style="font-size:10px;color:var(--muted);opacity:.8;margin-top:1px">v0.26</div></div></div>
|
<div class="sidebar-header"><div class="logo">H</div><div><h1 style="margin:0;font-size:15px;font-weight:700;letter-spacing:-.01em">Hermes</h1><div style="font-size:10px;color:var(--muted);opacity:.8;margin-top:1px">v0.27</div></div></div>
|
||||||
<div class="sidebar-nav">
|
<div class="sidebar-nav">
|
||||||
<button class="nav-tab active" data-panel="chat" data-label="Chat" onclick="switchPanel('chat')" title="Chat">💬</button>
|
<button class="nav-tab active" data-panel="chat" data-label="Chat" onclick="switchPanel('chat')" title="Chat">💬</button>
|
||||||
<button class="nav-tab" data-panel="tasks" data-label="Tasks" onclick="switchPanel('tasks')" title="Tasks">📅</button>
|
<button class="nav-tab" data-panel="tasks" data-label="Tasks" onclick="switchPanel('tasks')" title="Tasks">📅</button>
|
||||||
|
|||||||
Reference in New Issue
Block a user