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: