2241 lines
87 KiB
Python
2241 lines
87 KiB
Python
"""Profile management for multiple isolated Hermes instances."""
|
|
|
|
import json
|
|
import logging
|
|
import os
|
|
import re
|
|
import shlex
|
|
import shutil
|
|
import stat
|
|
import subprocess
|
|
import sys
|
|
import time
|
|
from dataclasses import dataclass
|
|
from pathlib import Path
|
|
from typing import Dict, List, Optional, Tuple
|
|
|
|
from agent.skill_utils import is_excluded_skill_path
|
|
from hermes_cli.archive_safe import (
|
|
archive_root_dirs,
|
|
make_targz,
|
|
normalize_archive_parts,
|
|
safe_extract_targz,
|
|
)
|
|
from hermes_constants import (
|
|
clear_named_profile_deleted,
|
|
mark_named_profile_deleted,
|
|
named_profile_is_deleted,
|
|
)
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
_PROFILE_ID_RE = re.compile(r"^[a-z0-9][a-z0-9_-]{0,63}$")
|
|
_WARNED_MISSING_ALLOWLIST_ENTRIES: set[tuple[str, ...]] = set()
|
|
|
|
# Directories bootstrapped inside every new profile
|
|
_PROFILE_DIRS = [
|
|
"memories",
|
|
"sessions",
|
|
"skills",
|
|
"skins",
|
|
"logs",
|
|
"plans",
|
|
"workspace",
|
|
"cron",
|
|
# Back-compat/Docker HOME for tool subprocesses. Host subprocesses keep
|
|
# the user's real HOME by default so normal CLI credentials remain visible;
|
|
# containers still use this directory for persistent HOME state.
|
|
# See hermes_constants.get_subprocess_home().
|
|
"home",
|
|
]
|
|
|
|
# Files copied during --clone (if they exist in the source)
|
|
_CLONE_CONFIG_FILES = [
|
|
"config.yaml",
|
|
".env",
|
|
"SOUL.md",
|
|
]
|
|
|
|
# Subdirectory files copied during --clone (path relative to profile root).
|
|
# Memory files are part of the agent's curated identity — just as important
|
|
# as SOUL.md for continuity when cloning a profile.
|
|
_CLONE_SUBDIR_FILES = [
|
|
"memories/MEMORY.md",
|
|
"memories/USER.md",
|
|
]
|
|
|
|
# Runtime files stripped after --clone-all (shouldn't carry over).
|
|
# Kept as a post-copy step rather than in the ignore filter because they
|
|
# are created dynamically during normal use and may be absent at copy time.
|
|
_CLONE_ALL_STRIP: list[str] = [
|
|
"gateway.pid",
|
|
"gateway_state.json",
|
|
"processes.json",
|
|
]
|
|
|
|
# Infrastructure artifacts excluded from --clone-all when the source is the
|
|
# default profile (``~/.hermes``). Named profiles never contain these
|
|
# directories at root, so the exclusion is gated to avoid silently dropping
|
|
# user data from a named-profile source.
|
|
#
|
|
# Rationale per item:
|
|
# hermes-agent — git repo checkout (~84 MB source + ~3 GB venv)
|
|
# .worktrees — git worktrees
|
|
# profiles — sibling named profiles (recursive copy never intended)
|
|
# bin — installed binaries (tirith etc., ~10 MB) shared per-host
|
|
# node_modules — npm packages (hundreds of MB)
|
|
#
|
|
# Export uses a root allow-list instead (``_DEFAULT_EXPORT_INCLUDE_ROOT``): the
|
|
# archive is a portable snapshot, while a clone must keep working immediately.
|
|
_CLONE_ALL_DEFAULT_EXCLUDE_ROOT: frozenset[str] = frozenset({
|
|
"hermes-agent",
|
|
".worktrees",
|
|
"profiles",
|
|
"bin",
|
|
"node_modules",
|
|
})
|
|
|
|
# Per-profile history artifacts excluded from --clone-all regardless of the
|
|
# source profile. A new profile is a fresh workspace — inheriting the source
|
|
# profile's session history, backup archives, or quick-backup snapshots is
|
|
# never useful (restoring one inside the clone would resurrect the SOURCE
|
|
# profile's state) and can balloon the copy by tens of GB. Unlike
|
|
# ``_CLONE_ALL_DEFAULT_EXCLUDE_ROOT`` this set is NOT gated on the default
|
|
# profile: named profiles accumulate the same artifacts.
|
|
#
|
|
# Rationale per item:
|
|
# state.db (+wal/shm) — SQLite session store (can reach many GB)
|
|
# sessions — per-session transcript/data dirs
|
|
# backups — `hermes backup` archives
|
|
# state-snapshots — quick-backup snapshot trees
|
|
# checkpoints — session checkpoint data
|
|
_CLONE_ALL_HISTORY_EXCLUDE_ROOT: frozenset[str] = frozenset({
|
|
"state.db",
|
|
"state.db-wal",
|
|
"state.db-shm",
|
|
"sessions",
|
|
"backups",
|
|
"state-snapshots",
|
|
"checkpoints",
|
|
})
|
|
|
|
# Marker file written by `hermes profile create --no-skills`. When present in
|
|
# a profile's root, callers of seed_profile_skills() (fresh-create, `hermes
|
|
# update`'s all-profile sync, the web dashboard) skip bundled-skill seeding
|
|
# for that profile. The user can still install skills manually via
|
|
# `hermes skills install` or drop SKILL.md files into the profile's skills/.
|
|
# Delete the marker file to opt back in.
|
|
NO_BUNDLED_SKILLS_MARKER = ".no-bundled-skills"
|
|
|
|
# Header seeded into a profile's empty .env so it owns a credentials file from day one.
|
|
_PLACEHOLDER_ENV = (
|
|
"# Per-profile secrets for this Hermes profile.\n"
|
|
"# API keys and tokens set here override the shell environment.\n"
|
|
"# Behavioral settings belong in config.yaml, not here.\n"
|
|
)
|
|
|
|
|
|
def _clone_all_copytree_ignore(source_dir: Path):
|
|
"""Exclude infrastructure artifacts when cloning a profile via --clone-all.
|
|
|
|
Three categories: 1. Root-level entries in ``_CLONE_ALL_HISTORY_EXCLUDE_ROOT`` — session
|
|
history, backups, and snapshots that belong to the SOURCE profile and should never carry into a
|
|
fresh clone. Applies to any source. 2.
|
|
|
|
The export-side ignore (``_default_export_ignore``) uses a root-level allow-list instead
|
|
because the export archive is a portable snapshot rather than a live clone.
|
|
"""
|
|
source_resolved = source_dir.resolve()
|
|
is_default_source = source_resolved == _get_default_hermes_home().resolve()
|
|
|
|
# History artifacts are excluded for ANY source; infrastructure only
|
|
# when the source is the default profile (named profiles never have it).
|
|
root_exclude = set(_CLONE_ALL_HISTORY_EXCLUDE_ROOT)
|
|
if is_default_source:
|
|
root_exclude |= _CLONE_ALL_DEFAULT_EXCLUDE_ROOT
|
|
|
|
def _ignore(directory: str, names: List[str]) -> List[str]:
|
|
try:
|
|
at_root = Path(directory).resolve() == source_resolved
|
|
except (OSError, ValueError):
|
|
# ``resolve()`` can fail on unusual FS layouts (broken
|
|
# symlinks, missing parents). Fail open — better to
|
|
# over-copy than silently drop user data.
|
|
at_root = False
|
|
return [
|
|
entry for entry in names
|
|
# Universal exclusions at any depth.
|
|
if entry == "__pycache__"
|
|
or entry.endswith((".pyc", ".pyo", ".sock", ".tmp"))
|
|
or (at_root and entry in root_exclude)
|
|
]
|
|
|
|
return _ignore
|
|
|
|
|
|
# Allow-list for ``export_profile("default")``: when HERMES_HOME equals the
|
|
# cwd (Docker/custom deployments), the default profile home is the working
|
|
# directory and contains arbitrary user files that should NOT be bundled
|
|
# into the export. The set below identifies the *known Hermes profile
|
|
# artifacts* at the root of HERMES_HOME; everything else is excluded.
|
|
# Sensitive runtime infrastructure (``state.db``, ``logs/``, ``auth.*``,
|
|
# other profiles) is intentionally *not* in this list so the export stays
|
|
# a portable, credential-free snapshot of the user-facing surface
|
|
# (#58394). Add new artifacts here when introduced in ``hermes_constants``.
|
|
_DEFAULT_EXPORT_INCLUDE_ROOT = frozenset({
|
|
# Configuration / persona
|
|
"config.yaml", "SOUL.md", "MEMORY.md", "USER.md", "todo.json",
|
|
"system_prompt.md", "AGENTS.md", "CLAUDE.md", ".cursorrules",
|
|
# Desktop appearance/interface overlay (written by the desktop app's
|
|
# profile export; applied by its import — see desktop.json handling).
|
|
"desktop.json",
|
|
# User-facing skill, cron, and session artifacts
|
|
"skills", "cron", "scripts", "sessions",
|
|
# Plugin / memory surfaces (per-profile overrides live here)
|
|
"plugins", "memories", "knowledge", "preferences",
|
|
})
|
|
|
|
# Names that cannot be used as profile aliases
|
|
_RESERVED_NAMES = frozenset({
|
|
"hermes", "default", "test", "tmp", "root", "sudo",
|
|
})
|
|
|
|
# Hermes subcommands that cannot be used as profile names/aliases
|
|
_HERMES_SUBCOMMANDS = frozenset({
|
|
"chat", "model", "gateway", "setup", "whatsapp", "login", "logout",
|
|
"status", "cron", "doctor", "dump", "config", "pairing", "skills", "tools",
|
|
"mcp", "sessions", "insights", "version", "update", "uninstall",
|
|
"profile", "plugins", "honcho", "acp",
|
|
})
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Path helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def _get_profiles_root() -> Path:
|
|
"""Return the directory where named profiles are stored.
|
|
|
|
Anchored to the hermes root, NOT to the current HERMES_HOME (which may itself be a profile).
|
|
This ensures ``coder profile list`` can see all profiles.
|
|
"""
|
|
return _get_default_hermes_home() / "profiles"
|
|
|
|
|
|
def _get_default_hermes_home() -> Path:
|
|
"""Return the default (pre-profile) HERMES_HOME path.
|
|
|
|
Normally ``~/.hermes``; in Docker/custom deployments where HERMES_HOME lives elsewhere
|
|
(e.g. ``/opt/data``) returns HERMES_HOME itself.
|
|
"""
|
|
from hermes_constants import get_default_hermes_root
|
|
return get_default_hermes_root()
|
|
|
|
|
|
def _get_active_profile_path() -> Path:
|
|
"""Return the path to the sticky active_profile file."""
|
|
return _get_default_hermes_home() / "active_profile"
|
|
|
|
|
|
def _get_wrapper_dir() -> Path:
|
|
"""Return the directory for wrapper scripts."""
|
|
return Path.home() / ".local" / "bin"
|
|
|
|
|
|
def _wrapper_path(alias: str) -> Path:
|
|
"""Wrapper script path for *alias*: ``<alias>.bat`` on Windows, bare name elsewhere."""
|
|
return _get_wrapper_dir() / (f"{alias}.bat" if sys.platform == "win32" else alias)
|
|
|
|
|
|
def _is_our_wrapper(path: Path) -> bool:
|
|
"""True when *path* reads as a Hermes-generated wrapper (contains ``hermes -p``)."""
|
|
try:
|
|
return "hermes -p" in path.read_text(encoding="utf-8")
|
|
except Exception:
|
|
return False
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Validation
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def normalize_profile_name(name: str) -> str:
|
|
"""Return the canonical profile id used on disk and in CLI ``-p`` argv.
|
|
|
|
Named profiles are stored lowercase under ``profiles/<id>/``; ``default`` matches
|
|
case-insensitively. Dashboards/tools may pass title-cased labels, so normalize before
|
|
validation, assignment, and subprocess spawn.
|
|
"""
|
|
if not isinstance(name, str):
|
|
name = str(name)
|
|
stripped = name.strip()
|
|
if not stripped:
|
|
raise ValueError("profile name cannot be empty")
|
|
if stripped.casefold() == "default":
|
|
return "default"
|
|
return stripped.lower()
|
|
|
|
|
|
def validate_profile_name(name: str) -> None:
|
|
"""Raise ``ValueError`` if *name* is not a valid profile identifier.
|
|
|
|
Strict lowercase match as-given -- callers taking mixed-case user input must call
|
|
``normalize_profile_name`` first, so this stays honest about the on-disk directory name.
|
|
Also rejects ``_RESERVED_NAMES`` (``hermes``, ``test``, ``tmp``, ``root``, ``sudo``) that
|
|
would collide on disk or be refused at alias creation; ``default`` passes through.
|
|
"""
|
|
if name == "default":
|
|
return # special alias for ~/.hermes
|
|
if not _PROFILE_ID_RE.match(name):
|
|
raise ValueError(
|
|
f"Invalid profile name {name!r}. Must match "
|
|
f"[a-z0-9][a-z0-9_-]{{0,63}}"
|
|
)
|
|
if name in _RESERVED_NAMES:
|
|
raise ValueError(
|
|
f"Profile name {name!r} is reserved — it collides with either "
|
|
f"the Hermes installation itself or a common system binary. "
|
|
f"Pick a different name."
|
|
)
|
|
|
|
|
|
def validate_alias_name(name: str) -> None:
|
|
"""Raise ``ValueError`` if *name* is not a safe wrapper-alias identifier.
|
|
|
|
The alias is used verbatim as a filename under :func:`_get_wrapper_dir` (``~/.local/bin``), so
|
|
it must be a single safe command name with no path separators or traversal segments — otherwise
|
|
a value like ``../../.bashrc`` would escape the wrapper directory and clobber arbitrary user
|
|
files.
|
|
"""
|
|
if not _PROFILE_ID_RE.match(name):
|
|
raise ValueError(
|
|
f"Invalid alias name {name!r}. Must match "
|
|
f"[a-z0-9][a-z0-9_-]{{0,63}}"
|
|
)
|
|
|
|
|
|
def get_profile_dir(name: str) -> Path:
|
|
"""Resolve a profile name to its HERMES_HOME directory."""
|
|
canon = normalize_profile_name(name)
|
|
if canon == "default":
|
|
return _get_default_hermes_home()
|
|
return _get_profiles_root() / canon
|
|
|
|
|
|
def profile_exists(name: str) -> bool:
|
|
"""Check whether a live (non-tombstoned) profile directory exists."""
|
|
canon = normalize_profile_name(name)
|
|
if canon == "default":
|
|
return True
|
|
profile_dir = get_profile_dir(canon)
|
|
return profile_dir.is_dir() and not named_profile_is_deleted(profile_dir)
|
|
|
|
|
|
def profile_matches_home(name: str, home: "Path | None" = None) -> bool:
|
|
"""Return True when *name* refers to the profile served from *home*.
|
|
|
|
Lets single-profile gateways decide whether a ``/p/<profile>/`` URL prefix is
|
|
self-referential (safe on the bare route) or names a different profile, which must fail
|
|
closed rather than silently resolve the owner's config. Invalid names return False.
|
|
"""
|
|
try:
|
|
target = get_profile_dir(name)
|
|
if home is None:
|
|
from hermes_constants import get_hermes_home
|
|
|
|
home = get_hermes_home()
|
|
return (
|
|
Path(target).expanduser().resolve(strict=False)
|
|
== Path(home).expanduser().resolve(strict=False)
|
|
)
|
|
except Exception:
|
|
return False
|
|
|
|
|
|
def _iter_named_profile_dirs(*, live_only: bool = True) -> List[Path]:
|
|
"""Sorted named-profile dirs under the profiles root (valid ids, never ``default``).
|
|
|
|
``live_only`` additionally skips tombstoned (deleted) profiles.
|
|
"""
|
|
profiles_root = _get_profiles_root()
|
|
if not profiles_root.is_dir():
|
|
return []
|
|
return [
|
|
entry for entry in sorted(profiles_root.iterdir())
|
|
if entry.is_dir()
|
|
and entry.name != "default"
|
|
and _PROFILE_ID_RE.match(entry.name)
|
|
and not (live_only and named_profile_is_deleted(entry))
|
|
]
|
|
|
|
|
|
def list_profile_names() -> List[str]:
|
|
"""Cheap name-only profile listing: ``default`` plus profile dirs.
|
|
|
|
Unlike :func:`list_profiles` this reads NO per-profile config/metadata — it is a directory scan,
|
|
safe to call from hot paths (cron delivery-target listings, create-time validation).
|
|
"""
|
|
names = ["default"]
|
|
try:
|
|
names.extend(entry.name for entry in _iter_named_profile_dirs(live_only=False))
|
|
except OSError:
|
|
pass
|
|
return names
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Alias / wrapper script management
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def check_alias_collision(name: str) -> Optional[str]:
|
|
"""Return a human-readable collision message, or None if the name is safe."""
|
|
canon = normalize_profile_name(name)
|
|
try:
|
|
validate_alias_name(canon)
|
|
except ValueError as exc:
|
|
return str(exc)
|
|
if canon in _RESERVED_NAMES:
|
|
return f"'{canon}' is a reserved name"
|
|
if canon in _HERMES_SUBCOMMANDS:
|
|
return f"'{canon}' conflicts with a hermes subcommand"
|
|
|
|
# Check existing commands in PATH
|
|
try:
|
|
result = subprocess.run(
|
|
["where" if sys.platform == "win32" else "which", canon],
|
|
capture_output=True, text=True, encoding='utf-8', errors='replace', timeout=5,
|
|
)
|
|
if result.returncode == 0:
|
|
existing_path = result.stdout.strip().splitlines()[0]
|
|
# Allow overwriting our own wrappers
|
|
expected = _wrapper_path(canon)
|
|
if existing_path == str(expected) and _is_our_wrapper(expected):
|
|
return None # it's our wrapper, safe to overwrite
|
|
return f"'{canon}' conflicts with an existing command ({existing_path})"
|
|
except (FileNotFoundError, subprocess.TimeoutExpired):
|
|
pass
|
|
|
|
return None # safe
|
|
|
|
|
|
def _is_wrapper_dir_in_path() -> bool:
|
|
"""Check if ~/.local/bin is in PATH."""
|
|
wrapper_dir = str(_get_wrapper_dir())
|
|
return wrapper_dir in os.environ.get("PATH", "").split(os.pathsep)
|
|
|
|
|
|
def create_wrapper_script(name: str, target: Optional[str] = None) -> Optional[Path]:
|
|
"""Create a shell wrapper script at ~/.local/bin/<name>.
|
|
|
|
The wrapper file is named after ``name`` (the alias). The profile it activates is ``target`` if
|
|
given, otherwise ``name`` — this lets a custom alias name point at a differently-named profile
|
|
without a post-hoc rewrite.
|
|
"""
|
|
canon = normalize_profile_name(name)
|
|
profile = normalize_profile_name(target) if target else canon
|
|
# The alias is used verbatim as a filename under the wrapper dir; reject
|
|
# any value that isn't a single safe identifier so it can't traverse out.
|
|
validate_alias_name(canon)
|
|
wrapper_dir = _get_wrapper_dir()
|
|
try:
|
|
wrapper_dir.mkdir(parents=True, exist_ok=True)
|
|
except OSError as e:
|
|
print(f"⚠ Could not create {wrapper_dir}: {e}")
|
|
return None
|
|
|
|
wrapper_path = _wrapper_path(canon)
|
|
try:
|
|
if sys.platform == "win32":
|
|
wrapper_path.write_text(f"@echo off\r\nhermes -p {profile} %*\r\n", encoding="utf-8")
|
|
else:
|
|
hermes_exe = shutil.which("hermes") or "hermes"
|
|
wrapper_path.write_text(f'#!/bin/sh\nexec {shlex.quote(hermes_exe)} -p {profile} "$@"\n', encoding="utf-8")
|
|
wrapper_path.chmod(wrapper_path.stat().st_mode | stat.S_IEXEC | stat.S_IXGRP | stat.S_IXOTH)
|
|
return wrapper_path
|
|
except OSError as e:
|
|
print(f"⚠ Could not create wrapper at {wrapper_path}: {e}")
|
|
return None
|
|
|
|
|
|
def remove_wrapper_script(name: str) -> bool:
|
|
"""Remove the wrapper script for a profile. Returns True if removed."""
|
|
canon = normalize_profile_name(name)
|
|
# A traversal-shaped name could point unlink() at a file outside the
|
|
# wrapper dir; refuse it rather than acting on an arbitrary path.
|
|
try:
|
|
validate_alias_name(canon)
|
|
except ValueError:
|
|
return False
|
|
|
|
# Check both the extensionless path (POSIX) and .bat (Windows)
|
|
candidates = [_get_wrapper_dir() / canon]
|
|
if sys.platform == "win32":
|
|
candidates.insert(0, _get_wrapper_dir() / f"{canon}.bat")
|
|
|
|
for wrapper_path in candidates:
|
|
# Verify it's our wrapper before removing
|
|
if wrapper_path.exists() and _is_our_wrapper(wrapper_path):
|
|
try:
|
|
wrapper_path.unlink()
|
|
return True
|
|
except Exception:
|
|
pass
|
|
return False
|
|
|
|
|
|
def _migrate_profile_config_if_outdated(profile_dir: Path) -> None:
|
|
"""Bring a copied profile config.yaml up to the current schema.
|
|
|
|
A cloned config may predate schema tracking or be older than the running Hermes; left alone,
|
|
the first desktop/doctor view of the new profile shows a scary ``v0 -> latest`` warning.
|
|
Runs the normal migration pipeline scoped to the new profile, non-interactively.
|
|
"""
|
|
config_path = profile_dir / "config.yaml"
|
|
if not config_path.exists():
|
|
return
|
|
|
|
try:
|
|
from hermes_constants import reset_hermes_home_override, set_hermes_home_override
|
|
from hermes_cli.config import check_config_version, migrate_config
|
|
|
|
token = set_hermes_home_override(str(profile_dir))
|
|
try:
|
|
current_ver, latest_ver = check_config_version()
|
|
if current_ver < latest_ver:
|
|
migrate_config(interactive=False, quiet=True)
|
|
finally:
|
|
reset_hermes_home_override(token)
|
|
except Exception:
|
|
# Profile creation should not fail because an old copied config could
|
|
# not be migrated. The next `hermes doctor --fix` can still surface the
|
|
# detailed error in the target profile.
|
|
pass
|
|
|
|
|
|
def find_alias_for_profile(profile_name: str) -> Optional[str]:
|
|
"""Return the alias name of the wrapper that activates *profile_name*, or None.
|
|
|
|
A wrapper created by :func:`create_wrapper_script` is a file named after the alias whose body
|
|
invokes ``hermes -p <profile>``.
|
|
|
|
For listing ALL profiles at once, prefer :func:`build_alias_map` — calling this per-profile re-
|
|
reads every wrapper file N times (O(N*M)); on a wrapper dir like ``~/.local/bin`` that also
|
|
holds large unrelated binaries (ffmpeg etc.) that meant multi-second ``list_profiles`` latency
|
|
and desktop timeouts.
|
|
"""
|
|
return build_alias_map().get(normalize_profile_name(profile_name))
|
|
|
|
|
|
# Cap how much of a wrapper file we read when reverse-looking-up its profile.
|
|
# Real wrappers are a few hundred bytes of shell; the needle (``hermes -p X``)
|
|
# sits near the top. The wrapper dir (e.g. ``~/.local/bin``) commonly also holds
|
|
# large unrelated binaries (ffmpeg, node, …) — reading those whole, N times, was
|
|
# the dominant cost in ``list_profiles`` (~4.5s). Reading a small head slice and
|
|
# skipping NUL-bearing (binary) content keeps the scan to a single cheap pass.
|
|
_WRAPPER_READ_LIMIT = 8192
|
|
|
|
|
|
def build_alias_map() -> dict[str, str]:
|
|
"""Single-pass reverse map ``{canonical_profile -> alias_name}``.
|
|
|
|
Scans the wrapper dir ONCE (vs. :func:`find_alias_for_profile` per profile) and reads only a
|
|
small head slice of each candidate wrapper, skipping binaries. A custom alias (file name !=
|
|
profile) wins over the profile-named wrapper, matching ``find_alias_for_profile``'s preference;
|
|
deterministic via sorted iteration.
|
|
"""
|
|
wrapper_dir = _get_wrapper_dir()
|
|
result: dict[str, str] = {}
|
|
if not wrapper_dir.is_dir():
|
|
return result
|
|
is_windows = sys.platform == "win32"
|
|
prefix = "hermes -p "
|
|
|
|
for entry in sorted(wrapper_dir.iterdir()):
|
|
if not entry.is_file():
|
|
continue
|
|
# Only our own wrappers are named with the alias and (on Windows) .bat.
|
|
if is_windows and entry.suffix != ".bat":
|
|
continue
|
|
if not is_windows and entry.suffix:
|
|
continue
|
|
try:
|
|
with open(entry, "r", encoding="utf-8", errors="strict") as f:
|
|
content = f.read(_WRAPPER_READ_LIMIT)
|
|
except (OSError, UnicodeDecodeError):
|
|
# UnicodeDecodeError = a binary on PATH (ffmpeg etc.) — not a wrapper.
|
|
continue
|
|
idx = content.find(prefix)
|
|
if idx == -1:
|
|
continue
|
|
rest = content[idx + len(prefix):]
|
|
# Profile id is the first whitespace-delimited token after the flag.
|
|
canon = rest.split(None, 1)[0].strip() if rest.strip() else ""
|
|
if not canon:
|
|
continue
|
|
canon = normalize_profile_name(canon)
|
|
alias = entry.stem if is_windows else entry.name
|
|
# Custom alias (name != profile) preferred; otherwise keep the
|
|
# profile-named wrapper. Don't overwrite a custom alias already found.
|
|
if alias == canon:
|
|
result.setdefault(canon, alias)
|
|
else:
|
|
result[canon] = alias
|
|
return result
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# ProfileInfo
|
|
# ---------------------------------------------------------------------------
|
|
|
|
@dataclass
|
|
class ProfileInfo:
|
|
"""Summary information about a profile."""
|
|
name: str
|
|
path: Path
|
|
is_default: bool
|
|
gateway_running: bool
|
|
model: Optional[str] = None
|
|
provider: Optional[str] = None
|
|
has_env: bool = False
|
|
skill_count: int = 0
|
|
alias_path: Optional[Path] = None
|
|
# Custom alias name (the wrapper file name) when it differs from ``name``;
|
|
# falls back to ``name`` when a profile-named wrapper exists. None if no
|
|
# wrapper points at this profile. See ``find_alias_for_profile``.
|
|
alias_name: Optional[str] = None
|
|
# Distribution metadata (None if the profile wasn't installed from a distribution).
|
|
distribution_name: Optional[str] = None
|
|
distribution_version: Optional[str] = None
|
|
distribution_source: Optional[str] = None
|
|
# Free-form description (1-2 sentences) of what this profile is good
|
|
# at. Persisted in ``<profile_dir>/profile.yaml``. Empty when the
|
|
# user has not described the profile (legacy profiles, fresh
|
|
# installs). Surfaced to the kanban decomposer so it can route work
|
|
# to the right profile based on role rather than name alone.
|
|
description: str = ""
|
|
# When True, ``description`` was auto-generated by the LLM
|
|
# describer and has not been confirmed by the user. The dashboard
|
|
# surfaces a "review" badge in this case so the user can edit or
|
|
# accept.
|
|
description_auto: bool = False
|
|
# Optional user-facing display name from profile.yaml. Presentation
|
|
# only — resolution/comparison/spawn paths always use ``name``.
|
|
display_name: str = ""
|
|
|
|
|
|
def _load_yaml_dict(path: Path) -> Optional[dict]:
|
|
"""Return the mapping in a YAML file, or None when missing/unreadable/not a mapping."""
|
|
if not path.is_file():
|
|
return None
|
|
try:
|
|
import yaml
|
|
with open(path, "r", encoding="utf-8") as f:
|
|
data = yaml.safe_load(f) or {}
|
|
except Exception:
|
|
return None
|
|
return data if isinstance(data, dict) else None
|
|
|
|
|
|
def _read_distribution_meta(profile_dir: Path) -> tuple:
|
|
"""Return ``(name, version, source)`` from the profile's ``distribution.yaml`` if present; ``(None,
|
|
None, None)`` otherwise.
|
|
"""
|
|
data = _load_yaml_dict(profile_dir / "distribution.yaml")
|
|
if data is None:
|
|
return None, None, None
|
|
return data.get("name"), data.get("version"), data.get("source")
|
|
|
|
|
|
def _read_config_model(profile_dir: Path) -> tuple:
|
|
"""Read model/provider from a profile's config.yaml. Returns (model, provider)."""
|
|
config_path = profile_dir / "config.yaml"
|
|
if not config_path.exists():
|
|
return None, None
|
|
try:
|
|
# Multi-profile display read: load_config() targets the ACTIVE
|
|
# profile's home, so read THIS profile's file via the raw primitive.
|
|
from hermes_cli.config import read_user_config_raw
|
|
cfg = read_user_config_raw(config_path)
|
|
model_cfg = cfg.get("model", {})
|
|
if isinstance(model_cfg, str):
|
|
return model_cfg, None
|
|
if isinstance(model_cfg, dict):
|
|
return model_cfg.get("default") or model_cfg.get("model"), model_cfg.get("provider")
|
|
return None, None
|
|
except Exception:
|
|
return None, None
|
|
|
|
|
|
def _seed_model_config(profile_dir: Path) -> None:
|
|
"""Give a profile created without a clone source a usable model block.
|
|
|
|
This is a copy, not a link: profiles remain independent islands, and editing either one
|
|
afterwards never touches the other. "Fresh" means fresh skills and SOUL, not unreachable.
|
|
"""
|
|
config_path = profile_dir / "config.yaml"
|
|
if config_path.exists():
|
|
return
|
|
try:
|
|
import yaml
|
|
from hermes_constants import get_hermes_home
|
|
from hermes_cli.config import read_user_config_raw
|
|
|
|
source = get_hermes_home() / "config.yaml"
|
|
if not source.is_file():
|
|
return
|
|
model_cfg = read_user_config_raw(source).get("model")
|
|
if not model_cfg:
|
|
return
|
|
config_path.write_text(
|
|
yaml.safe_dump({"model": model_cfg}, sort_keys=False),
|
|
encoding="utf-8",
|
|
)
|
|
except Exception:
|
|
# Creation must not fail over this; `hermes model` still sets it later.
|
|
pass
|
|
|
|
|
|
def _check_gateway_running(profile_dir: Path) -> bool:
|
|
"""Check if a gateway is running for a given profile directory.
|
|
|
|
Primary signal is ``gateway.pid`` verified against the runtime lock, which fails closed when
|
|
the lock isn't held by *this* reader (dashboard as a separate s6 service, launch-service
|
|
gateways with no live PID file). Then fall back to validating the PID in the profile's
|
|
``gateway_state.json`` against the process table, matching ``/api/status``. Never mutates
|
|
``HERMES_HOME``.
|
|
"""
|
|
try:
|
|
from gateway.status import (
|
|
get_running_pid,
|
|
get_runtime_status_running_pid,
|
|
read_runtime_status,
|
|
)
|
|
if get_running_pid(profile_dir / "gateway.pid", cleanup_stale=False) is not None:
|
|
return True
|
|
except Exception:
|
|
pass
|
|
try:
|
|
runtime = read_runtime_status(profile_dir / "gateway_state.json")
|
|
return get_runtime_status_running_pid(runtime, expected_home=profile_dir) is not None
|
|
except Exception:
|
|
return False
|
|
|
|
|
|
def _served_by_running_multiplexer(profile_name: str) -> bool:
|
|
"""True when the live default gateway multiplexes ``profile_name``.
|
|
|
|
A served named profile has no gateway.pid of its own, so ``_check_gateway_running`` alone
|
|
reports it stopped while the default multiplexer is really its inbound process. Shared by the
|
|
named-profile start guard and cron liveness.
|
|
"""
|
|
try:
|
|
from hermes_cli.gateway import named_profile_served_by_running_multiplexer
|
|
|
|
return named_profile_served_by_running_multiplexer(profile_name)
|
|
except Exception:
|
|
return False
|
|
|
|
|
|
# In-process cache for skill counts. Walking ``skills_dir.rglob("SKILL.md")``
|
|
# recurses the entire skill tree (each skill carries references/scripts/assets
|
|
# sub-trees); the default profile alone has ~270 skills, and ``list_profiles``
|
|
# calls this for EVERY profile (16+), so an uncached scan costs ~6s — long
|
|
# enough that the desktop's per-request backend calls time out and the sidebar
|
|
# renders "全部智能体 0". We cache the count keyed by the skills dir, invalidated
|
|
# when the dir tree's signature (skills_dir + immediate category dirs mtimes)
|
|
# changes (catches skill add/remove) or after a short TTL (catches deep edits).
|
|
_SKILL_COUNT_CACHE: dict[str, tuple[float, float, int]] = {}
|
|
_SKILL_COUNT_TTL_SECONDS = 30.0
|
|
|
|
|
|
def _skills_dir_signature(skills_dir: Path) -> float:
|
|
"""Cheap change-signature for a skills tree.
|
|
|
|
Max mtime of ``skills_dir`` and its immediate children: adding/removing a category bumps the
|
|
root, adding/removing a skill bumps its category dir. One ``scandir`` keeps this
|
|
O(#categories), not O(#files).
|
|
"""
|
|
try:
|
|
sig = skills_dir.stat().st_mtime
|
|
except OSError:
|
|
return 0.0
|
|
try:
|
|
with os.scandir(skills_dir) as it:
|
|
for entry in it:
|
|
try:
|
|
if entry.is_dir(follow_symlinks=False):
|
|
m = entry.stat(follow_symlinks=False).st_mtime
|
|
if m > sig:
|
|
sig = m
|
|
except OSError:
|
|
continue
|
|
except OSError:
|
|
pass
|
|
return sig
|
|
|
|
|
|
def _count_skills(profile_dir: Path) -> int:
|
|
"""Count installed skills in a profile (cached by skills-dir signature)."""
|
|
skills_dir = profile_dir / "skills"
|
|
if not skills_dir.is_dir():
|
|
return 0
|
|
|
|
key = str(skills_dir)
|
|
signature = _skills_dir_signature(skills_dir)
|
|
now = time.time()
|
|
cached = _SKILL_COUNT_CACHE.get(key)
|
|
if (
|
|
cached is not None
|
|
and cached[0] == signature
|
|
and (now - cached[1]) < _SKILL_COUNT_TTL_SECONDS
|
|
):
|
|
return cached[2]
|
|
|
|
count = 0
|
|
for md in skills_dir.rglob("SKILL.md"):
|
|
if is_excluded_skill_path(md):
|
|
continue
|
|
count += 1
|
|
_SKILL_COUNT_CACHE[key] = (signature, now, count)
|
|
return count
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# profile.yaml — per-profile metadata (description, role, etc.)
|
|
# ---------------------------------------------------------------------------
|
|
#
|
|
# We keep this file deliberately tiny and separate from the profile's
|
|
# ``config.yaml``. ``config.yaml`` is the user-facing Hermes config
|
|
# (~5000 lines of defaults); ``profile.yaml`` is metadata ABOUT the
|
|
# profile itself (its role, who described it). Mixing them makes both
|
|
# harder to read.
|
|
#
|
|
# Missing file -> empty defaults; never an error. The kanban decomposer
|
|
# tolerates empty descriptions and just falls back to the profile name.
|
|
|
|
|
|
def read_profile_meta(profile_dir: Path) -> dict:
|
|
"""Read ``<profile_dir>/profile.yaml`` and return a dict.
|
|
|
|
Returns ``{"description": "", "description_auto": False, "display_name": ""}`` when the file is
|
|
missing or unreadable. Never raises — a corrupt profile.yaml on an unrelated profile must not
|
|
break ``hermes profile list``.
|
|
"""
|
|
data = _load_yaml_dict(profile_dir / "profile.yaml")
|
|
if data is None:
|
|
return {"description": "", "description_auto": False, "display_name": ""}
|
|
return {
|
|
"description": str(data.get("description") or "").strip(),
|
|
"description_auto": bool(data.get("description_auto", False)),
|
|
"display_name": str(data.get("display_name") or "").strip(),
|
|
}
|
|
|
|
|
|
def write_profile_meta(
|
|
profile_dir: Path,
|
|
*,
|
|
description: Optional[str] = None,
|
|
description_auto: Optional[bool] = None,
|
|
display_name: Optional[str] = None,
|
|
) -> None:
|
|
"""Update ``<profile_dir>/profile.yaml`` in place.
|
|
|
|
Only the explicitly passed fields are overwritten; unspecified fields preserve existing values.
|
|
Creates the file if missing. Profile directory itself must exist.
|
|
"""
|
|
if not profile_dir.is_dir():
|
|
raise FileNotFoundError(f"profile directory does not exist: {profile_dir}")
|
|
path = profile_dir / "profile.yaml"
|
|
existing: dict = _load_yaml_dict(path) or {}
|
|
if description is not None:
|
|
existing["description"] = description.strip()
|
|
if description_auto is not None:
|
|
existing["description_auto"] = bool(description_auto)
|
|
if display_name is not None:
|
|
# Empty string clears the key (falls back to the canonical id).
|
|
if display_name.strip():
|
|
existing["display_name"] = display_name.strip()
|
|
else:
|
|
existing.pop("display_name", None)
|
|
# Atomic write: bare open("w") truncates before the dump, and the read
|
|
# path above swallows parse errors as {}, so a crashed write would
|
|
# silently drop unspecified fields on the next call (#51356, #16743).
|
|
from utils import atomic_yaml_write
|
|
|
|
atomic_yaml_write(path, existing, sort_keys=False)
|
|
|
|
|
|
def format_profile_label(name: str, display_name: Optional[str]) -> str:
|
|
"""Render a profile for display: ``display_name (canonical_id)``.
|
|
|
|
Falls back to the bare canonical id when no display name is set (or it equals the id) — byte-
|
|
for-byte the pre-feature rendering. Display names are presentation-only free text (Unicode
|
|
fine); they are never a directory name, wrapper filename, or argv token.
|
|
"""
|
|
dn = (display_name or "").strip()
|
|
return f"{dn} ({name})" if dn and dn != name else name
|
|
|
|
|
|
def set_profile_display_name(profile_name: str, display_name: str) -> str:
|
|
"""Set (or clear, with ``""``) a profile's user-facing display name.
|
|
|
|
Presentation-only: the canonical profile id is untouched. Returns the stored value. Raises
|
|
``ValueError`` for names over 64 chars.
|
|
"""
|
|
canon = normalize_profile_name(profile_name)
|
|
validate_profile_name(canon)
|
|
profile_dir = get_profile_dir(canon)
|
|
if not profile_dir.is_dir():
|
|
raise FileNotFoundError(f"Profile '{canon}' does not exist.")
|
|
cleaned = (display_name or "").strip()
|
|
if len(cleaned) > 64:
|
|
raise ValueError(f"Display name too long ({len(cleaned)} chars, max 64).")
|
|
write_profile_meta(profile_dir, display_name=cleaned)
|
|
return cleaned
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# CRUD operations
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def _profile_info(name: str, path: Path, *, is_default: bool, alias_name: Optional[str] = None) -> ProfileInfo:
|
|
"""Build one :class:`ProfileInfo` from a profile directory."""
|
|
model, provider = _read_config_model(path)
|
|
dist_name, dist_version, dist_source = _read_distribution_meta(path)
|
|
meta = read_profile_meta(path)
|
|
alias_path = _wrapper_path(alias_name) if alias_name else None
|
|
if alias_path is not None and not alias_path.exists():
|
|
alias_path = None
|
|
gateway_running = _check_gateway_running(path)
|
|
if not is_default:
|
|
gateway_running = gateway_running or _served_by_running_multiplexer(name)
|
|
return ProfileInfo(
|
|
name=name,
|
|
path=path,
|
|
is_default=is_default,
|
|
gateway_running=gateway_running,
|
|
model=model,
|
|
provider=provider,
|
|
has_env=(path / ".env").exists(),
|
|
skill_count=_count_skills(path),
|
|
alias_path=alias_path,
|
|
alias_name=alias_name,
|
|
distribution_name=dist_name,
|
|
distribution_version=dist_version,
|
|
distribution_source=dist_source,
|
|
description=meta.get("description", ""),
|
|
description_auto=meta.get("description_auto", False),
|
|
display_name=meta.get("display_name", ""),
|
|
)
|
|
|
|
|
|
def list_profiles() -> List[ProfileInfo]:
|
|
"""Return info for all profiles, including the default."""
|
|
profiles = []
|
|
default_home = _get_default_hermes_home()
|
|
if default_home.is_dir():
|
|
profiles.append(_profile_info("default", default_home, is_default=True))
|
|
|
|
named = _iter_named_profile_dirs()
|
|
if named:
|
|
# Build the {profile -> alias} map ONCE instead of per profile
|
|
# (re-scanning the wrapper dir N times was the dominant cost here).
|
|
alias_map = build_alias_map()
|
|
for entry in named:
|
|
alias_name = alias_map.get(normalize_profile_name(entry.name))
|
|
profiles.append(_profile_info(entry.name, entry, is_default=False, alias_name=alias_name))
|
|
return profiles
|
|
|
|
|
|
def profiles_to_serve(
|
|
multiplex: bool,
|
|
profile_allowlist: Optional[List[str]] = None,
|
|
) -> List[Tuple[str, Path]]:
|
|
"""Return the ``(profile_name, hermes_home)`` pairs a gateway should serve.
|
|
|
|
This is the single chokepoint for "which profiles does the inbound gateway handle" so later
|
|
multiplexing phases never re-derive the set.
|
|
|
|
- ``multiplex=False`` (default): returns exactly one entry for the *active* profile — byte-for-
|
|
byte the single-profile behavior the gateway has always had. The name is ``"default"`` for the
|
|
default profile or the active named profile's id.
|
|
"""
|
|
active = get_active_profile_name() or "default"
|
|
if not multiplex:
|
|
return [(active, get_profile_dir(active))]
|
|
|
|
serve: List[Tuple[str, Path]] = [("default", _get_default_hermes_home())]
|
|
allowed: Optional[set[str]] = None
|
|
if profile_allowlist is not None:
|
|
allowed = set()
|
|
for entry in profile_allowlist:
|
|
if not isinstance(entry, str):
|
|
continue
|
|
try:
|
|
name = normalize_profile_name(entry)
|
|
validate_profile_name(name)
|
|
except ValueError:
|
|
continue
|
|
if name != "default":
|
|
allowed.add(name)
|
|
|
|
for entry in _iter_named_profile_dirs():
|
|
if allowed is None or entry.name in allowed:
|
|
serve.append((entry.name, entry))
|
|
|
|
if allowed is not None:
|
|
missing = tuple(sorted(allowed - {name for name, _ in serve}))
|
|
if missing and missing not in _WARNED_MISSING_ALLOWLIST_ENTRIES:
|
|
_WARNED_MISSING_ALLOWLIST_ENTRIES.add(missing)
|
|
logger.warning(
|
|
"Skipping missing gateway.multiplex_profile_allowlist profile(s): %s",
|
|
", ".join(missing),
|
|
)
|
|
|
|
return serve
|
|
|
|
|
|
def _resolve_clone_source(clone_from: Optional[str]) -> Path:
|
|
"""Directory to clone from: the named profile, or the active profile when ``None``."""
|
|
if clone_from is None:
|
|
from hermes_constants import get_hermes_home
|
|
source_dir = get_hermes_home()
|
|
else:
|
|
clone_from = normalize_profile_name(clone_from)
|
|
validate_profile_name(clone_from)
|
|
source_dir = get_profile_dir(clone_from)
|
|
if not source_dir.is_dir():
|
|
raise FileNotFoundError(
|
|
f"Source profile '{clone_from or 'active'}' does not exist at {source_dir}"
|
|
)
|
|
return source_dir
|
|
|
|
|
|
def _seed_file_if_missing(path: Path, text: str, mode: Optional[int] = None) -> None:
|
|
"""Best-effort: write *text* to *path* unless it already exists; never raises."""
|
|
if path.exists():
|
|
return
|
|
try:
|
|
path.write_text(text, encoding="utf-8")
|
|
if mode is not None:
|
|
os.chmod(str(path), mode)
|
|
except OSError:
|
|
pass
|
|
|
|
|
|
def _clone_file(source_dir: Path, profile_dir: Path, relpath: str) -> None:
|
|
"""Copy one profile-relative file if it exists; ``.env`` is tightened to owner-only.
|
|
|
|
``shutil.copy2`` preserves source mode bits, so a loose source ``.env`` (host umask 0o022
|
|
leaving 0o644) would otherwise hand the clone weak perms.
|
|
"""
|
|
src = source_dir / relpath
|
|
if not src.exists():
|
|
return
|
|
dst = profile_dir / relpath
|
|
dst.parent.mkdir(parents=True, exist_ok=True)
|
|
shutil.copy2(src, dst)
|
|
if relpath == ".env":
|
|
try:
|
|
os.chmod(str(dst), 0o600)
|
|
except OSError:
|
|
pass
|
|
|
|
|
|
def create_profile(
|
|
name: str,
|
|
clone_from: Optional[str] = None,
|
|
clone_all: bool = False,
|
|
clone_config: bool = False,
|
|
no_alias: bool = False,
|
|
no_skills: bool = False,
|
|
description: Optional[str] = None,
|
|
) -> Path:
|
|
"""Create a new profile directory and return its path.
|
|
|
|
``clone_from`` defaults to the active profile when cloning. ``clone_all`` copies all state;
|
|
``clone_config`` copies config.yaml/.env/SOUL.md, installed skills, and identity files.
|
|
``no_skills`` creates an empty profile and writes a marker so ``hermes update`` skips
|
|
re-seeding its skills; it is mutually exclusive with the clone options, which copy skills.
|
|
"""
|
|
if no_skills and (clone_from is not None or clone_config or clone_all):
|
|
raise ValueError(
|
|
"--no-skills is mutually exclusive with --clone / --clone-from / --clone-all "
|
|
"(cloning explicitly copies skills from the source profile)."
|
|
)
|
|
canon = normalize_profile_name(name)
|
|
validate_profile_name(canon)
|
|
|
|
if canon == "default":
|
|
raise ValueError(
|
|
"Cannot create a profile named 'default' — it is the built-in profile (~/.hermes)."
|
|
)
|
|
|
|
profile_dir = get_profile_dir(canon)
|
|
if profile_dir.exists() and named_profile_is_deleted(profile_dir):
|
|
# Empty shells left by post-delete mkdir may be replaced. Identity
|
|
# files mean the leftover is not a shell — fail closed, no rmtree.
|
|
if (profile_dir / "config.yaml").exists() or (profile_dir / ".env").exists():
|
|
raise FileExistsError(f"Profile '{canon}' already exists at {profile_dir}")
|
|
shutil.rmtree(profile_dir)
|
|
if profile_dir.exists():
|
|
raise FileExistsError(f"Profile '{canon}' already exists at {profile_dir}")
|
|
clear_named_profile_deleted(profile_dir)
|
|
|
|
source_dir = None
|
|
if clone_from is not None or clone_all or clone_config:
|
|
source_dir = _resolve_clone_source(clone_from)
|
|
|
|
if clone_all and source_dir:
|
|
# Full copy of source profile (exclude sibling ~/.hermes/profiles/)
|
|
shutil.copytree(
|
|
source_dir,
|
|
profile_dir,
|
|
symlinks=True,
|
|
ignore=_clone_all_copytree_ignore(source_dir),
|
|
)
|
|
# Strip runtime files
|
|
for stale in _CLONE_ALL_STRIP:
|
|
(profile_dir / stale).unlink(missing_ok=True)
|
|
# A clone-all copies auth.json and .anthropic_oauth.json verbatim.
|
|
# Single-use OAuth grants (Anthropic / Codex / xAI) forked that way
|
|
# are one credential with two owners: the first profile to refresh
|
|
# revokes the pair for every sibling (#100339). Drop the copies; the
|
|
# clone reads the root grant through the credential-pool fallback.
|
|
from hermes_cli.auth import strip_cloned_single_use_oauth_grants
|
|
stripped = strip_cloned_single_use_oauth_grants(profile_dir)
|
|
if any(stripped.values()):
|
|
logger.info(
|
|
"profile %s: dropped cloned single-use OAuth grants %s "
|
|
"(inherits the root grant instead)", canon, stripped,
|
|
)
|
|
else:
|
|
# 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)
|
|
|
|
if source_dir is None:
|
|
_seed_model_config(profile_dir)
|
|
|
|
# Clone config files, then installed skills (the dashboard's "clone from
|
|
# default" flow must preserve bundled AND user-installed skills), then
|
|
# memory/identity files from the source profile.
|
|
if source_dir is not None:
|
|
for relpath in _CLONE_CONFIG_FILES:
|
|
_clone_file(source_dir, profile_dir, relpath)
|
|
source_skills = source_dir / "skills"
|
|
if source_skills.is_dir():
|
|
shutil.copytree(source_skills, profile_dir / "skills", symlinks=True, dirs_exist_ok=True)
|
|
for relpath in _CLONE_SUBDIR_FILES:
|
|
_clone_file(source_dir, profile_dir, relpath)
|
|
|
|
# Seed an empty .env so the profile has its own credentials file from
|
|
# day one. Without it, profile-scoped env writes (dashboard Channels /
|
|
# Keys pages, `hermes -p <name> auth add`) had no file until first
|
|
# write, and the profile silently inherited API keys from the shell
|
|
# environment — users reasonably read that as "the new profile reads
|
|
# the root .env". Skipped when --clone/--clone-all already copied one
|
|
# (save_env_value creates the file on demand if this fails).
|
|
_seed_file_if_missing(profile_dir / ".env", _PLACEHOLDER_ENV, 0o600)
|
|
|
|
# Seed a default SOUL.md so the user has a file to customize immediately.
|
|
# Skipped when the profile already has one (from --clone / --clone-all).
|
|
try:
|
|
from hermes_cli.default_soul import DEFAULT_SOUL_MD
|
|
_seed_file_if_missing(profile_dir / "SOUL.md", DEFAULT_SOUL_MD)
|
|
except Exception:
|
|
pass # best-effort — don't fail profile creation over this
|
|
|
|
# Write the opt-out marker so seed_profile_skills() and `hermes update`'s
|
|
# all-profile sync loop both skip this profile for bundled-skill seeding
|
|
# (the feature still works via the empty skills/ dir if this fails).
|
|
if no_skills:
|
|
_seed_file_if_missing(
|
|
profile_dir / NO_BUNDLED_SKILLS_MARKER,
|
|
"This profile opted out of bundled-skill seeding "
|
|
"(`hermes profile create --no-skills`).\n"
|
|
"Delete this file to re-enable sync on the next `hermes update`.\n",
|
|
)
|
|
|
|
# Cloned configs can be older than the running Hermes (or predate schema
|
|
# tracking entirely). Migrate config-only clones immediately so
|
|
# desktop/status surfaces don't warn that a just-created profile is
|
|
# v0/outdated. Leave --clone-all snapshots byte-for-byte apart from the
|
|
# explicit runtime/history stripping above.
|
|
if not clone_all:
|
|
_migrate_profile_config_if_outdated(profile_dir)
|
|
|
|
# Persist description if the caller provided one. Done last so a
|
|
# partial-create failure doesn't strand a description file in an
|
|
# incomplete profile.
|
|
if description and description.strip():
|
|
try:
|
|
write_profile_meta(
|
|
profile_dir,
|
|
description=description.strip(),
|
|
description_auto=False,
|
|
)
|
|
except Exception:
|
|
pass # non-fatal — user can describe later with `hermes profile describe`
|
|
|
|
# Phase 4: when running inside a container under s6, register the
|
|
# new profile's gateway as a runtime s6 service so
|
|
# `hermes -p <profile> gateway start` can supervise it via
|
|
# `s6-svc -u` instead of spawning a bare process. On host (systemd
|
|
# / launchd / windows) this is a no-op — the existing per-profile
|
|
# unit-generation paths handle gateway lifecycle.
|
|
_maybe_register_gateway_service(canon)
|
|
|
|
return profile_dir
|
|
|
|
|
|
def seed_profile_skills(profile_dir: Path, quiet: bool = False) -> Optional[dict]:
|
|
"""Seed bundled skills into a profile via subprocess.
|
|
|
|
Uses subprocess because sync_skills() caches HERMES_HOME at module level. Returns the sync
|
|
result dict, or None on failure.
|
|
|
|
Profiles that opted out of bundled skills (via ``hermes profile create --no-skills`` — which
|
|
writes ``.no-bundled-skills`` to the profile root) still run the sync: ``sync_skills()`` detects
|
|
the marker itself and seeds only the essential skills (e.g.
|
|
"""
|
|
project_root = Path(__file__).parent.parent.resolve()
|
|
try:
|
|
result = subprocess.run(
|
|
[sys.executable, "-c",
|
|
"import json; from tools.skills_sync import sync_skills; "
|
|
"r = sync_skills(quiet=True); print(json.dumps(r))"],
|
|
env={**os.environ, "HERMES_HOME": str(profile_dir)},
|
|
cwd=str(project_root),
|
|
capture_output=True, text=True, encoding='utf-8', errors='replace', timeout=60,
|
|
)
|
|
if result.returncode == 0 and result.stdout.strip():
|
|
return json.loads(result.stdout.strip())
|
|
if not quiet:
|
|
print(f"⚠ Skill seeding returned exit code {result.returncode}")
|
|
if result.stderr.strip():
|
|
print(f" {result.stderr.strip()[:200]}")
|
|
return None
|
|
except subprocess.TimeoutExpired:
|
|
if not quiet:
|
|
print("⚠ Skill seeding timed out (60s)")
|
|
return None
|
|
except Exception as e:
|
|
if not quiet:
|
|
print(f"⚠ Skill seeding failed: {e}")
|
|
return None
|
|
|
|
|
|
def backfill_profile_envs(quiet: bool = False) -> List[str]:
|
|
"""Give every named profile that predates per-profile ``.env`` files one.
|
|
|
|
Falls back to the placeholder header when the default install has no ``.env`` itself. Never
|
|
overwrites an existing profile ``.env``.
|
|
"""
|
|
backfilled: List[str] = []
|
|
default_env = _get_default_hermes_home() / ".env"
|
|
|
|
for entry in _iter_named_profile_dirs():
|
|
env_path = entry / ".env"
|
|
if env_path.exists():
|
|
continue
|
|
try:
|
|
if default_env.is_file():
|
|
shutil.copy2(default_env, env_path)
|
|
else:
|
|
env_path.write_text(_PLACEHOLDER_ENV, encoding="utf-8")
|
|
os.chmod(str(env_path), 0o600)
|
|
backfilled.append(entry.name)
|
|
except OSError as e:
|
|
if not quiet:
|
|
print(f"⚠ Could not seed .env for profile '{entry.name}': {e}")
|
|
|
|
return backfilled
|
|
|
|
|
|
_BACKEND_TOKENS = frozenset({"serve", "dashboard", "gateway"})
|
|
_HERMES_ARGV_MARKERS = ("hermes_cli.main", "hermes-gateway", "tui_gateway")
|
|
# Matches python / python3 / python3.12 / pythonw(.exe) — the interpreter
|
|
# basenames a `#!/…/python3` console-script shim gets exec'd through when
|
|
# something (e.g. Electron's `findOnPath('hermes')` resolution) spawns the
|
|
# shim by handing the interpreter its path explicitly. In that shape the
|
|
# OS-reported argv[0] is the interpreter, not "hermes".
|
|
_PYTHON_INTERPRETER_RE = re.compile(r"^python[\d.]*w?(\.exe)?$")
|
|
# Console-script entry points this project ships (pyproject.toml
|
|
# [project.scripts]). argv[1] is validated against these exact names rather
|
|
# than a loose ``startswith("hermes")``: when argv[0] is a bare interpreter,
|
|
# argv[1] can be ANY user script ("hermes-notes.py") and a prefix match would
|
|
# make it killable by profile delete.
|
|
_HERMES_CONSOLE_SCRIPT_NAMES = frozenset({"hermes", "hermes-agent", "hermes-acp"})
|
|
|
|
|
|
def _is_hermes_argv(argv: list) -> bool:
|
|
"""True when *argv* is a Hermes process: an entrypoint marker in argv, an executable named
|
|
``hermes*``, or a python interpreter directly exec'ing a known ``hermes`` console-script shim.
|
|
"""
|
|
joined = " ".join(argv)
|
|
exe_name = os.path.basename(argv[0]).lower()
|
|
if any(marker in joined for marker in _HERMES_ARGV_MARKERS) or exe_name.startswith("hermes"):
|
|
return True
|
|
if len(argv) >= 2 and _PYTHON_INTERPRETER_RE.match(exe_name):
|
|
script_name = os.path.basename(str(argv[1])).lower()
|
|
return script_name.rsplit(".", 1)[0] in _HERMES_CONSOLE_SCRIPT_NAMES
|
|
return False
|
|
|
|
|
|
def _argv_profile_selectors(argv: list):
|
|
"""Yield every profile name selected via ``-p X`` / ``--profile X`` / ``--profile=X``."""
|
|
for i, tok in enumerate(argv):
|
|
if tok in {"--profile", "-p"} and i + 1 < len(argv):
|
|
yield argv[i + 1]
|
|
elif tok.startswith("--profile="):
|
|
yield tok.split("=", 1)[1]
|
|
|
|
|
|
def _profile_bound_backend_pids(canon: str, profile_dir: Path) -> list[int]:
|
|
"""PIDs of running Hermes *backends* bound to this profile.
|
|
|
|
The ``gateway.pid`` file only tracks the messaging gateway.
|
|
|
|
Best-effort and tightly scoped: current-user processes only, backend subcommands only (never an
|
|
interactive ``chat``/``tui``), and never this process or its ancestors. Returns an empty list if
|
|
``psutil`` can't inspect anything.
|
|
"""
|
|
try:
|
|
import psutil # type: ignore
|
|
except Exception:
|
|
return []
|
|
|
|
try:
|
|
resolved_dir = profile_dir.resolve()
|
|
except OSError:
|
|
resolved_dir = profile_dir
|
|
|
|
# Never terminate ourselves or a parent (e.g. `hermes -p <canon> profile
|
|
# delete` runs under the very profile it's deleting).
|
|
skip: set[int] = {os.getpid()}
|
|
try:
|
|
parent = psutil.Process(os.getpid()).parent()
|
|
while parent is not None:
|
|
skip.add(parent.pid)
|
|
parent = parent.parent()
|
|
except Exception:
|
|
pass
|
|
|
|
try:
|
|
current_user = psutil.Process(os.getpid()).username()
|
|
except Exception:
|
|
current_user = None
|
|
|
|
pids: list[int] = []
|
|
|
|
for proc in psutil.process_iter(["pid", "name", "username", "cmdline"]):
|
|
try:
|
|
info = proc.info
|
|
pid = info.get("pid")
|
|
if pid is None or pid in skip:
|
|
continue
|
|
if current_user is not None and info.get("username") != current_user:
|
|
continue
|
|
|
|
argv = info.get("cmdline") or []
|
|
if not argv or not _is_hermes_argv(argv):
|
|
continue
|
|
|
|
# Restrict to backend subcommands so we never kill an interactive
|
|
# session the user is deliberately running.
|
|
if not ({tok.lower() for tok in argv} & _BACKEND_TOKENS):
|
|
continue
|
|
|
|
# Bound to THIS profile — by selector flag in argv...
|
|
bound = any(
|
|
normalize_profile_name(sel) == canon for sel in _argv_profile_selectors(argv)
|
|
)
|
|
|
|
# ...or by HERMES_HOME env pointing at this profile dir.
|
|
if not bound:
|
|
try:
|
|
env_home = (proc.environ() or {}).get("HERMES_HOME", "")
|
|
if env_home and Path(env_home).resolve() == resolved_dir:
|
|
bound = True
|
|
except Exception:
|
|
# environ() can raise AccessDenied even same-user on some
|
|
# platforms; fall back to the argv signal only.
|
|
pass
|
|
|
|
if bound:
|
|
pids.append(pid)
|
|
except (psutil.NoSuchProcess, psutil.AccessDenied, psutil.ZombieProcess):
|
|
continue
|
|
except Exception:
|
|
continue
|
|
|
|
return pids
|
|
|
|
|
|
def _wait_then_force_kill(pids: List[int], start_times: dict, *, wait: float = 10.0) -> bool:
|
|
"""After a graceful ``terminate_pid``, wait up to *wait* seconds (0.5s polls) for *pids* to
|
|
exit, then force-kill stragglers. Returns True when every pid exited gracefully.
|
|
|
|
``start_times`` pins each force kill to the same process incarnation (PID reuse guard);
|
|
force-kill errors are swallowed.
|
|
"""
|
|
from gateway.status import _pid_exists, get_process_start_time, terminate_pid
|
|
|
|
for _ in range(int(wait / 0.5)):
|
|
time.sleep(0.5)
|
|
if not any(_pid_exists(pid) for pid in pids):
|
|
return True
|
|
for pid in pids:
|
|
if _pid_exists(pid):
|
|
try:
|
|
terminate_pid(
|
|
pid,
|
|
force=True,
|
|
expected_start_time=start_times.get(pid, get_process_start_time(pid)),
|
|
)
|
|
except (ProcessLookupError, PermissionError, OSError):
|
|
pass
|
|
return False
|
|
|
|
|
|
def _stop_profile_backends(canon: str, profile_dir: Path) -> None:
|
|
"""Terminate any Desktop-spawned / stray backends bound to this profile.
|
|
|
|
Complements ``_stop_gateway_process`` (which only knows ``gateway.pid``): without this, a live
|
|
``serve``/``dashboard`` backend keeps creating files under the profile dir while ``rmtree``
|
|
walks it, so the final ``rmdir`` fails with ``ENOTEMPTY`` and the delete doesn't converge.
|
|
"""
|
|
pids = _profile_bound_backend_pids(canon, profile_dir)
|
|
if not pids:
|
|
return
|
|
try:
|
|
from gateway.status import terminate_pid
|
|
except Exception:
|
|
return
|
|
|
|
for pid in pids:
|
|
try:
|
|
terminate_pid(pid) # graceful first
|
|
except (ProcessLookupError, PermissionError, OSError):
|
|
continue
|
|
_wait_then_force_kill(pids, {})
|
|
|
|
print(f"✓ Stopped {len(pids)} profile backend process(es)")
|
|
|
|
|
|
def _rmtree_make_writable(func, path, exc):
|
|
"""onexc/onerror handler: add +w on PermissionError so rmtree can proceed.
|
|
|
|
Handles two cases on NixOS (and other systems with read-only copies from immutable
|
|
stores): 1. The path itself isn't writable (e.g. a file with mode 0444) 2. The *parent*
|
|
directory isn't writable (e.g. mode 0555)
|
|
"""
|
|
# Normalise the two callback signatures:
|
|
# onexc(func, path, exc_instance) — 3.12+
|
|
# onerror(func, path, exc_info_tuple) — 3.11
|
|
if isinstance(exc, tuple):
|
|
exc = exc[1] # exc_info → actual exception object
|
|
if not isinstance(exc, PermissionError):
|
|
raise
|
|
# Make the path and its parent writable (parent needed for unlink/rmdir).
|
|
for target in (path, os.path.dirname(path)):
|
|
if target:
|
|
try:
|
|
os.chmod(target, os.stat(target).st_mode | stat.S_IWUSR)
|
|
except OSError:
|
|
pass
|
|
func(path)
|
|
|
|
|
|
def _rmtree_with_retry(profile_dir: Path, onexc_handler) -> None:
|
|
"""``shutil.rmtree`` with a short retry loop for transient races.
|
|
|
|
Even after stopping the gateway and profile backends, a just-terminated process can leave in-
|
|
flight writes (SQLite ``-wal``/``-shm`` checkpoints, sandbox temp files) that land after
|
|
``rmtree`` has walked past a directory, surfacing as ``ENOTEMPTY`` (POSIX) or a transient
|
|
``PermissionError`` (Windows file lock still releasing).
|
|
"""
|
|
attempts = 3
|
|
last_exc: OSError | None = None
|
|
for attempt in range(attempts):
|
|
try:
|
|
# ``onexc`` was added in 3.12; fall back to ``onerror`` on 3.11.
|
|
try:
|
|
shutil.rmtree(profile_dir, onexc=onexc_handler)
|
|
except TypeError:
|
|
shutil.rmtree(profile_dir, onerror=onexc_handler)
|
|
return
|
|
except OSError as e:
|
|
last_exc = e
|
|
if not profile_dir.exists():
|
|
return
|
|
if attempt < attempts - 1:
|
|
time.sleep(0.3 * (attempt + 1))
|
|
if last_exc is not None:
|
|
raise last_exc
|
|
|
|
|
|
def _print_delete_summary(canon: str, profile_dir: Path, gw_running: bool, wrapper_path: Optional[Path]) -> None:
|
|
"""Show what ``delete_profile`` is about to remove."""
|
|
model, provider = _read_config_model(profile_dir)
|
|
skill_count = _count_skills(profile_dir)
|
|
dist_name, dist_version, dist_source = _read_distribution_meta(profile_dir)
|
|
|
|
print(f"\nProfile: {canon}")
|
|
print(f"Path: {profile_dir}")
|
|
if model:
|
|
print(f"Model: {model}" + (f" ({provider})" if provider else ""))
|
|
if skill_count:
|
|
print(f"Skills: {skill_count}")
|
|
if dist_name:
|
|
print(f"Distribution: {dist_name}@{dist_version or '?'}")
|
|
if dist_source:
|
|
print(f"Installed from: {dist_source}")
|
|
|
|
print("\nThis will permanently delete:")
|
|
print(" • All config, API keys, memories, sessions, skills, cron jobs")
|
|
if wrapper_path is not None:
|
|
print(f" • Command alias ({wrapper_path})")
|
|
if gw_running:
|
|
print(" ⚠ Gateway is running — it will be stopped.")
|
|
|
|
|
|
def delete_profile(name: str, yes: bool = False) -> Path:
|
|
"""Delete a profile, its wrapper script, and its gateway service.
|
|
|
|
Stops the gateway if running. Disables systemd/launchd service first to prevent auto-restart.
|
|
"""
|
|
canon = normalize_profile_name(name)
|
|
validate_profile_name(canon)
|
|
|
|
if canon == "default":
|
|
raise ValueError(
|
|
"Cannot delete the default profile (~/.hermes).\n"
|
|
"To remove everything, use: hermes uninstall"
|
|
)
|
|
|
|
profile_dir = get_profile_dir(canon)
|
|
if not profile_dir.is_dir():
|
|
raise FileNotFoundError(f"Profile '{canon}' does not exist.")
|
|
|
|
gw_running = _check_gateway_running(profile_dir)
|
|
wrapper_path = _get_wrapper_dir() / canon
|
|
has_wrapper = wrapper_path.exists()
|
|
_print_delete_summary(canon, profile_dir, gw_running, wrapper_path if has_wrapper else None)
|
|
|
|
# Confirmation
|
|
if not yes:
|
|
print()
|
|
try:
|
|
confirm = input(f"Type '{canon}' to confirm: ").strip()
|
|
except (KeyboardInterrupt, EOFError):
|
|
confirm = None
|
|
print()
|
|
if confirm != canon:
|
|
print("Cancelled.")
|
|
return profile_dir
|
|
|
|
# 1. Disable service (prevents auto-restart)
|
|
_cleanup_gateway_service(canon, profile_dir)
|
|
# 1b. Phase 4: unregister the s6 service slot (container path).
|
|
# On host this is a no-op; on container it removes
|
|
# /run/service/gateway-<profile>/ so s6-supervise drops it.
|
|
_maybe_unregister_gateway_service(canon)
|
|
|
|
# 2. Stop running gateway
|
|
if gw_running:
|
|
_stop_gateway_process(profile_dir)
|
|
|
|
# 2b. Stop any other backends bound to this profile (Desktop-spawned
|
|
# serve/dashboard processes the gateway.pid file never names). They hold
|
|
# the profile's SQLite connection open and keep writing files, which makes
|
|
# the rmtree below fail with ENOTEMPTY and — before the ensure_hermes_home
|
|
# guard — resurrected the deleted tree.
|
|
_stop_profile_backends(canon, profile_dir)
|
|
|
|
# Tombstone before rmtree so a stale serve/logging mkdir cannot relist
|
|
# this name as a live profile.
|
|
mark_named_profile_deleted(profile_dir)
|
|
|
|
# 2c. Release this process's holographic memory-store connections into
|
|
# the profile. The Desktop's *main* serve process opens memory_store.db
|
|
# for every known profile and is deliberately not stopped above, so on
|
|
# Windows its open handles make the rmtree below fail with WinError 32
|
|
# (#88347). When this delete runs inside serve (the DELETE
|
|
# /api/profiles/<name> route) the handles live in this process and are
|
|
# closed here; from the CLI this finds nothing and is a no-op.
|
|
try:
|
|
from plugins.memory.holographic.store import MemoryStore as _MemoryStore
|
|
|
|
_released = _MemoryStore.release_all_under(profile_dir)
|
|
if _released:
|
|
print(f"✓ Released {_released} memory-store connection(s) held by this process")
|
|
except Exception:
|
|
pass # best-effort: never block the delete on the release path
|
|
|
|
# 3. Remove wrapper script
|
|
if has_wrapper and remove_wrapper_script(canon):
|
|
print(f"✓ Removed {wrapper_path}")
|
|
|
|
# 4. Remove profile directory
|
|
remove_error: Exception | None = None
|
|
try:
|
|
_rmtree_with_retry(profile_dir, _rmtree_make_writable)
|
|
print(f"✓ Removed {profile_dir}")
|
|
except Exception as e:
|
|
print(f"⚠ Could not remove {profile_dir}: {e}")
|
|
remove_error = e
|
|
|
|
# 5. Clear active_profile if it pointed to this profile
|
|
_retarget_active_profile(canon, "default", "✓ Active profile reset to default")
|
|
|
|
if remove_error is not None:
|
|
raise RuntimeError(f"Could not remove profile directory {profile_dir}: {remove_error}") from remove_error
|
|
|
|
print(f"\nProfile '{canon}' deleted.")
|
|
return profile_dir
|
|
|
|
|
|
def _s6_runtime_manager():
|
|
"""Return the s6 service manager when running inside the container, else None.
|
|
|
|
Silent on host: a failing/absent detector must never print a confusing s6 warning to users
|
|
who have never touched the container.
|
|
"""
|
|
try:
|
|
from hermes_cli.service_manager import detect_service_manager, get_service_manager
|
|
if detect_service_manager() != "s6":
|
|
return None
|
|
mgr = get_service_manager()
|
|
except Exception:
|
|
return None
|
|
return mgr if mgr.supports_runtime_registration() else None
|
|
|
|
|
|
def _maybe_register_gateway_service(profile_name: str) -> None:
|
|
"""Register a profile's gateway with s6 inside the container.
|
|
|
|
Best-effort: any error (no backend detected, s6 not yet ready, etc.) is logged and swallowed so
|
|
profile creation doesn't fail because the s6 supervision tree is in a weird state. The user can
|
|
re-register manually later via the gateway start command, which goes through the same dispatch
|
|
path.
|
|
"""
|
|
mgr = _s6_runtime_manager()
|
|
if mgr is None:
|
|
return
|
|
try:
|
|
mgr.register_profile_gateway(profile_name, start_now=False)
|
|
except ValueError:
|
|
# Already registered (e.g. the container-boot reconciler ran
|
|
# first and brought up a stale slot). That's fine.
|
|
pass
|
|
except Exception as exc:
|
|
# Don't fail profile create over a supervision-tree hiccup.
|
|
print(f"⚠ Could not register s6 gateway service: {exc}")
|
|
|
|
|
|
def _maybe_unregister_gateway_service(profile_name: str) -> None:
|
|
"""Tear down a profile's s6 gateway service inside the container.
|
|
|
|
No-op on host (same short-circuit as ``_maybe_register_gateway_service``); idempotent since
|
|
absent services are silently skipped.
|
|
"""
|
|
mgr = _s6_runtime_manager()
|
|
if mgr is None:
|
|
return
|
|
try:
|
|
mgr.unregister_profile_gateway(profile_name)
|
|
except Exception as exc:
|
|
print(f"⚠ Could not unregister s6 gateway service: {exc}")
|
|
|
|
|
|
def _cleanup_gateway_service(name: str, profile_dir: Path) -> None:
|
|
"""Disable and remove systemd/launchd service for a profile."""
|
|
import platform as _platform
|
|
|
|
# Derive service name for this profile
|
|
# Temporarily set HERMES_HOME so _profile_suffix resolves correctly
|
|
old_home = os.environ.get("HERMES_HOME")
|
|
try:
|
|
os.environ["HERMES_HOME"] = str(profile_dir)
|
|
from hermes_cli.gateway import get_service_name, get_launchd_plist_path
|
|
|
|
def _run(*cmd: str) -> None:
|
|
subprocess.run(list(cmd), capture_output=True, check=False, timeout=10)
|
|
|
|
system = _platform.system()
|
|
if system == "Linux":
|
|
svc_name = get_service_name()
|
|
svc_file = Path.home() / ".config" / "systemd" / "user" / f"{svc_name}.service"
|
|
if svc_file.exists():
|
|
_run("systemctl", "--user", "disable", svc_name)
|
|
_run("systemctl", "--user", "stop", svc_name)
|
|
svc_file.unlink(missing_ok=True)
|
|
_run("systemctl", "--user", "daemon-reload")
|
|
print(f"✓ Service {svc_name} removed")
|
|
elif system == "Darwin":
|
|
plist_path = get_launchd_plist_path()
|
|
if plist_path.exists():
|
|
_run("launchctl", "unload", str(plist_path))
|
|
plist_path.unlink(missing_ok=True)
|
|
print("✓ Launchd service removed")
|
|
except Exception as e:
|
|
print(f"⚠ Service cleanup: {e}")
|
|
finally:
|
|
if old_home is not None:
|
|
os.environ["HERMES_HOME"] = old_home
|
|
else:
|
|
os.environ.pop("HERMES_HOME", None)
|
|
|
|
|
|
def _stop_gateway_process(profile_dir: Path) -> None:
|
|
"""Stop a running gateway process via its PID file."""
|
|
pid_file = profile_dir / "gateway.pid"
|
|
if not pid_file.exists():
|
|
return
|
|
|
|
try:
|
|
raw = pid_file.read_text(encoding="utf-8").strip()
|
|
data = json.loads(raw) if raw.startswith("{") else {"pid": int(raw)}
|
|
pid = int(data["pid"])
|
|
# Cross-profile kill refusal (#89315): the record's hermes_home stamp
|
|
# names the gateway's TRUE owner. A contaminated/poisoned gateway.pid
|
|
# inside this profile dir can point at another profile's live gateway
|
|
# — killing it starts the mutual SIGTERM restart loop from the issue.
|
|
from gateway.status import (
|
|
get_process_start_time,
|
|
recorded_gateway_home_conflicts,
|
|
terminate_pid,
|
|
)
|
|
|
|
if recorded_gateway_home_conflicts(data, expected_home=profile_dir):
|
|
print(
|
|
f"✗ Refusing to stop PID {pid}: its recorded HERMES_HOME "
|
|
f"belongs to a different profile than {profile_dir} "
|
|
"(stale/poisoned PID record, #89315)."
|
|
)
|
|
return
|
|
# Route through terminate_pid so Windows uses the appropriate
|
|
# primitive (taskkill / TerminateProcess) — raw os.kill with
|
|
# _signal.SIGKILL raises AttributeError at import time on Windows,
|
|
# and raw os.kill with SIGTERM doesn't cascade to child processes
|
|
# the same way taskkill /T does.
|
|
expected_start_time = data.get("start_time")
|
|
if expected_start_time is None:
|
|
expected_start_time = get_process_start_time(pid)
|
|
terminate_pid(pid) # graceful first
|
|
if _wait_then_force_kill([pid], {pid: expected_start_time}):
|
|
print(f"✓ Gateway stopped (PID {pid})")
|
|
else:
|
|
print(f"✓ Gateway force-stopped (PID {pid})")
|
|
except (ProcessLookupError, PermissionError):
|
|
print("✓ Gateway already stopped")
|
|
except Exception as e:
|
|
print(f"⚠ Could not stop gateway: {e}")
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Active profile (sticky default)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def get_active_profile() -> str:
|
|
"""Read the sticky active profile name."""
|
|
path = _get_active_profile_path()
|
|
try:
|
|
return path.read_text(encoding="utf-8").strip() or "default"
|
|
except (UnicodeDecodeError, OSError):
|
|
return "default"
|
|
|
|
|
|
def set_active_profile(name: str) -> None:
|
|
"""Set the sticky active profile."""
|
|
canon = normalize_profile_name(name)
|
|
validate_profile_name(canon)
|
|
if canon != "default" and not profile_exists(canon):
|
|
raise FileNotFoundError(
|
|
f"Profile '{canon}' does not exist. "
|
|
f"Create it with: hermes profile create {canon}"
|
|
)
|
|
|
|
path = _get_active_profile_path()
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
if canon == "default":
|
|
# Remove the file to indicate default
|
|
path.unlink(missing_ok=True)
|
|
else:
|
|
# Atomic write
|
|
tmp = path.with_suffix(".tmp")
|
|
tmp.write_text(canon + "\n", encoding="utf-8")
|
|
tmp.replace(path)
|
|
|
|
|
|
def _retarget_active_profile(old: str, new: str, message: str) -> None:
|
|
"""If the sticky active profile is *old*, point it at *new* and print *message*. Never raises."""
|
|
try:
|
|
if get_active_profile() == old:
|
|
set_active_profile(new)
|
|
print(message)
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
def get_active_profile_name() -> str:
|
|
"""Infer the current profile name from HERMES_HOME.
|
|
|
|
``"default"`` when unset or ``~/.hermes``; the profile name when under
|
|
``~/.hermes/profiles/<name>``; ``"custom"`` for any other path.
|
|
"""
|
|
from hermes_constants import get_hermes_home
|
|
hermes_home = get_hermes_home()
|
|
resolved = hermes_home.resolve()
|
|
|
|
default_resolved = _get_default_hermes_home().resolve()
|
|
if resolved == default_resolved:
|
|
return "default"
|
|
|
|
profiles_root = _get_profiles_root().resolve()
|
|
try:
|
|
rel = resolved.relative_to(profiles_root)
|
|
parts = rel.parts
|
|
if len(parts) == 1 and _PROFILE_ID_RE.match(parts[0]):
|
|
return parts[0]
|
|
except ValueError:
|
|
pass
|
|
|
|
return "custom"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Export / Import
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def _inside_git_checkout(path: Path) -> bool:
|
|
"""Return True when *path* lies inside a Git checkout.
|
|
|
|
Walks the path's OWN resolved ancestry for a ``.git`` marker (dir or worktree file), not
|
|
``Path.cwd()``, so the check holds when HERMES_HOME sits in a checkout but the process runs
|
|
elsewhere (cron, service manager). Resolution failure reports True so callers fall through
|
|
to a provably safe candidate.
|
|
"""
|
|
try:
|
|
resolved = path.resolve()
|
|
except (OSError, RuntimeError):
|
|
# RuntimeError: symlink loops on Python <= 3.12; fail closed either way.
|
|
return True
|
|
return any(
|
|
(candidate / ".git").exists() for candidate in (resolved, *resolved.parents)
|
|
)
|
|
|
|
|
|
def _profile_export_directory() -> Path:
|
|
"""Choose an export directory that cannot become source-tree input."""
|
|
import tempfile
|
|
|
|
export_dir = _get_default_hermes_home() / "profile-exports"
|
|
if not _inside_git_checkout(export_dir):
|
|
return export_dir
|
|
|
|
# A custom deployment may point HERMES_HOME at its source checkout. Do
|
|
# not put the automatic archive under that tree; use a sibling store and
|
|
# fall back to the OS temp directory only for the unusual case where the
|
|
# user's home itself is a checkout (e.g. a dotfiles repo).
|
|
# Per-uid temp name: a fixed /tmp/hermes-profile-exports is a predictable
|
|
# shared path another local user could pre-create (or symlink) before us.
|
|
uid_suffix = f"-{os.getuid()}" if hasattr(os, "getuid") else ""
|
|
candidates = (
|
|
Path.home() / ".hermes-profile-exports",
|
|
Path(tempfile.gettempdir()) / f"hermes-profile-exports{uid_suffix}",
|
|
)
|
|
for candidate in candidates:
|
|
if not _inside_git_checkout(candidate):
|
|
return candidate
|
|
# Fail closed: writing a secret-bearing archive into a source tree is the
|
|
# incident this helper exists to prevent (#92457). A warning on stderr
|
|
# would not stop a scripted export from recreating it.
|
|
raise ValueError(
|
|
"No safe automatic export destination: every candidate directory is "
|
|
"inside a Git checkout. Provide an explicit output path outside the "
|
|
"checkout (CLI: -o /path/outside/repo/profile.tar.gz)."
|
|
)
|
|
|
|
|
|
def get_profile_export_path(name: str, *, timestamp: Optional[str] = None) -> Path:
|
|
"""Return a managed destination for an export with no explicit output.
|
|
|
|
Kept outside the cwd and every named profile: the CLI is often run from a source checkout,
|
|
where a ``<name>.tar.gz`` default looked like a repo artifact and got committed by accident.
|
|
"""
|
|
canon = normalize_profile_name(name)
|
|
validate_profile_name(canon)
|
|
export_dir = _profile_export_directory()
|
|
export_dir.mkdir(parents=True, exist_ok=True)
|
|
# exist_ok=True would silently accept a directory (or symlink) another
|
|
# local user pre-created at a predictable path; refuse to write a
|
|
# secret-bearing archive anywhere we don't own.
|
|
if export_dir.is_symlink():
|
|
raise ValueError(
|
|
f"Export directory {export_dir} is a symlink; refusing to write "
|
|
"a profile archive through it. Provide an explicit output path."
|
|
)
|
|
if hasattr(os, "getuid") and export_dir.stat().st_uid != os.getuid():
|
|
raise ValueError(
|
|
f"Export directory {export_dir} is owned by another user; "
|
|
"refusing to write a profile archive there. Provide an explicit "
|
|
"output path."
|
|
)
|
|
stamp = timestamp or time.strftime("%Y%m%d-%H%M%S")
|
|
return export_dir / f"{canon}-{stamp}.tar.gz"
|
|
|
|
def _default_export_ignore(root_dir: Path):
|
|
"""Return an *ignore* callable for :func:`shutil.copytree`.
|
|
|
|
* **Root-level allow-list** — only entries whose name appears in
|
|
``_DEFAULT_EXPORT_INCLUDE_ROOT`` survive. Everything else (such as an unrelated ``x11-dev/``
|
|
directory in a Docker deployment where HERMES_HOME equals the cwd) is excluded.
|
|
|
|
Surviving text files are later force-redacted by :func:`_scrub_export_secrets` before the
|
|
archive is written.
|
|
"""
|
|
|
|
def _ignore(directory: str, contents: list) -> set:
|
|
# Universal exclusions (any depth) plus npm lockfiles that can appear at root.
|
|
ignored: set = {
|
|
entry for entry in contents
|
|
if entry == "__pycache__"
|
|
or entry.endswith((".sock", ".tmp"))
|
|
or entry in {"package.json", "package-lock.json"}
|
|
}
|
|
# Root-level allow-list: drop everything that isn't a known
|
|
# Hermes profile artifact.
|
|
if Path(directory) == root_dir:
|
|
ignored.update(
|
|
entry for entry in contents if entry not in _DEFAULT_EXPORT_INCLUDE_ROOT
|
|
)
|
|
return ignored
|
|
|
|
return _ignore
|
|
|
|
|
|
# Credential files dropped from named-profile exports.
|
|
_EXPORT_CREDENTIAL_FILES = frozenset({"auth.json", ".env"})
|
|
|
|
# Text / config suffixes walked during export secret scrubbing. Binary DBs,
|
|
# images, and other non-text artifacts are left alone (they may still leave
|
|
# via named-profile export — scrubbing those is a separate concern).
|
|
_EXPORT_REDACT_SUFFIXES = frozenset({
|
|
".md", ".txt", ".yaml", ".yml", ".json", ".jsonl",
|
|
".toml", ".ini", ".cfg", ".conf", ".py", ".sh",
|
|
".bash", ".zsh", ".js", ".ts", ".tsx", ".jsx",
|
|
".css", ".html", ".xml", ".csv",
|
|
})
|
|
# pathlib.Path(".cursorrules").suffix is "" — name-match these.
|
|
# ``*.env.example`` uses endswith (suffix would be ``.example``).
|
|
_EXPORT_REDACT_NAMES = frozenset({
|
|
".cursorrules",
|
|
})
|
|
|
|
|
|
def _should_redact_export_file(path: Path) -> bool:
|
|
"""True when *path* is a text-ish file we should secret-scrub on export."""
|
|
name = path.name
|
|
return (
|
|
name in _EXPORT_REDACT_NAMES
|
|
or name.lower().endswith(".env.example")
|
|
or path.suffix.lower() in _EXPORT_REDACT_SUFFIXES
|
|
)
|
|
|
|
|
|
def _scrub_export_secrets(staged: Path) -> None:
|
|
"""Force-redact secret-shaped strings in a staged export tree.
|
|
|
|
Same ``agent.redact.redact_sensitive_text(..., force=True)`` pass used by ``hermes sessions
|
|
export --redact``. Runs on the *staged copy only* so the live profile is never rewritten.
|
|
|
|
Symlinks to text files are materialized into regular files when their content changes, so
|
|
redaction never follows a link back into the source profile (``copytree(..., symlinks=True)``).
|
|
"""
|
|
from agent.redact import redact_sensitive_text
|
|
|
|
for path in staged.rglob("*"):
|
|
try:
|
|
is_link = path.is_symlink()
|
|
# Skip broken links, symlinked directories, and non-files.
|
|
if not path.is_file():
|
|
continue
|
|
except OSError:
|
|
continue
|
|
|
|
if not _should_redact_export_file(path):
|
|
continue
|
|
|
|
try:
|
|
text = path.read_text(encoding="utf-8")
|
|
except (UnicodeDecodeError, OSError):
|
|
continue
|
|
|
|
redacted = redact_sensitive_text(text, force=True)
|
|
if redacted == text:
|
|
continue
|
|
|
|
if is_link:
|
|
path.unlink()
|
|
path.write_text(redacted, encoding="utf-8")
|
|
|
|
|
|
def export_profile(name: str, output_path: str, extra_files: Optional[Dict[str, str]] = None) -> Path:
|
|
"""Export a profile to a tar.gz archive.
|
|
|
|
Credential files (``auth.json``, ``.env``) are excluded, and secret-shaped strings in staged
|
|
text files are force-redacted before the archive is written. Returns the output file path.
|
|
"""
|
|
import tempfile
|
|
|
|
canon = normalize_profile_name(name)
|
|
validate_profile_name(canon)
|
|
profile_dir = get_profile_dir(canon)
|
|
if not profile_dir.is_dir():
|
|
raise FileNotFoundError(f"Profile '{canon}' does not exist.")
|
|
|
|
output = Path(output_path)
|
|
# Archive base name without extension (.tar.gz appended by the writer).
|
|
base = str(output).removesuffix(".tar.gz").removesuffix(".tgz")
|
|
|
|
def _stage_extras(staged: Path) -> None:
|
|
for rel, content in (extra_files or {}).items():
|
|
parts = normalize_archive_parts(rel)
|
|
target = staged.joinpath(*parts)
|
|
target.parent.mkdir(parents=True, exist_ok=True)
|
|
target.write_text(content, encoding="utf-8")
|
|
|
|
# The default profile IS ~/.hermes itself (dir name ".hermes", not "default"),
|
|
# so both paths stage a filtered copy under a temp dir named after the canonical
|
|
# id: the root allow-list for default, credential exclusion for named profiles.
|
|
def _ignore_credentials(directory: str, contents: list) -> set:
|
|
return _EXPORT_CREDENTIAL_FILES & set(contents)
|
|
|
|
ignore = _default_export_ignore(profile_dir) if canon == "default" else _ignore_credentials
|
|
with tempfile.TemporaryDirectory() as tmpdir:
|
|
staged = Path(tmpdir) / canon
|
|
shutil.copytree(profile_dir, staged, symlinks=True, ignore=ignore)
|
|
_stage_extras(staged)
|
|
_scrub_export_secrets(staged)
|
|
return Path(make_targz(base, tmpdir, canon))
|
|
|
|
|
|
def import_profile(archive_path: str, name: Optional[str] = None) -> Path:
|
|
"""Import a profile from a tar.gz archive."""
|
|
import tempfile
|
|
|
|
archive = Path(archive_path)
|
|
if not archive.exists():
|
|
raise FileNotFoundError(f"Archive not found: {archive}")
|
|
|
|
top_dirs = archive_root_dirs(archive)
|
|
archive_root = top_dirs.pop() if len(top_dirs) == 1 else None
|
|
inferred_name = name or archive_root
|
|
if not inferred_name:
|
|
raise ValueError(
|
|
"Cannot determine profile name from archive. "
|
|
"Specify it explicitly: hermes profile import <archive> --name <name>"
|
|
)
|
|
if archive_root is None:
|
|
raise ValueError(
|
|
"Profile archive must contain exactly one top-level directory."
|
|
)
|
|
|
|
# Archives exported from the default profile have "default/" as top-level
|
|
# dir. Importing as "default" would target ~/.hermes itself — disallow
|
|
# that and guide the user toward a named profile.
|
|
canon = normalize_profile_name(inferred_name)
|
|
validate_profile_name(canon)
|
|
if canon == "default":
|
|
raise ValueError(
|
|
"Cannot import as 'default' — that is the built-in root profile (~/.hermes). "
|
|
"Specify a different name: hermes profile import <archive> --name <name>"
|
|
)
|
|
|
|
profile_dir = get_profile_dir(canon)
|
|
if profile_dir.exists():
|
|
raise FileExistsError(f"Profile '{canon}' already exists at {profile_dir}")
|
|
|
|
profiles_root = _get_profiles_root()
|
|
profiles_root.mkdir(parents=True, exist_ok=True)
|
|
|
|
with tempfile.TemporaryDirectory(prefix="hermes_profile_import_") as tmpdir:
|
|
staging_root = Path(tmpdir)
|
|
safe_extract_targz(archive, staging_root)
|
|
|
|
extracted = staging_root / archive_root
|
|
if not extracted.is_dir():
|
|
raise ValueError(
|
|
f"Profile archive root is missing or invalid: {archive_root}"
|
|
)
|
|
|
|
final_source = extracted
|
|
if archive_root != canon:
|
|
final_source = staging_root / canon
|
|
extracted.rename(final_source)
|
|
|
|
shutil.move(str(final_source), str(profile_dir))
|
|
|
|
return profile_dir
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Rename
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def _atomic_write_json(path: Path, data: dict) -> bool:
|
|
"""Write *data* to *path* via a sibling ``.tmp`` + rename. Returns False (tmp cleaned) on OSError."""
|
|
tmp = path.with_suffix(path.suffix + ".tmp")
|
|
try:
|
|
tmp.write_text(json.dumps(data, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
|
|
tmp.replace(path)
|
|
return True
|
|
except OSError:
|
|
try:
|
|
tmp.unlink(missing_ok=True)
|
|
except OSError:
|
|
pass
|
|
return False
|
|
|
|
|
|
def _migrate_honcho_profile_host(old_name: str, new_name: str, new_dir: Path) -> None:
|
|
"""Rename Honcho host blocks for a renamed profile without changing peers."""
|
|
old_host = f"hermes_{old_name}"
|
|
legacy_old_host = f"hermes.{old_name}"
|
|
new_host = f"hermes_{new_name}"
|
|
|
|
candidates = [
|
|
new_dir / "honcho.json",
|
|
_get_default_hermes_home() / "honcho.json",
|
|
Path.home() / ".honcho" / "config.json",
|
|
]
|
|
|
|
seen: set[Path] = set()
|
|
for path in candidates:
|
|
try:
|
|
resolved = path.resolve()
|
|
except OSError:
|
|
resolved = path
|
|
if resolved in seen or not path.is_file():
|
|
continue
|
|
seen.add(resolved)
|
|
|
|
try:
|
|
raw = json.loads(path.read_text(encoding="utf-8"))
|
|
except (OSError, json.JSONDecodeError):
|
|
continue
|
|
|
|
hosts = raw.get("hosts")
|
|
if not isinstance(hosts, dict):
|
|
continue
|
|
source_host = old_host if old_host in hosts else legacy_old_host
|
|
if source_host not in hosts:
|
|
continue
|
|
|
|
if new_host in hosts:
|
|
print(f"⚠ Honcho host block not migrated: {new_host} already exists in {path}")
|
|
continue
|
|
|
|
block = hosts[source_host]
|
|
if isinstance(block, dict) and "aiPeer" not in block:
|
|
# source_host is either ``hermes_<old>`` or legacy ``hermes.<old>``.
|
|
block["aiPeer"] = old_name
|
|
hosts[new_host] = hosts.pop(source_host)
|
|
if _atomic_write_json(path, raw):
|
|
print(f"✓ Honcho host updated: {source_host} → {new_host}")
|
|
|
|
|
|
def rename_profile(old_name: str, new_name: str) -> Path:
|
|
"""Rename a profile: directory, wrapper script, service, active_profile.
|
|
|
|
The default profile's home IS the installation root, so "renaming" it sets a presentation-only
|
|
``display_name`` in profile.yaml instead — the canonical id stays ``default`` and every
|
|
resolution path is untouched.
|
|
"""
|
|
old_canon = normalize_profile_name(old_name)
|
|
validate_profile_name(old_canon)
|
|
|
|
if old_canon == "default":
|
|
if not (new_name or "").strip():
|
|
raise ValueError("Display name cannot be empty.")
|
|
cleaned = set_profile_display_name("default", new_name)
|
|
print(f"✓ Display name set: {cleaned} (canonical id remains 'default')")
|
|
return _get_default_hermes_home()
|
|
|
|
new_canon = normalize_profile_name(new_name)
|
|
validate_profile_name(new_canon)
|
|
|
|
if new_canon == "default":
|
|
raise ValueError("Cannot rename to 'default' — it is reserved.")
|
|
|
|
old_dir = get_profile_dir(old_canon)
|
|
new_dir = get_profile_dir(new_canon)
|
|
|
|
if not old_dir.is_dir():
|
|
raise FileNotFoundError(f"Profile '{old_canon}' does not exist.")
|
|
if new_dir.exists():
|
|
raise FileExistsError(f"Profile '{new_canon}' already exists.")
|
|
|
|
# 1. Stop gateway if running
|
|
if _check_gateway_running(old_dir):
|
|
_cleanup_gateway_service(old_canon, old_dir)
|
|
_stop_gateway_process(old_dir)
|
|
|
|
# 2. Rename directory
|
|
old_dir.rename(new_dir)
|
|
print(f"✓ Renamed {old_dir.name} → {new_dir.name}")
|
|
|
|
# 3. Update profile-scoped Honcho host blocks, preserving aiPeer identity
|
|
_migrate_honcho_profile_host(old_canon, new_canon, new_dir)
|
|
|
|
# 4. Update wrapper script
|
|
remove_wrapper_script(old_canon)
|
|
collision = check_alias_collision(new_canon)
|
|
if not collision:
|
|
create_wrapper_script(new_canon)
|
|
print(f"✓ Alias updated: {new_canon}")
|
|
else:
|
|
print(f"⚠ Cannot create alias '{new_canon}' — {collision}")
|
|
|
|
# 5. Update active_profile if it pointed to old name
|
|
_retarget_active_profile(old_canon, new_canon, f"✓ Active profile updated: {new_canon}")
|
|
|
|
return new_dir
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Profile env resolution (called from _apply_profile_override)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def resolve_profile_env(profile_name: str) -> str:
|
|
"""Resolve a profile name to a HERMES_HOME path string.
|
|
|
|
Called early in the CLI entry point, before any hermes modules are imported, to set the
|
|
HERMES_HOME environment variable.
|
|
"""
|
|
canon = normalize_profile_name(profile_name)
|
|
validate_profile_name(canon)
|
|
env_home = os.environ.get("HERMES_HOME", "").strip()
|
|
if env_home:
|
|
env_path = Path(env_home)
|
|
# A profile-shaped env value means the root is the grandparent
|
|
# (mirrors get_default_hermes_root()).
|
|
root = env_path.parent.parent if env_path.parent.name == "profiles" else env_path
|
|
else:
|
|
root = _get_default_hermes_home()
|
|
if canon == "default":
|
|
return str(root)
|
|
profile_dir = root / "profiles" / canon
|
|
|
|
if not profile_dir.is_dir() or named_profile_is_deleted(profile_dir):
|
|
raise FileNotFoundError(
|
|
f"Profile '{canon}' does not exist. "
|
|
f"Create it with: hermes profile create {canon}"
|
|
)
|
|
|
|
return str(profile_dir)
|