diff --git a/hermes_cli/profile_cmd.py b/hermes_cli/profile_cmd.py index 2dd4b70b24..1157d95ae7 100644 --- a/hermes_cli/profile_cmd.py +++ b/hermes_cli/profile_cmd.py @@ -1,8 +1,8 @@ -"""``hermes profile`` command — one handler per action, dispatched by table. +"""``hermes profile`` command — one handler per action, dispatched by ``PROFILE_ACTIONS``. -Moved out of ``hermes_cli/main.py:cmd_profile`` (was a 600-line if/elif chain). -``hermes_cli.main`` re-exports ``cmd_profile`` so existing callers and -monkeypatches on ``hermes_cli.main.cmd_profile`` keep working. +``hermes_cli.main`` re-exports ``cmd_profile`` so existing callers and monkeypatches on +``hermes_cli.main.cmd_profile`` keep working. Imports from ``hermes_cli.profiles`` stay +lazy (inside each handler) so tests can monkeypatch the module attributes. """ from __future__ import annotations @@ -12,6 +12,40 @@ import os import sys +def _die(msg: str, code: int = 1, *, err: bool = False) -> None: + print(msg, file=sys.stderr if err else sys.stdout) + sys.exit(code) + + +def _confirm(prompt: str) -> bool: + """y/N prompt; EOF / Ctrl-C count as "no".""" + try: + answer = input(prompt).strip().lower() + except (EOFError, KeyboardInterrupt): + answer = "" + return answer in {"y", "yes"} + + +def _is_active(p, active: str) -> bool: + return p.name == active or (active == "default" and p.is_default) + + +def _env_file_has_key(env_path: Path, key: str) -> bool: + """True when *key* is assigned in *env_path*. Read as utf-8-sig: a Notepad-edited .env can + carry a BOM that would hide the first key behind U+FEFF. A mis-encoded file (UnicodeDecodeError + is a ValueError, not OSError) must not abort the install preview — skip the pre-check.""" + if not env_path.is_file(): + return False + try: + for raw in env_path.read_text(encoding="utf-8-sig").splitlines(): + line = raw.strip() + if line and not line.startswith("#") and line.split("=", 1)[0].strip() == key: + return True + except (OSError, UnicodeDecodeError): + pass + return False + + def _render_distribution_plan(plan) -> None: """Print a human-readable summary of a pending distribution install.""" from hermes_cli.profile_distribution import MANIFEST_FILENAME @@ -26,13 +60,10 @@ def _render_distribution_plan(plan) -> None: print(f" Source: {plan.provenance}") print(f" Target: {plan.target_dir}") if plan.existing: - # Distinguish "updating an existing distribution" (well-understood - # semantics — dist-owned overwritten, config preserved, user data - # untouched) from "overwriting a hand-built plain profile" (same - # mechanics but the user didn't sign up for this when they created - # the profile manually). - existing_is_distribution = (plan.target_dir / MANIFEST_FILENAME).is_file() - if existing_is_distribution: + # Updating an existing distribution (dist-owned overwritten, config preserved, user + # data untouched) vs overwriting a hand-built plain profile (same mechanics, but the + # user didn't sign up for it). + if (plan.target_dir / MANIFEST_FILENAME).is_file(): print(" (profile exists — will overwrite distribution-owned files only)") else: print( @@ -45,30 +76,10 @@ def _render_distribution_plan(plan) -> None: print("\n Env vars:") for er in mf.env_requires: tag = "required" if er.required else "optional" - # Check both the current shell environment and the target profile's - # .env file so we don't nag about keys the user already has set up. - already = os.environ.get(er.name) is not None - if not already and plan.target_dir.is_dir(): - env_path = plan.target_dir / ".env" - if env_path.is_file(): - try: - # .env is written as UTF-8 everywhere in the codebase, - # but a Notepad-edited file can carry a BOM — read as - # utf-8-sig so the first key isn't hidden behind - # U+FEFF (#62617). - for raw in env_path.read_text(encoding="utf-8-sig").splitlines(): - line = raw.strip() - if not line or line.startswith("#"): - continue - key = line.split("=", 1)[0].strip() - if key == er.name: - already = True - break - except (OSError, UnicodeDecodeError): - # UnicodeDecodeError is a ValueError, not an OSError, so - # the old guard let a mis-encoded .env abort the whole - # install preview. Skip the pre-check instead. - pass + # Shell environment OR the target profile's .env — don't nag about set keys. + already = os.environ.get(er.name) is not None or ( + plan.target_dir.is_dir() and _env_file_has_key(plan.target_dir / ".env", er.name) + ) status = "✓ set" if already else ("needs setting" if er.required else "—") line = f" • {er.name} ({tag}, {status})" if er.description: @@ -84,288 +95,171 @@ def _render_distribution_plan(plan) -> None: def _profile_status(args): """Bare ``hermes profile`` — show current profile status.""" from hermes_constants import display_hermes_home - from hermes_cli.profiles import ( - get_active_profile_name, - list_profiles, - ) - - from hermes_cli.profiles import format_profile_label - + from hermes_cli.profiles import format_profile_label, get_active_profile_name, list_profiles profile_name = get_active_profile_name() dhh = display_hermes_home() - - profiles = list_profiles() - current = next( - ( - p - for p in profiles - if p.name == profile_name - or (profile_name == "default" and p.is_default) - ), - None, - ) - label = format_profile_label( - profile_name, current.display_name if current else "" - ) + current = next((p for p in list_profiles() if _is_active(p, profile_name)), None) + label = format_profile_label(profile_name, current.display_name if current else "") print(f"\nActive profile: {label}") print(f"Path: {dhh}") - if current is not None: p = current if p.model: - print( - f"Model: {p.model}" - + (f" ({p.provider})" if p.provider else "") - ) - print( - f"Gateway: {'running' if p.gateway_running else 'stopped'}" - ) + print(f"Model: {p.model}" + (f" ({p.provider})" if p.provider else "")) + print(f"Gateway: {'running' if p.gateway_running else 'stopped'}") print(f"Skills: {p.skill_count} installed") if p.alias_path: - alias_display = p.alias_name or p.name - print(f"Alias: {alias_display} → hermes -p {p.name}") + print(f"Alias: {p.alias_name or p.name} → hermes -p {p.name}") print() - return def _profile_list(args): - from hermes_cli.profiles import ( - get_active_profile_name, - list_profiles, - ) - - from hermes_cli.profiles import format_profile_label - + from hermes_cli.profiles import format_profile_label, get_active_profile_name, list_profiles profiles = list_profiles() active = get_active_profile_name() - if not profiles: print("No profiles found.") return - - # Header - print( - f"\n {'Profile':<16} {'Model':<28} {'Gateway':<12} " - f"{'Alias':<12} {'Distribution'}" - ) - print( - f" {'─' * 15} {'─' * 27} {'─' * 11} " - f"{'─' * 11} {'─' * 20}" - ) - + print(f"\n {'Profile':<16} {'Model':<28} {'Gateway':<12} {'Alias':<12} {'Distribution'}") + print(f" {'─' * 15} {'─' * 27} {'─' * 11} {'─' * 11} {'─' * 20}") for p in profiles: - marker = ( - " ◆" - if (p.name == active or (active == "default" and p.is_default)) - else " " - ) + marker = " ◆" if _is_active(p, active) else " " name = format_profile_label(p.name, p.display_name) model = (p.model or "—")[:26] gw = "running" if p.gateway_running else "stopped" - alias = (p.alias_name or p.name) if p.alias_path else "—" - if p.is_default: - alias = "—" - if p.distribution_name: - dist = f"{p.distribution_name}@{p.distribution_version or '?'}" - dist = dist[:30] - else: - dist = "—" + alias = (p.alias_name or p.name) if p.alias_path and not p.is_default else "—" + dist = f"{p.distribution_name}@{p.distribution_version or '?'}"[:30] if p.distribution_name else "—" print(f"{marker}{name:<15} {model:<28} {gw:<12} {alias:<12} {dist}") print() def _profile_use(args): from hermes_cli.profiles import set_active_profile - name = args.profile_name try: set_active_profile(name) - if name == "default": - print("Switched to: default (~/.hermes)") - else: - print(f"Switched to: {name}") + print("Switched to: default (~/.hermes)" if name == "default" else f"Switched to: {name}") except (ValueError, FileNotFoundError) as e: - print(f"Error: {e}") - sys.exit(1) + _die(f"Error: {e}") def _profile_create(args): from hermes_cli.profiles import ( - _get_wrapper_dir, - _is_wrapper_dir_in_path, - check_alias_collision, - create_profile, - create_wrapper_script, - get_active_profile_name, - seed_profile_skills, + _get_wrapper_dir, _is_wrapper_dir_in_path, check_alias_collision, create_profile, + create_wrapper_script, get_active_profile_name, seed_profile_skills, ) - name = args.profile_name clone = getattr(args, "clone", False) clone_all = getattr(args, "clone_all", False) no_alias = getattr(args, "no_alias", False) no_skills = getattr(args, "no_skills", False) - + clone_from = getattr(args, "clone_from", None) + clone_config = clone or clone_from is not None + cloned = clone_config or clone_all try: - clone_from = getattr(args, "clone_from", None) - clone_config = clone or clone_from is not None - profile_dir = create_profile( - name=name, - clone_from=clone_from, - clone_all=clone_all, - clone_config=clone_config, - no_alias=no_alias, - no_skills=no_skills, - description=getattr(args, "description", None), + name=name, clone_from=clone_from, clone_all=clone_all, clone_config=clone_config, + no_alias=no_alias, no_skills=no_skills, description=getattr(args, "description", None), ) - print(f"\nProfile '{name}' created at {profile_dir}") - - if clone_config or clone_all: - source_label = ( - getattr(args, "clone_from", None) or get_active_profile_name() - ) - if clone_all: - print( - f"Full copy from {source_label} " - "(excluding session history, backups, and snapshots)." - ) - else: - print( - f"Cloned config, .env, SOUL.md, and skills from {source_label}." - ) - - # Auto-clone Honcho config for the new profile (only with clone operations) - if clone_config or clone_all: - try: - from plugins.memory.honcho.cli import clone_honcho_for_profile - - if clone_honcho_for_profile(name): - print(f"Honcho config cloned (peer: {name})") - except Exception: - pass # Honcho plugin not installed or not configured - - # Seed bundled skills for fresh profiles only. Clone operations - # already copied the source profile's skills, including any - # user-installed or intentionally removed skills. - if not (clone_config or clone_all): - result = seed_profile_skills(profile_dir) - if result and result.get("skipped_opt_out"): - print( - "No bundled skills seeded (--no-skills). " - "Delete .no-bundled-skills in the profile to opt back in." - ) - elif result: - copied = len(result.get("copied", [])) - print(f"{copied} bundled skills synced.") - else: - print( - "⚠ Skills could not be seeded. Run `{} update` to retry.".format( - name - ) - ) - - # Create wrapper alias - if not no_alias: - collision = check_alias_collision(name) - if collision: - print(f"\n⚠ Cannot create alias '{name}' — {collision}") - print( - f" Choose a custom alias: hermes profile alias {name} --name " - ) - print(f" Or access via flag: hermes -p {name} chat") - else: - wrapper_path = create_wrapper_script(name) - if wrapper_path: - print(f"Wrapper created: {wrapper_path}") - if not _is_wrapper_dir_in_path(): - print(f"\n⚠ {_get_wrapper_dir()} is not in your PATH.") - print( - " Add to your shell config (~/.bashrc or ~/.zshrc):" - ) - print(' export PATH="$HOME/.local/bin:$PATH"') - - # Profile dir for display - try: - profile_dir_display = "~/" + profile_dir.relative_to(Path.home()).as_posix() - except ValueError: - profile_dir_display = str(profile_dir) - - # Next steps - print("\nNext steps:") - print(f" {name} setup Configure API keys and model") - print(f" {name} chat Start chatting") - print(f" {name} gateway start Start the messaging gateway") - if clone or clone_all: - print(f"\n Edit {profile_dir_display}/.env for different API keys") - print(f" Edit {profile_dir_display}/SOUL.md for different personality") - else: - print( - f"\n ⚠ This profile has no API keys yet. Run '{name} setup' first," - ) - print(" or it will inherit keys from your shell environment.") - print(f" Edit {profile_dir_display}/SOUL.md to customize personality") - print() - except (ValueError, FileExistsError, FileNotFoundError) as e: - print(f"Error: {e}") - sys.exit(1) + _die(f"Error: {e}") + print(f"\nProfile '{name}' created at {profile_dir}") + if cloned: + source_label = clone_from or get_active_profile_name() + if clone_all: + print(f"Full copy from {source_label} (excluding session history, backups, and snapshots).") + else: + print(f"Cloned config, .env, SOUL.md, and skills from {source_label}.") + # Auto-clone Honcho config for the new profile (only with clone operations) + try: + from plugins.memory.honcho.cli import clone_honcho_for_profile + if clone_honcho_for_profile(name): + print(f"Honcho config cloned (peer: {name})") + except Exception: + pass # Honcho plugin not installed or not configured + else: + # Fresh profiles only: clones already carry the source's (user-curated) skills. + result = seed_profile_skills(profile_dir) + if result and result.get("skipped_opt_out"): + print("No bundled skills seeded (--no-skills). Delete .no-bundled-skills in the profile to opt back in.") + elif result: + print(f"{len(result.get('copied', []))} bundled skills synced.") + else: + print(f"⚠ Skills could not be seeded. Run `{name} update` to retry.") + if not no_alias: + collision = check_alias_collision(name) + if collision: + print(f"\n⚠ Cannot create alias '{name}' — {collision}") + print(f" Choose a custom alias: hermes profile alias {name} --name ") + print(f" Or access via flag: hermes -p {name} chat") + else: + wrapper_path = create_wrapper_script(name) + if wrapper_path: + print(f"Wrapper created: {wrapper_path}") + if not _is_wrapper_dir_in_path(): + print(f"\n⚠ {_get_wrapper_dir()} is not in your PATH.") + print(" Add to your shell config (~/.bashrc or ~/.zshrc):") + print(' export PATH="$HOME/.local/bin:$PATH"') + try: + profile_dir_display = "~/" + profile_dir.relative_to(Path.home()).as_posix() + except ValueError: + profile_dir_display = str(profile_dir) + print("\nNext steps:") + print(f" {name} setup Configure API keys and model") + print(f" {name} chat Start chatting") + print(f" {name} gateway start Start the messaging gateway") + if clone or clone_all: + print(f"\n Edit {profile_dir_display}/.env for different API keys") + print(f" Edit {profile_dir_display}/SOUL.md for different personality") + else: + print(f"\n ⚠ This profile has no API keys yet. Run '{name} setup' first,") + print(" or it will inherit keys from your shell environment.") + print(f" Edit {profile_dir_display}/SOUL.md to customize personality") + print() def _profile_delete(args): from hermes_cli.profiles import delete_profile - - name = args.profile_name - yes = getattr(args, "yes", False) try: - delete_profile(name, yes=yes) + delete_profile(args.profile_name, yes=getattr(args, "yes", False)) except (ValueError, FileNotFoundError) as e: - print(f"Error: {e}") - sys.exit(1) + _die(f"Error: {e}") + + +def _describe_target_dir(name: str) -> Path: + """Profile dir for ``describe``: ``default`` maps to the CURRENT home (get_hermes_home), + everything else to its named directory.""" + from hermes_cli import profiles as _profiles_mod + if _profiles_mod.normalize_profile_name(name) == "default": + from hermes_constants import get_hermes_home as _hh + return Path(_hh()) + return _profiles_mod.get_profile_dir(name) def _profile_describe(args): from hermes_cli import profiles as _profiles_mod - all_flag = bool(getattr(args, "all_missing", False)) auto_flag = bool(getattr(args, "auto", False)) overwrite_flag = bool(getattr(args, "overwrite", False)) text_value = getattr(args, "text", None) name = getattr(args, "profile_name", None) - if all_flag and not auto_flag: - print("profile describe: --all requires --auto", file=sys.stderr) - sys.exit(2) + _die("profile describe: --all requires --auto", 2, err=True) if all_flag and (text_value or name): - print( - "profile describe: --all is mutually exclusive with a profile name / --text", - file=sys.stderr, - ) - sys.exit(2) + _die("profile describe: --all is mutually exclusive with a profile name / --text", 2, err=True) if not all_flag and not name: - print("profile describe: profile name is required (or --all --auto)", file=sys.stderr) - sys.exit(2) + _die("profile describe: profile name is required (or --all --auto)", 2, err=True) if text_value and auto_flag: - print( - "profile describe: --text is mutually exclusive with --auto", - file=sys.stderr, - ) - sys.exit(2) + _die("profile describe: --text is mutually exclusive with --auto", 2, err=True) # Show current description if no operation requested. if name and not text_value and not auto_flag: try: - if _profiles_mod.normalize_profile_name(name) == "default": - from hermes_constants import get_hermes_home as _hh - profile_dir = Path(_hh()) - else: - profile_dir = _profiles_mod.get_profile_dir(name) + profile_dir = _describe_target_dir(name) except Exception as exc: - print(f"Error: {exc}", file=sys.stderr) - sys.exit(1) + _die(f"Error: {exc}", err=True) if not profile_dir.is_dir(): - print(f"Error: profile '{name}' not found", file=sys.stderr) - sys.exit(1) + _die(f"Error: profile '{name}' not found", err=True) meta = _profiles_mod.read_profile_meta(profile_dir) desc = meta.get("description") or "" if not desc: @@ -378,234 +272,146 @@ def _profile_describe(args): # --text path: just write the user-authored description. if text_value: try: - if _profiles_mod.normalize_profile_name(name) == "default": - from hermes_constants import get_hermes_home as _hh - profile_dir = Path(_hh()) - else: - profile_dir = _profiles_mod.get_profile_dir(name) - _profiles_mod.write_profile_meta( - profile_dir, - description=text_value, - description_auto=False, - ) + _profiles_mod.write_profile_meta(_describe_target_dir(name), description=text_value, description_auto=False) print(f"Description updated for '{name}'.") except Exception as exc: - print(f"Error: {exc}", file=sys.stderr) - sys.exit(1) + _die(f"Error: {exc}", err=True) sys.exit(0) # --auto path: invoke the LLM describer. from hermes_cli import profile_describer as _pd - if all_flag: targets = _pd.list_describable_profiles(missing_only=True) if not targets: - print("All profiles already have descriptions.") - sys.exit(0) + _die("All profiles already have descriptions.", 0) else: targets = [name] - ok_count = 0 - fail_count = 0 for tgt in targets: outcome = _pd.describe_profile(tgt, overwrite=overwrite_flag) if outcome.ok: ok_count += 1 print(f"Described '{outcome.profile_name}': {outcome.description}") else: - fail_count += 1 - print( - f"profile describe {outcome.profile_name}: {outcome.reason}", - file=sys.stderr, - ) - if not all_flag: - sys.exit(0 if ok_count == 1 else 1) - sys.exit(0 if ok_count > 0 else 1) + print(f"profile describe {outcome.profile_name}: {outcome.reason}", file=sys.stderr) + sys.exit(0 if (ok_count > 0 if all_flag else ok_count == 1) else 1) def _profile_show(args): name = args.profile_name from hermes_cli.profiles import ( - get_profile_dir, - profile_exists, - _read_config_model, - _check_gateway_running, - _served_by_running_multiplexer, - _count_skills, - _read_distribution_meta, - _get_wrapper_dir, - find_alias_for_profile, - format_profile_label, - read_profile_meta, + get_profile_dir, profile_exists, _read_config_model, _check_gateway_running, + _served_by_running_multiplexer, _count_skills, _read_distribution_meta, _wrapper_path, + find_alias_for_profile, format_profile_label, read_profile_meta, ) - if not profile_exists(name): - print(f"Error: Profile '{name}' does not exist.") - sys.exit(1) + _die(f"Error: Profile '{name}' does not exist.") profile_dir = get_profile_dir(name) model, provider = _read_config_model(profile_dir) gw = _check_gateway_running(profile_dir) or _served_by_running_multiplexer(name) - skills = _count_skills(profile_dir) dist_name, dist_version, dist_source = _read_distribution_meta(profile_dir) alias_name = find_alias_for_profile(name) display = read_profile_meta(profile_dir).get("display_name", "") - print(f"\nProfile: {format_profile_label(name, display)}") print(f"Path: {profile_dir}") if model: print(f"Model: {model}" + (f" ({provider})" if provider else "")) print(f"Gateway: {'running' if gw else 'stopped'}") - print(f"Skills: {skills}") - print( - f".env: {'exists' if (profile_dir / '.env').exists() else 'not configured'}" - ) - print( - f"SOUL.md: {'exists' if (profile_dir / 'SOUL.md').exists() else 'not configured'}" - ) + print(f"Skills: {_count_skills(profile_dir)}") + print(f".env: {'exists' if (profile_dir / '.env').exists() else 'not configured'}") + print(f"SOUL.md: {'exists' if (profile_dir / 'SOUL.md').exists() else 'not configured'}") if dist_name: print(f"Distribution: {dist_name}@{dist_version or '?'}") if dist_source: print(f"Installed from: {dist_source}") print(f" (run `hermes profile info {name}` for full manifest)") if alias_name: - is_windows = sys.platform == "win32" - wrapper = _get_wrapper_dir() / (f"{alias_name}.bat" if is_windows else alias_name) - print(f"Alias: {alias_name} → hermes -p {name} ({wrapper})") + print(f"Alias: {alias_name} → hermes -p {name} ({_wrapper_path(alias_name)})") print() def _profile_alias(args): from hermes_cli.profiles import ( - _get_wrapper_dir, - _is_wrapper_dir_in_path, - check_alias_collision, - create_wrapper_script, - remove_wrapper_script, + _get_wrapper_dir, _is_wrapper_dir_in_path, check_alias_collision, create_wrapper_script, + profile_exists, remove_wrapper_script, validate_alias_name, ) - name = args.profile_name remove = getattr(args, "remove", False) custom_name = getattr(args, "alias_name", None) - - from hermes_cli.profiles import profile_exists, validate_alias_name - if not profile_exists(name): - print(f"Error: Profile '{name}' does not exist.") - sys.exit(1) - + _die(f"Error: Profile '{name}' does not exist.") alias_name = custom_name or name - try: validate_alias_name(alias_name) except ValueError as exc: - print(f"Error: {exc}") - sys.exit(1) - + _die(f"Error: {exc}") if remove: if remove_wrapper_script(alias_name): print(f"✓ Removed alias '{alias_name}'") else: print(f"No alias '{alias_name}' found to remove.") - else: - collision = check_alias_collision(alias_name) - if collision: - print(f"Error: {collision}") - sys.exit(1) - wrapper_path = create_wrapper_script( - alias_name, target=name if custom_name else None - ) - if wrapper_path: - print(f"✓ Alias created: {wrapper_path}") - if not _is_wrapper_dir_in_path(): - print(f"⚠ {_get_wrapper_dir()} is not in your PATH.") + return + collision = check_alias_collision(alias_name) + if collision: + _die(f"Error: {collision}") + wrapper_path = create_wrapper_script(alias_name, target=name if custom_name else None) + if wrapper_path: + print(f"✓ Alias created: {wrapper_path}") + if not _is_wrapper_dir_in_path(): + print(f"⚠ {_get_wrapper_dir()} is not in your PATH.") def _profile_rename(args): from hermes_cli.profiles import normalize_profile_name, rename_profile - try: new_dir = rename_profile(args.old_name, args.new_name) if normalize_profile_name(args.old_name) != "default": print(f"\nProfile renamed: {args.old_name} → {args.new_name}") print(f"Path: {new_dir}\n") except (ValueError, FileExistsError, FileNotFoundError) as e: - print(f"Error: {e}") - sys.exit(1) + _die(f"Error: {e}") def _profile_export(args): from hermes_cli.profiles import export_profile, get_profile_export_path - name = args.profile_name try: output = args.output or str(get_profile_export_path(name)) result_path = export_profile(name, output) print(f"✓ Exported '{name}' to {result_path}") except (ValueError, FileNotFoundError, OSError) as e: - print(f"Error: {e}") - sys.exit(1) + _die(f"Error: {e}") def _profile_import(args): - from hermes_cli.profiles import ( - check_alias_collision, - create_wrapper_script, - ) - - from hermes_cli.profiles import import_profile - + from hermes_cli.profiles import check_alias_collision, create_wrapper_script, import_profile try: - profile_dir = import_profile( - args.archive, name=getattr(args, "import_name", None) - ) + profile_dir = import_profile(args.archive, name=getattr(args, "import_name", None)) name = profile_dir.name print(f"✓ Imported profile '{name}' at {profile_dir}") - - # Offer to create alias - collision = check_alias_collision(name) - if not collision: + if not check_alias_collision(name): wrapper_path = create_wrapper_script(name) if wrapper_path: print(f" Wrapper created: {wrapper_path}") print() except (ValueError, FileExistsError, FileNotFoundError) as e: - print(f"Error: {e}") - sys.exit(1) + _die(f"Error: {e}") def _profile_install(args): import tempfile - from hermes_cli.profile_distribution import ( - plan_install, - install_distribution, - DistributionError, - ) - + from hermes_cli.profile_distribution import DistributionError, install_distribution, plan_install try: - # Preview: stage the distribution into a scratch dir, show the - # manifest, then do the real install. The double-stage avoids - # any side-effects if the user declines. + # Preview: stage into a scratch dir, show the manifest, then do the real install. + # The double-stage avoids any side-effects if the user declines. with tempfile.TemporaryDirectory(prefix="hermes_dist_preview_") as tmp: - plan = plan_install( - args.source, - Path(tmp), - override_name=getattr(args, "install_name", None), - ) + plan = plan_install(args.source, Path(tmp), override_name=getattr(args, "install_name", None)) _render_distribution_plan(plan) - - if not getattr(args, "yes", False): - try: - answer = input("\nProceed with install? [y/N] ").strip().lower() - except (EOFError, KeyboardInterrupt): - answer = "" - if answer not in {"y", "yes"}: - print("Install cancelled.") - return - + if not getattr(args, "yes", False) and not _confirm("\nProceed with install? [y/N] "): + print("Install cancelled.") + return plan = install_distribution( - args.source, - name=getattr(args, "install_name", None), - force=getattr(args, "force", False), + args.source, name=getattr(args, "install_name", None), force=getattr(args, "force", False), create_alias=getattr(args, "alias", False), ) print(f"\n✓ Installed '{plan.manifest.name}' v{plan.manifest.version}") @@ -622,29 +428,20 @@ def _profile_install(args): ) print(f"\n Use with: hermes -p {plan.manifest.name} chat") except (DistributionError, ValueError) as e: - print(f"Error: {e}") - sys.exit(1) + _die(f"Error: {e}") def _profile_update(args): - from hermes_cli.profile_distribution import ( - update_distribution, - read_manifest, - DistributionError, - ) + from hermes_cli.profile_distribution import DistributionError, read_manifest, update_distribution from hermes_cli.profiles import get_profile_dir, normalize_profile_name - - name = args.profile_name try: - canon = normalize_profile_name(name) + canon = normalize_profile_name(args.profile_name) current = read_manifest(get_profile_dir(canon)) if current is None: - print( + _die( f"Error: Profile '{canon}' is not a distribution (no distribution.yaml). " "Only profiles installed via `hermes profile install` can be updated." ) - sys.exit(1) - force_config = getattr(args, "force_config", False) if not getattr(args, "yes", False): print(f"\nUpdate '{canon}' from: {current.source or '(no source)'}") @@ -654,54 +451,41 @@ def _profile_update(args): else: print(" config.yaml will be preserved (pass --force-config to overwrite).") print(" User data (memories, sessions, auth, .env) will NOT be touched.") - try: - answer = input("\nProceed? [y/N] ").strip().lower() - except (EOFError, KeyboardInterrupt): - answer = "" - if answer not in {"y", "yes"}: + if not _confirm("\nProceed? [y/N] "): print("Update cancelled.") return - plan = update_distribution(canon, force_config=force_config) print(f"\n✓ Updated '{plan.manifest.name}' → v{plan.manifest.version}") if plan.has_cron: - print( - " Cron files were refreshed. Review with: " - f"hermes -p {plan.manifest.name} cron list" - ) + print(f" Cron files were refreshed. Review with: hermes -p {plan.manifest.name} cron list") except (DistributionError, ValueError) as e: - print(f"Error: {e}") - sys.exit(1) + _die(f"Error: {e}") + + +_INFO_FIELDS = ( + ("description", "Description: "), + ("author", "Author: "), + ("license", "License: "), + ("hermes_requires", "Requires: Hermes "), + ("source", "Source: "), + ("installed_at", "Installed: "), +) def _profile_info(args): from hermes_cli.profile_distribution import describe_distribution, DistributionError - try: data = describe_distribution(args.profile_name) except (DistributionError, ValueError) as e: - print(f"Error: {e}") - sys.exit(1) + _die(f"Error: {e}") if not data: - print( - f"Profile '{args.profile_name}' is not a distribution " - "(no distribution.yaml)." - ) + print(f"Profile '{args.profile_name}' is not a distribution (no distribution.yaml).") return print(f"\nDistribution: {data.get('name')}") print(f"Version: {data.get('version', '?')}") - if data.get("description"): - print(f"Description: {data['description']}") - if data.get("author"): - print(f"Author: {data['author']}") - if data.get("license"): - print(f"License: {data['license']}") - if data.get("hermes_requires"): - print(f"Requires: Hermes {data['hermes_requires']}") - if data.get("source"): - print(f"Source: {data['source']}") - if data.get("installed_at"): - print(f"Installed: {data['installed_at']}") + for key, label in _INFO_FIELDS: + if data.get(key): + print(f"{label}{data[key]}") env_reqs = data.get("env_requires") or [] if env_reqs: print("\nEnvironment variables:") diff --git a/hermes_cli/profile_describer.py b/hermes_cli/profile_describer.py index c6094c8f8f..f21e0a3854 100644 --- a/hermes_cli/profile_describer.py +++ b/hermes_cli/profile_describer.py @@ -1,13 +1,12 @@ """Profile describer — auto-generate ``description`` for a profile. -Design notes ------------ - Mirrors the shape of ``hermes_cli/kanban_specify.py``: lazy aux client -import inside the function, lenient response parse, never raises on expected failure modes. - Reads -at most ``MAX_SKILLS_FOR_PROMPT`` skill names to keep the prompt bounded. +Mirrors ``hermes_cli/kanban_specify.py``: lazy aux client import, lenient response parse, +never raises on expected failure modes. Reads at most ``MAX_SKILLS_FOR_PROMPT`` skill +names to keep the prompt bounded. """ from __future__ import annotations -import json import logging import re from dataclasses import dataclass @@ -19,9 +18,7 @@ from agent.skill_utils import is_excluded_skill_path logger = logging.getLogger(__name__) -# Cap on how many skill names we feed the LLM. Profiles with 200+ -# skills (uncommon but possible) would blow context otherwise. The cap -# is per-category — see _collect_skills. +# Cap on skill names fed to the LLM (200+ skill profiles would blow context). MAX_SKILLS_FOR_PROMPT = 60 @@ -79,11 +76,8 @@ class DescribeOutcome: def _collect_skills(profile_dir: Path) -> list[str]: - """Return every (non-excluded) skill name in a profile, sorted. - - Format ``category/skill_name`` (category = immediate subdir under ``skills/``); skills - directly under ``skills/`` show as bare ``skill_name``. - """ + """Sorted non-excluded skill names: ``category/skill_name`` (category = immediate subdir + under ``skills/``), or bare ``skill_name`` for skills directly under ``skills/``.""" skills_dir = profile_dir / "skills" if not skills_dir.is_dir(): return [] @@ -92,24 +86,18 @@ def _collect_skills(profile_dir: Path) -> list[str]: if is_excluded_skill_path(md): continue try: - rel = md.relative_to(skills_dir) + parts = md.relative_to(skills_dir).parts[:-1] # drop SKILL.md except ValueError: continue - parts = rel.parts[:-1] # drop SKILL.md filename - if not parts: - continue - # parts[-1] is the skill dir name; parts[0] is the top-level category - names.append(parts[0] if len(parts) == 1 else f"{parts[0]}/{parts[-1]}") + if parts: + names.append(parts[0] if len(parts) == 1 else f"{parts[0]}/{parts[-1]}") names.sort() return names def _sample_skills(names: list[str]) -> list[str]: - """Cap *names* to the prompt budget with evenly-spaced picks. - - Skills earlier in the alphabet aren't more important, so sample across the whole list rather - than taking the head — a profile with skills A..Z must not be described as "starts with A". - """ + """Cap *names* to the prompt budget with evenly-spaced picks: alphabetical position isn't + importance, so a profile with skills A..Z must not read as "starts with A".""" if len(names) <= MAX_SKILLS_FOR_PROMPT: return names step = len(names) / MAX_SKILLS_FOR_PROMPT @@ -117,42 +105,19 @@ def _sample_skills(names: list[str]) -> list[str]: def _extract_json_blob(raw: str) -> Optional[dict]: - if not raw: - return None - stripped = _FENCE_RE.sub("", raw.strip()) - first = stripped.find("{") - last = stripped.rfind("}") - if first == -1 or last == -1 or last <= first: - return None - try: - val = json.loads(stripped[first : last + 1]) - except ValueError: - return None - return val if isinstance(val, dict) else None + from hermes_cli.kanban_specify import _extract_json_blob as _extract + return _extract(raw, _FENCE_RE) -def describe_profile( - profile_name: str, - *, - overwrite: bool = False, - timeout: Optional[int] = None, -) -> DescribeOutcome: - """Auto-generate a description for one profile. +def describe_profile(profile_name: str, *, overwrite: bool = False, timeout: Optional[int] = None) -> DescribeOutcome: + """Auto-generate a description for one profile. Expected failures (profile missing, no aux + client, API error, malformed response) return ``ok=False`` so a sweep continues. - Returns an outcome describing what happened. Never raises for expected failure modes (profile - missing, no aux client configured, API error, malformed response) — those surface via - ``ok=False`` so a sweep can continue past individual failures. - - ``overwrite`` controls whether an existing user-authored description is replaced. By default we - refuse to overwrite a description with ``description_auto: false`` to protect curated text. - Auto-generated descriptions (``description_auto: true``) are always replaceable. - """ + ``overwrite`` allows replacing a user-authored (``description_auto: false``) description; + auto-generated ones are always replaceable.""" canon = profiles_mod.normalize_profile_name(profile_name) - if not profiles_mod.profile_exists(canon): - # Special case: "default" exists as a virtual profile name - # mapped to the default home dir. profile_exists() handles it. + if not profiles_mod.profile_exists(canon): # handles the virtual "default" name return DescribeOutcome(canon, False, "profile not found") - try: if canon == "default": from hermes_constants import get_hermes_home # type: ignore @@ -161,53 +126,32 @@ def describe_profile( profile_dir = profiles_mod.get_profile_dir(canon) except Exception as exc: return DescribeOutcome(canon, False, f"cannot resolve profile dir: {exc}") - - # Honor curated descriptions unless --overwrite. existing = profiles_mod.read_profile_meta(profile_dir) if existing.get("description") and not existing.get("description_auto") and not overwrite: return DescribeOutcome( - canon, - False, - "profile already has a user-authored description " - "(use --overwrite to replace)", + canon, False, "profile already has a user-authored description (use --overwrite to replace)" ) - all_skills = _collect_skills(profile_dir) - skill_count = len(all_skills) - skill_names = _sample_skills(all_skills) - skill_list = "\n".join(f" - {n}" for n in skill_names) or " (no skills installed)" - - # Read model + provider from the profile's config. + skill_list = "\n".join(f" - {n}" for n in _sample_skills(all_skills)) or " (no skills installed)" try: model, provider = profiles_mod._read_config_model(profile_dir) except Exception: model, provider = None, None - try: from agent.auxiliary_client import call_llm # type: ignore except Exception as exc: logger.debug("describe: auxiliary client import failed: %s", exc) return DescribeOutcome(canon, False, "auxiliary client unavailable") - user_msg = _USER_TEMPLATE.format( - name=canon, - model=(model or "(unset)"), - provider=(provider or "(unset)"), - skill_count=skill_count, - skill_cap=MAX_SKILLS_FOR_PROMPT, - skill_list=skill_list, + name=canon, model=(model or "(unset)"), provider=(provider or "(unset)"), skill_count=len(all_skills), + skill_cap=MAX_SKILLS_FOR_PROMPT, skill_list=skill_list, ) - try: - # Route through call_llm so auxiliary.profile_describer.* config - # (provider/model/base_url, extra_body, reasoning_effort, retries) - # all apply — the direct-create path dropped extra_body (#35566). + # call_llm applies auxiliary.profile_describer.* config (provider/model/base_url, + # extra_body, reasoning_effort, retries); the direct-create path dropped extra_body. resp = call_llm( task="profile_describer", - messages=[ - {"role": "system", "content": _SYSTEM_PROMPT}, - {"role": "user", "content": user_msg}, - ], + messages=[{"role": "system", "content": _SYSTEM_PROMPT}, {"role": "user", "content": user_msg}], temperature=0.3, max_tokens=400, timeout=timeout or 60, @@ -215,15 +159,13 @@ def describe_profile( except Exception as exc: logger.info("describe: API call failed for %s (%s)", canon, exc) return DescribeOutcome(canon, False, f"LLM error: {type(exc).__name__}") - try: raw = resp.choices[0].message.content or "" except Exception: raw = "" - parsed = _extract_json_blob(raw) if parsed is None: - # Fall back: take the raw text trimmed to one paragraph. + # Fall back: raw text trimmed to one paragraph. text = raw.strip().split("\n\n", 1)[0] if not text: return DescribeOutcome(canon, False, "LLM returned an empty response") @@ -231,29 +173,18 @@ def describe_profile( else: val = parsed.get("description") if not isinstance(val, str) or not val.strip(): - return DescribeOutcome( - canon, False, "LLM response missing 'description' field" - ) + return DescribeOutcome(canon, False, "LLM response missing 'description' field") description = val.strip()[:280] - try: - profiles_mod.write_profile_meta( - profile_dir, - description=description, - description_auto=True, - ) + profiles_mod.write_profile_meta(profile_dir, description=description, description_auto=True) except Exception as exc: return DescribeOutcome(canon, False, f"failed to write profile.yaml: {exc}") - return DescribeOutcome(canon, True, "described", description=description) def list_describable_profiles(*, missing_only: bool = True) -> list[str]: - """Return profile names that can be described. - - ``missing_only=True`` (default) returns only profiles without a description. - ``missing_only=False`` returns every profile. - """ + """Profile names that can be described; ``missing_only`` keeps only those without a + user-authored description.""" return [ p.name for p in profiles_mod.list_profiles() if not (missing_only and (p.description or "").strip() and not p.description_auto) diff --git a/hermes_cli/profile_distribution.py b/hermes_cli/profile_distribution.py index c4ad3589fb..3c1a8a7f13 100644 --- a/hermes_cli/profile_distribution.py +++ b/hermes_cli/profile_distribution.py @@ -1,8 +1,8 @@ """Profile distributions — shareable, packaged Hermes profiles via git. -* A git URL (``github.com/user/repo``, ``https://github.com/...``, ``git@...``, ``ssh://``, -``git://``), optionally with ``#`` to pin a tag / branch / commit SHA. * A local directory that -already contains ``distribution.yaml`` — used during profile development before the first push. +Sources: a git URL (``github.com/user/repo``, ``https://...``, ``git@...``, ``ssh://``, +``git://``) or a local directory that already contains ``distribution.yaml`` (profile +development before the first push). """ from __future__ import annotations @@ -19,34 +19,19 @@ from typing import Any, Dict, List, Optional, Tuple import yaml -from agent.skill_utils import is_excluded_skill_path from hermes_cli._subprocess_compat import noninteractive_git_env -# --------------------------------------------------------------------------- -# Constants -# --------------------------------------------------------------------------- - MANIFEST_FILENAME = "distribution.yaml" ENV_TEMPLATE_FILENAME = ".env.template" ENV_EXAMPLE_FILENAME = ".env.EXAMPLE" -# Default distribution-owned paths (relative to profile root). Authors may -# override via ``distribution_owned:`` in the manifest. config.yaml is -# distribution-owned but treated specially on update (see _is_config_like). -DEFAULT_DIST_OWNED: Tuple[str, ...] = ( - "SOUL.md", - "config.yaml", - "mcp.json", - "skills", - "cron", - MANIFEST_FILENAME, -) +# Default distribution-owned paths (relative to profile root). Authors may override via +# ``distribution_owned:``. config.yaml is dist-owned but preserved on update by default. +DEFAULT_DIST_OWNED: Tuple[str, ...] = ("SOUL.md", "config.yaml", "mcp.json", "skills", "cron", MANIFEST_FILENAME) -# Paths that are NEVER part of a distribution. These are user-owned and are -# protected on update. Must stay consistent with -# ``profiles.py::_DEFAULT_EXPORT_EXCLUDE_ROOT`` plus the ``local/`` -# convention for user customizations. +# Paths NEVER part of a distribution: user-owned, protected on update. Keep consistent with +# ``profiles.py`` export exclusions plus the ``local/`` convention for user customizations. USER_OWNED_EXCLUDE: frozenset = frozenset({ # Credentials & runtime secrets "auth.json", ".env", @@ -69,18 +54,14 @@ USER_OWNED_EXCLUDE: frozenset = frozenset({ }) -# --------------------------------------------------------------------------- -# Errors -# --------------------------------------------------------------------------- - - class DistributionError(Exception): """Raised for distribution install/update failures.""" -# --------------------------------------------------------------------------- # Manifest -# --------------------------------------------------------------------------- + +def _str(data: dict, key: str, default: str = "") -> str: + return str(data.get(key) or default) @dataclass @@ -93,16 +74,12 @@ class EnvRequirement: @classmethod def from_dict(cls, data: Any) -> "EnvRequirement": if not isinstance(data, dict): - raise DistributionError( - f"env_requires entry must be a mapping, got {type(data).__name__}" - ) - name = str(data.get("name") or "").strip() + raise DistributionError(f"env_requires entry must be a mapping, got {type(data).__name__}") + name = _str(data, "name").strip() if not name: raise DistributionError("env_requires entry missing 'name'") return cls( - name=name, - description=str(data.get("description") or ""), - required=bool(data.get("required", True)), + name=name, description=_str(data, "description"), required=bool(data.get("required", True)), default=data.get("default"), ) @@ -127,52 +104,38 @@ class DistributionManifest: distribution_owned: List[str] = field(default_factory=list) # Tracked after install — where we pulled from, so ``update`` can re-pull. source: str = "" - # ISO-8601 UTC timestamp written on install / update, so ``info`` and - # ``list`` can show when a distribution landed on disk. Empty for - # manifests that ship in a repo (authors don't populate this). + # ISO-8601 UTC timestamp written on install/update (empty in repo-shipped manifests). installed_at: str = "" @classmethod def from_dict(cls, data: Any) -> "DistributionManifest": if not isinstance(data, dict): - raise DistributionError( - f"{MANIFEST_FILENAME} must be a mapping, got {type(data).__name__}" - ) - name = str(data.get("name") or "").strip() + raise DistributionError(f"{MANIFEST_FILENAME} must be a mapping, got {type(data).__name__}") + name = _str(data, "name").strip() if not name: raise DistributionError(f"{MANIFEST_FILENAME} missing 'name'") env_raw = data.get("env_requires") or [] if not isinstance(env_raw, list): raise DistributionError("env_requires must be a list") - env_requires = [EnvRequirement.from_dict(e) for e in env_raw] dist_owned_raw = data.get("distribution_owned") or [] if dist_owned_raw and not isinstance(dist_owned_raw, list): raise DistributionError("distribution_owned must be a list") - distribution_owned = [str(p).strip().strip("/") for p in dist_owned_raw if str(p).strip()] return cls( - name=name, - version=str(data.get("version") or "0.1.0"), - description=str(data.get("description") or ""), - hermes_requires=str(data.get("hermes_requires") or ""), - author=str(data.get("author") or ""), - license=str(data.get("license") or ""), - env_requires=env_requires, - distribution_owned=distribution_owned, - source=str(data.get("source") or ""), - installed_at=str(data.get("installed_at") or ""), + name=name, version=_str(data, "version", "0.1.0"), description=_str(data, "description"), + hermes_requires=_str(data, "hermes_requires"), author=_str(data, "author"), + license=_str(data, "license"), env_requires=[EnvRequirement.from_dict(e) for e in env_raw], + distribution_owned=[str(p).strip().strip("/") for p in dist_owned_raw if str(p).strip()], + source=_str(data, "source"), installed_at=_str(data, "installed_at"), ) def to_dict(self) -> Dict[str, Any]: out: Dict[str, Any] = {"name": self.name, "version": self.version} # Key order is the on-disk YAML order (write_manifest uses sort_keys=False). optional = ( - ("description", self.description), - ("hermes_requires", self.hermes_requires), - ("author", self.author), - ("license", self.license), + ("description", self.description), ("hermes_requires", self.hermes_requires), + ("author", self.author), ("license", self.license), ("env_requires", [e.to_dict() for e in self.env_requires]), - ("distribution_owned", self.distribution_owned), - ("source", self.source), + ("distribution_owned", self.distribution_owned), ("source", self.source), ("installed_at", self.installed_at), ) out.update((k, v) for k, v in optional if v) @@ -192,77 +155,51 @@ def read_manifest(profile_dir: Path) -> Optional[DistributionManifest]: def write_manifest(profile_dir: Path, manifest: DistributionManifest) -> Path: + """Atomically write ``distribution.yaml``. A bare write_text() truncates before the dump + lands and read_manifest() treats a missing/unparseable manifest as "not a distribution", + so an interrupted install/update would silently demote the profile.""" mf_path = profile_dir / MANIFEST_FILENAME - # Route through the shared atomic YAML writer (temp file + fsync + atomic - # replace, preserving mode/owner and symlinks). A bare write_text() - # truncates distribution.yaml before the dump lands, and read_manifest() - # treats a missing-or-unparseable manifest as "not a distribution" -- so an - # interrupted install/update silently demotes the profile, losing update - # tracking and env_requires with no error surfaced anywhere. from utils import atomic_yaml_write - # create_mode=0o644: _materialize() reaches this line with no manifest on - # disk whenever a distribution declares an explicit `distribution_owned` - # allowlist that does not list distribution.yaml itself, so the file is - # never copied out of the staged tree. The manifest is a shareable - # descriptor rather than a secret and used to land at the umask default, - # so don't leave a freshly created one at mkstemp's 0600. An existing - # file's mode is preserved as before. - atomic_yaml_write( - mf_path, - manifest.to_dict(), - sort_keys=False, - default_flow_style=False, - create_mode=0o644, - ) + # create_mode=0o644: with an explicit `distribution_owned` allowlist that omits + # distribution.yaml, _copy_dist_payload reaches here with no manifest on disk. It is a + # shareable descriptor, not a secret — don't leave it at mkstemp's 0600. An existing + # file's mode is preserved. + atomic_yaml_write(mf_path, manifest.to_dict(), sort_keys=False, default_flow_style=False, create_mode=0o644) return mf_path -# --------------------------------------------------------------------------- # Version check -# --------------------------------------------------------------------------- - _VERSION_OP_RE = re.compile(r"^\s*(>=|<=|==|!=|>|<)\s*(.+?)\s*$") _VERSION_OPS = {">=": operator.ge, "<=": operator.le, "==": operator.eq, "!=": operator.ne, ">": operator.gt, "<": operator.lt} def _parse_semver(v: str) -> Tuple[int, int, int]: - """Very small semver parser — major.minor.patch only. Extra labels stripped.""" - # Strip any pre-release / build metadata (e.g. "0.12.0-rc1+abc") + """major.minor.patch only; pre-release / build metadata ("0.12.0-rc1+abc") stripped.""" parts = re.split(r"[-+]", str(v).strip().lstrip("v"), 1)[0].split(".") parts += ["0"] * (3 - len(parts)) try: - return (int(parts[0]), int(parts[1]), int(parts[2])) + return int(parts[0]), int(parts[1]), int(parts[2]) except ValueError as exc: raise DistributionError(f"Unparseable version: {v!r}") from exc def check_hermes_requires(spec: str, current_version: str) -> None: - """Raise DistributionError if ``current_version`` does not satisfy ``spec``.""" + """Raise DistributionError if ``current_version`` does not satisfy ``spec`` (bare version = ``>=``).""" if not spec or not spec.strip(): return m = _VERSION_OP_RE.match(spec) - # Bare version → treat as ``>=`` op, target = m.groups() if m else (">=", spec.strip()) if not _VERSION_OPS[op](_parse_semver(current_version), _parse_semver(target)): - raise DistributionError( - f"This distribution requires Hermes {op}{target}, " - f"but you have {current_version}." - ) - - -# --------------------------------------------------------------------------- -# Env var template helper -# --------------------------------------------------------------------------- + raise DistributionError(f"This distribution requires Hermes {op}{target}, but you have {current_version}.") def _env_template_from_manifest(manifest: DistributionManifest) -> str: """Generate a ``.env.template`` body from env_requires.""" lines = [ "# Environment variables required by this Hermes distribution.", - "# Copy to `.env` and fill in your own values before running.", - "", + "# Copy to `.env` and fill in your own values before running.", "", ] for req in manifest.env_requires: if req.description: @@ -275,18 +212,13 @@ def _env_template_from_manifest(manifest: DistributionManifest) -> str: return "\n".join(lines).rstrip() + "\n" -# --------------------------------------------------------------------------- # Source staging — git clone or local directory -# --------------------------------------------------------------------------- - -# Bare github.com/user/repo shorthand _GITHUB_SHORTHAND_RE = re.compile(r"^github\.com/[\w.-]+/[\w.-]+/?$") def _looks_like_git_url(s: str) -> bool: - # Any http(s) URL is treated as a git repo. We no longer accept - # tar.gz URLs — git is the only remote transport. + """Any http(s) URL is a git repo — git is the only remote transport (no tar.gz URLs).""" s = s.strip() return ( s.endswith(".git") @@ -296,16 +228,12 @@ def _looks_like_git_url(s: str) -> bool: def _git_clone(url: str, dest: Path) -> None: - # Normalize github.com/user/repo shorthand if _GITHUB_SHORTHAND_RE.match(url): url = f"https://{url.rstrip('/')}" try: subprocess.run( - ["git", "clone", "--depth", "1", url, str(dest)], - check=True, - capture_output=True, - stdin=subprocess.DEVNULL, - env=noninteractive_git_env(), + ["git", "clone", "--depth", "1", url, str(dest)], check=True, capture_output=True, + stdin=subprocess.DEVNULL, env=noninteractive_git_env(), ) except FileNotFoundError as exc: raise DistributionError("git is required for git-URL installs") from exc @@ -315,19 +243,12 @@ def _git_clone(url: str, dest: Path) -> None: def _stage_source(source: str, workdir: Path) -> Tuple[Path, str]: - """Resolve *source* to a local directory containing distribution.yaml. - - Accepts: * A git URL (https / ssh / git@ / bare github.com shorthand) — cloned into a temp - directory; ``.git`` removed after clone. * A local directory already containing - ``distribution.yaml``. - """ + """Resolve *source* to ``(staged_dir, provenance)``: git URLs are shallow-cloned into + *workdir* (``.git`` removed); a local directory is used in place.""" src_str = source.strip() - - # Git URL if _looks_like_git_url(src_str): staged, provenance = workdir / "clone", src_str _git_clone(src_str, staged) - # Remove .git to keep the staged tree clean shutil.rmtree(staged / ".git", ignore_errors=True) missing = ( f"No {MANIFEST_FILENAME} at the root of {src_str!r}. " @@ -359,15 +280,10 @@ def _reject_distribution_symlinks(staged: Path) -> None: rel = entry.relative_to(staged) except ValueError: rel = entry - raise DistributionError( - f"Profile distributions cannot contain symlinks: {rel}" - ) + raise DistributionError(f"Profile distributions cannot contain symlinks: {rel}") -# --------------------------------------------------------------------------- # Install -# --------------------------------------------------------------------------- - @dataclass class InstallPlan: @@ -379,85 +295,50 @@ class InstallPlan: existing: bool # True if target profile already exists (update path) preserves_config: bool = True has_cron: bool = False - has_skills: bool = False def _has_cron_jobs(staged: Path) -> bool: cron_dir = staged / "cron" - return cron_dir.is_dir() and ( - any(cron_dir.rglob("*.json")) or any(cron_dir.rglob("*.yaml")) - ) + return cron_dir.is_dir() and (any(cron_dir.rglob("*.json")) or any(cron_dir.rglob("*.yaml"))) -def _has_skills(staged: Path) -> bool: - skills_dir = staged / "skills" - return skills_dir.is_dir() and any(not is_excluded_skill_path(p) for p in skills_dir.rglob("SKILL.md")) - - -def plan_install( - source: str, - workdir: Path, - override_name: Optional[str] = None, -) -> InstallPlan: +def plan_install(source: str, workdir: Path, override_name: Optional[str] = None) -> InstallPlan: """Stage *source* and produce a plan describing what install would do.""" - from hermes_cli.profiles import ( - get_profile_dir, - normalize_profile_name, - validate_profile_name, - ) + from hermes_cli.profiles import _canon_valid, get_profile_dir from hermes_cli import __version__ as hermes_version - staged, provenance = _stage_source(source, workdir) _reject_distribution_symlinks(staged) manifest = read_manifest(staged) if manifest is None: raise DistributionError( - f"No {MANIFEST_FILENAME} found at the distribution root — " - "this source is not a Hermes distribution." + f"No {MANIFEST_FILENAME} found at the distribution root — this source is not a Hermes distribution." ) - - # Version check up-front so we fail fast - check_hermes_requires(manifest.hermes_requires, hermes_version) - - # Resolve target profile name - target_name = override_name or manifest.name - canon = normalize_profile_name(target_name) - validate_profile_name(canon) + check_hermes_requires(manifest.hermes_requires, hermes_version) # fail fast + canon = _canon_valid(override_name or manifest.name) if canon == "default": raise DistributionError( "Cannot install a distribution as 'default' — that is the built-in " - "root profile (~/.hermes). Pass --name to install under a " - "new profile." + "root profile (~/.hermes). Pass --name to install under a new profile." ) manifest.name = canon manifest.source = provenance - # Stamped once here so plan_install() callers (both fresh install and - # update) propagate a freshly-minted timestamp through _copy_dist_payload. + # Stamped once here so both fresh install and update propagate a fresh timestamp. manifest.installed_at = datetime.now(timezone.utc).isoformat(timespec="seconds") - target_dir = get_profile_dir(canon) existing = target_dir.is_dir() return InstallPlan( - manifest=manifest, - staged_dir=staged, - provenance=provenance, - target_dir=target_dir, - existing=existing, - preserves_config=existing, - has_cron=_has_cron_jobs(staged), - has_skills=_has_skills(staged), + manifest=manifest, staged_dir=staged, provenance=provenance, target_dir=target_dir, existing=existing, + preserves_config=existing, has_cron=_has_cron_jobs(staged), ) def _owned_entries(staged: Path, manifest: DistributionManifest): """Yield ``(src, rel_parts)`` for every staged path the distribution owns.""" - explicit_owned = [p.strip().strip("/") for p in manifest.distribution_owned] - explicit_owned = [p for p in explicit_owned if p] + explicit_owned = [p for p in (p.strip().strip("/") for p in manifest.distribution_owned) if p] if not explicit_owned: - # Legacy behaviour: no explicit allowlist means the whole staged - # payload (minus USER_OWNED_EXCLUDE) is distribution-owned. Do NOT - # narrow to DEFAULT_DIST_OWNED here — existing distributions ship - # arbitrary extra top-level paths without declaring them. + # Legacy: no allowlist means the whole payload (minus USER_OWNED_EXCLUDE) is owned. + # Do NOT narrow to DEFAULT_DIST_OWNED — existing distributions ship arbitrary extra + # top-level paths without declaring them. for entry in staged.iterdir(): if entry.name not in USER_OWNED_EXCLUDE: yield entry, (entry.name,) @@ -474,23 +355,12 @@ def _owned_entries(staged: Path, manifest: DistributionManifest): yield src, rel_parts -def _copy_dist_payload( - staged: Path, - target: Path, - manifest: DistributionManifest, - preserve_config: bool, -) -> None: - """Copy distribution-owned files from *staged* into *target*. +def _copy_dist_payload(staged: Path, target: Path, manifest: DistributionManifest, preserve_config: bool) -> None: + """Copy distribution-owned files (see ``_owned_entries``) from *staged* into *target*. - User-owned paths are never touched. ``config.yaml`` is replaced only when ``preserve_config`` is - False (fresh install or ``--force-config`` update). ``.env.template`` is renamed to - ``.env.EXAMPLE`` in the target to avoid shadowing a real ``.env``. - - When the manifest declares an explicit ``distribution_owned`` list, only those paths are copied - (path-aware: nested entries such as ``skills/research`` or ``cron/digest.json`` are honoured). - When the list is omitted the legacy behaviour is preserved: every staged entry outside - ``USER_OWNED_EXCLUDE`` is copied. - """ + User-owned paths are never touched. ``config.yaml`` is replaced only when + ``preserve_config`` is False (fresh install / ``--force-config``). ``.env.template`` lands + as ``.env.EXAMPLE`` so it never shadows a real ``.env``.""" target.mkdir(parents=True, exist_ok=True) staged_resolved = staged.resolve() @@ -505,7 +375,6 @@ def _copy_dist_payload( shutil.copy2(src, target / ENV_EXAMPLE_FILENAME) continue if name == "config.yaml" and preserve_config and (target / "config.yaml").exists(): - # Leave user's config.yaml alone on update continue dest = target.joinpath(*rel_parts) dest.parent.mkdir(parents=True, exist_ok=True) @@ -518,88 +387,54 @@ def _copy_dist_payload( # Emit .env.EXAMPLE from manifest if the staged tree didn't ship one if manifest.env_requires and not (target / ENV_EXAMPLE_FILENAME).exists(): - (target / ENV_EXAMPLE_FILENAME).write_text( - _env_template_from_manifest(manifest), encoding="utf-8" - ) + (target / ENV_EXAMPLE_FILENAME).write_text(_env_template_from_manifest(manifest), encoding="utf-8") # Make sure the manifest on disk reflects resolved name + source write_manifest(target, manifest) def _bootstrap_user_dirs(target: Path) -> None: - """Create the bootstrap dirs a fresh profile expects.""" - for d in ("memories", "sessions", "skills", "skins", "logs", - "plans", "workspace", "cron", "home"): + """Create the bootstrap dirs a fresh profile expects (same set as ``create_profile``).""" + from hermes_cli.profiles import _PROFILE_DIRS + for d in _PROFILE_DIRS: (target / d).mkdir(parents=True, exist_ok=True) def install_distribution( - source: str, - name: Optional[str] = None, - force: bool = False, - create_alias: bool = False, + source: str, name: Optional[str] = None, force: bool = False, create_alias: bool = False ) -> InstallPlan: - """Install a distribution from *source* into a new profile. - - Returns the resolved :class:`InstallPlan`. Use :func:`plan_install` first if you want to preview - + prompt the user before calling this. - """ - from hermes_cli.profiles import ( - check_alias_collision, - create_wrapper_script, - ) - + """Install a distribution from *source* into a new profile; returns the resolved plan. + Use :func:`plan_install` first to preview + prompt.""" + from hermes_cli.profiles import check_alias_collision, create_wrapper_script with tempfile.TemporaryDirectory(prefix="hermes_dist_install_") as tmp: plan = plan_install(source, Path(tmp), override_name=name) - if plan.existing and not force: raise DistributionError( f"Profile '{plan.manifest.name}' already exists at {plan.target_dir}. " - "Use `hermes profile update` to upgrade in place, " - "or pass --force to overwrite." + "Use `hermes profile update` to upgrade in place, or pass --force to overwrite." ) # Fresh install: config.yaml comes from the distribution. _bootstrap_user_dirs(plan.target_dir) - _copy_dist_payload( - plan.staged_dir, - plan.target_dir, - plan.manifest, - preserve_config=False, - ) - + _copy_dist_payload(plan.staged_dir, plan.target_dir, plan.manifest, preserve_config=False) if create_alias and check_alias_collision(plan.manifest.name) is None: create_wrapper_script(plan.manifest.name) - return plan def _existing_profile(profile_name: str) -> Tuple[str, Path]: """Return ``(canonical_name, profile_dir)`` or raise if the profile doesn't exist.""" - from hermes_cli.profiles import ( - get_profile_dir, - normalize_profile_name, - validate_profile_name, - ) + from hermes_cli.profiles import _existing_profile_dir - canon = normalize_profile_name(profile_name) - validate_profile_name(canon) - target = get_profile_dir(canon) - if not target.is_dir(): - raise DistributionError(f"Profile '{canon}' does not exist.") - return canon, target + try: + return _existing_profile_dir(profile_name) + except FileNotFoundError as exc: + raise DistributionError(str(exc)) from exc -def update_distribution( - profile_name: str, - force_config: bool = False, -) -> InstallPlan: - """Re-pull the distribution for an existing profile and apply updates. - - The source is read from the installed profile's ``distribution.yaml`` ``source:`` field. - Distribution-owned files are overwritten; user-owned data (memories, sessions, auth) is never - touched. ``config.yaml`` is preserved unless ``force_config`` is True. - """ +def update_distribution(profile_name: str, force_config: bool = False) -> InstallPlan: + """Re-pull from the installed manifest's ``source:`` and apply: dist-owned files + overwritten, user data never touched, ``config.yaml`` preserved unless ``force_config``.""" canon, target = _existing_profile(profile_name) existing_manifest = read_manifest(target) if existing_manifest is None: @@ -612,30 +447,14 @@ def update_distribution( f"Profile '{canon}' has no recorded source. Re-install with " "`hermes profile install --name {canon} --force`." ) - with tempfile.TemporaryDirectory(prefix="hermes_dist_update_") as tmp: - plan = plan_install( - existing_manifest.source, - Path(tmp), - override_name=canon, - ) + plan = plan_install(existing_manifest.source, Path(tmp), override_name=canon) plan.preserves_config = not force_config - - _copy_dist_payload( - plan.staged_dir, - plan.target_dir, - plan.manifest, - preserve_config=plan.preserves_config, - ) + _copy_dist_payload(plan.staged_dir, plan.target_dir, plan.manifest, preserve_config=plan.preserves_config) return plan -# --------------------------------------------------------------------------- -# Info — render a manifest summary -# --------------------------------------------------------------------------- - - def describe_distribution(profile_name: str) -> Dict[str, Any]: - """Return a structured view of a profile's distribution metadata.""" + """Return a structured view of a profile's distribution metadata ({} if not a distribution).""" manifest = read_manifest(_existing_profile(profile_name)[1]) return {} if manifest is None else manifest.to_dict() diff --git a/hermes_cli/profiles.py b/hermes_cli/profiles.py index 99ef669d45..8e104449ff 100644 --- a/hermes_cli/profiles.py +++ b/hermes_cli/profiles.py @@ -1,5 +1,6 @@ """Profile management for multiple isolated Hermes instances.""" +import contextlib import json import logging import os @@ -15,116 +16,49 @@ 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, -) +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", -] +# Directories bootstrapped inside every new profile. ``home`` is the back-compat/Docker +# HOME for tool subprocesses (host subprocesses keep the real HOME so CLI credentials +# stay visible; containers persist HOME state here). See hermes_constants.get_subprocess_home(). +_PROFILE_DIRS = ["memories", "sessions", "skills", "skins", "logs", "plans", "workspace", "cron", "home"] -# Files copied during --clone (if they exist in the source) -_CLONE_CONFIG_FILES = [ - "config.yaml", - ".env", - "SOUL.md", -] +# Files copied during --clone (if they exist in the source). +_CLONE_CONFIG_FILES = ["config.yaml", ".env", "SOUL.md"] +# Subdirectory files copied during --clone: memory files are part of the agent's curated +# identity, as important as SOUL.md for continuity. +_CLONE_SUBDIR_FILES = ["memories/MEMORY.md", "memories/USER.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. A post-copy step rather than an ignore filter +# because they are created dynamically and may be absent at copy time. +_CLONE_ALL_STRIP: list[str] = ["gateway.pid", "gateway_state.json", "processes.json"] -# 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. +# Infrastructure excluded from --clone-all ONLY when the source is the default profile +# (``~/.hermes``): git checkout (+ ~3 GB venv), worktrees, sibling profiles, shared bins, +# npm packages. Named profiles never hold these at root, so the gate avoids silently +# dropping user data from a named-profile source. Export uses a root allow-list instead +# (``_DEFAULT_EXPORT_INCLUDE_ROOT``): an archive is a portable snapshot, a clone must run. _CLONE_ALL_DEFAULT_EXCLUDE_ROOT: frozenset[str] = frozenset({ - "hermes-agent", - ".worktrees", - "profiles", - "bin", - "node_modules", + "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 +# Per-profile history excluded from --clone-all for ANY source: SQLite session store +# (+wal/shm, can reach many GB), session dirs, `hermes backup` archives, quick-backup +# snapshots, checkpoints. Inheriting them is never useful (restoring one inside the +# clone would resurrect the SOURCE profile's state) and can balloon the copy by tens of GB. _CLONE_ALL_HISTORY_EXCLUDE_ROOT: frozenset[str] = frozenset({ - "state.db", - "state.db-wal", - "state.db-shm", - "sessions", - "backups", - "state-snapshots", - "checkpoints", + "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. +# Marker written by `hermes profile create --no-skills`. When present at a profile root, +# seed_profile_skills() callers (fresh-create, `hermes update` all-profile sync, the +# dashboard) skip bundled-skill seeding. Delete the 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. @@ -136,35 +70,22 @@ _PLACEHOLDER_ENV = ( 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. - """ + """copytree ignore for --clone-all: history artifacts for any source, infrastructure + only when the source is the default profile (see the two exclude sets above).""" 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: + if source_resolved == _get_default_hermes_home().resolve(): 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. + # resolve() can fail on odd 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) @@ -173,21 +94,17 @@ def _clone_all_copytree_ignore(source_dir: Path): 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``. +# Allow-list for ``export_profile("default")``: when HERMES_HOME equals the cwd +# (Docker/custom deployments) the default home holds arbitrary user files that must NOT +# be bundled. Only known Hermes profile artifacts at the root survive; sensitive runtime +# infrastructure (``state.db``, ``logs/``, ``auth.*``, other profiles) is deliberately +# absent so the export stays a portable, credential-free snapshot. 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 appearance overlay (written/applied by the desktop app's export/import). "desktop.json", # User-facing skill, cron, and session artifacts "skills", "cron", "scripts", "sessions", @@ -196,49 +113,36 @@ _DEFAULT_EXPORT_INCLUDE_ROOT = frozenset({ }) # Names that cannot be used as profile aliases -_RESERVED_NAMES = frozenset({ - "hermes", "default", "test", "tmp", "root", "sudo", -}) +_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", + "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. - """ + """Named-profiles root, anchored to the hermes root (NOT the current HERMES_HOME, which + may itself be a profile) so ``coder profile list`` sees 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. - """ + """Default (pre-profile) HERMES_HOME: ``~/.hermes``, or HERMES_HOME itself in + Docker/custom deployments (e.g. ``/opt/data``).""" 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" @@ -255,17 +159,16 @@ def _is_our_wrapper(path: Path) -> bool: return False -# --------------------------------------------------------------------------- +def _missing_profile_error(canon: str) -> FileNotFoundError: + return FileNotFoundError(f"Profile '{canon}' does not exist. Create it with: hermes profile create {canon}") + + # 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//``; ``default`` matches - case-insensitively. Dashboards/tools may pass title-cased labels, so normalize before - validation, assignment, and subprocess spawn. - """ + """Canonical profile id used on disk and in ``-p`` argv: lowercase, ``default`` matched + case-insensitively. Dashboards/tools may pass title-cased labels — normalize before + validation, assignment, and subprocess spawn.""" if not isinstance(name, str): name = str(name) stripped = name.strip() @@ -277,20 +180,12 @@ def normalize_profile_name(name: str) -> str: 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. - """ + """Raise ``ValueError`` unless *name* is a valid profile id (strict as-given lowercase — + normalize mixed-case input first) and not in ``_RESERVED_NAMES``; ``default`` passes.""" 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}}" - ) + raise ValueError(f"Invalid profile name {name!r}. Must match [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 " @@ -300,18 +195,26 @@ def validate_profile_name(name: str) -> None: 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. - """ + """Raise ``ValueError`` unless *name* is a safe wrapper filename: it is used verbatim + under ``~/.local/bin``, so ``../../.bashrc`` must never escape the wrapper dir.""" 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}}" - ) + raise ValueError(f"Invalid alias name {name!r}. Must match [a-z0-9][a-z0-9_-]{{0,63}}") + + +def _canon_valid(name: str) -> str: + """normalize + validate in one step; returns the canonical id.""" + canon = normalize_profile_name(name) + validate_profile_name(canon) + return canon + + +def _existing_profile_dir(name: str) -> Tuple[str, Path]: + """``(canon, profile_dir)`` for an existing profile; FileNotFoundError otherwise.""" + canon = _canon_valid(name) + profile_dir = get_profile_dir(canon) + if not profile_dir.is_dir(): + raise FileNotFoundError(f"Profile '{canon}' does not exist.") + return canon, profile_dir def get_profile_dir(name: str) -> Path: @@ -332,31 +235,23 @@ def profile_exists(name: str) -> bool: def profile_matches_home(name: str, home: "Path | None" = None) -> bool: - """Return True when *name* refers to the profile served from *home*. + """True when *name* refers to the profile served from *home* (default: current home). Lets single-profile gateways decide whether a ``/p//`` 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. - """ + 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) - ) + 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. - """ + """Sorted named-profile dirs (valid ids, never ``default``); ``live_only`` skips tombstones.""" profiles_root = _get_profiles_root() if not profiles_root.is_dir(): return [] @@ -370,22 +265,15 @@ def _iter_named_profile_dirs(*, live_only: bool = True) -> List[Path]: 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). - """ + """Cheap name-only listing (``default`` + profile dirs). Unlike :func:`list_profiles` this + reads NO per-profile config — safe for hot paths (cron target listings, create validation).""" names = ["default"] - try: + with contextlib.suppress(OSError): 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.""" @@ -398,8 +286,6 @@ def check_alias_collision(name: str) -> Optional[str]: 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], @@ -407,42 +293,31 @@ def check_alias_collision(name: str) -> Optional[str]: ) 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 None # our own 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) + return str(_get_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/. - - 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. - """ + """Create ``~/.local/bin/`` activating profile *target* (default: *name*), so a + custom alias can 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) + validate_alias_name(canon) # alias is a verbatim filename: no traversal 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": @@ -460,44 +335,34 @@ def create_wrapper_script(name: str, target: Optional[str] = None) -> Optional[P 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. + # A traversal-shaped name could point unlink() outside the wrapper dir; refuse it. try: validate_alias_name(canon) except ValueError: return False - # Check both the extensionless path (POSIX) and .bat (Windows) + # 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: + with contextlib.suppress(Exception): 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(): + """Migrate a copied config.yaml to the current schema (non-interactive, scoped to the new + profile); otherwise the first desktop/doctor view shows a scary ``v0 -> latest`` warning.""" + if not (profile_dir / "config.yaml").exists(): return - - try: + # Creation must not fail over an unmigratable old config; `hermes doctor --fix` surfaces + # the detailed error in the target profile. + with contextlib.suppress(Exception): 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() @@ -505,55 +370,38 @@ def _migrate_profile_config_if_outdated(profile_dir: Path) -> None: 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 ``. - - 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. - """ + """Alias name of the wrapper activating *profile_name*, or None. For listing ALL profiles + prefer :func:`build_alias_map`: per-profile calls re-read every wrapper N times (O(N*M)), + which on a ``~/.local/bin`` full of large binaries meant multi-second ``list_profiles``.""" 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. +# Cap on how much of a wrapper file is read when reverse-looking-up its profile. Real +# wrappers are a few hundred bytes with the ``hermes -p X`` needle near the top; the wrapper +# dir commonly also holds large binaries (ffmpeg, node, …) whose whole-file reads, N times, +# dominated ``list_profiles`` (~4.5s). _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. - """ + Scans the wrapper dir ONCE, reading only a head slice of each candidate and skipping + binaries. A custom alias (file name != profile) wins over the profile-named wrapper; + 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. + # Our wrappers are named after the alias and (on Windows only) carry .bat. if is_windows and entry.suffix != ".bat": continue if not is_windows and entry.suffix: @@ -562,8 +410,7 @@ def build_alias_map() -> dict[str, str]: 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 + continue # UnicodeDecodeError = a binary on PATH, not a wrapper idx = content.find(prefix) if idx == -1: continue @@ -574,18 +421,14 @@ def build_alias_map() -> dict[str, str]: 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) + result.setdefault(canon, alias) # never overwrite a custom alias already found else: result[canon] = alias return result -# --------------------------------------------------------------------------- # ProfileInfo -# --------------------------------------------------------------------------- @dataclass class ProfileInfo: @@ -599,27 +442,20 @@ class ProfileInfo: 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``. + # Custom alias (wrapper file name) when it differs from ``name``; ``name`` when a + # profile-named wrapper exists; None if no wrapper points here. 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.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. + # 1-2 sentence role description from ``profile.yaml``; empty when never described. + # Surfaced to the kanban decomposer so it routes work by role rather than name. 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. + # True when ``description`` was LLM-generated and not yet user-confirmed (dashboard + # shows a "review" badge). description_auto: bool = False - # Optional user-facing display name from profile.yaml. Presentation - # only — resolution/comparison/spawn paths always use ``name``. + # Presentation-only display name; resolution/comparison/spawn always use ``name``. display_name: str = "" @@ -629,17 +465,14 @@ def _load_yaml_dict(path: Path) -> Optional[dict]: return None try: import yaml - with open(path, "r", encoding="utf-8") as f: - data = yaml.safe_load(f) or {} + data = yaml.safe_load(path.read_text(encoding="utf-8")) 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. - """ + """``(name, version, source)`` from ``distribution.yaml``; ``(None, None, None)`` if absent.""" data = _load_yaml_dict(profile_dir / "distribution.yaml") if data is None: return None, None, None @@ -652,64 +485,43 @@ def _read_config_model(profile_dir: Path) -> tuple: 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. + # load_config() targets the ACTIVE profile's home; read THIS profile's file raw. from hermes_cli.config import read_user_config_raw - cfg = read_user_config_raw(config_path) - model_cfg = cfg.get("model", {}) + model_cfg = read_user_config_raw(config_path).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 + pass + 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. - """ + """Copy (not link) the active profile's model block into a fresh profile so it is usable; + profiles stay independent islands afterwards.""" config_path = profile_dir / "config.yaml" if config_path.exists(): return - try: + with contextlib.suppress(Exception): # creation must not fail over this; `hermes model` sets it later 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 + model_cfg = read_user_config_raw(source).get("model") if source.is_file() else None + if model_cfg: + config_path.write_text(yaml.safe_dump({"model": model_cfg}, sort_keys=False), encoding="utf-8") def _check_gateway_running(profile_dir: Path) -> bool: - """Check if a gateway is running for a given profile directory. + """Gateway liveness for a profile dir, never mutating HERMES_HOME. - 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``. - """ + Primary signal is ``gateway.pid`` verified against the runtime lock (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); fallback validates the PID in ``gateway_state.json`` + against the process table, matching ``/api/status``.""" try: - from gateway.status import ( - get_running_pid, - get_runtime_status_running_pid, - read_runtime_status, - ) + 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: @@ -722,39 +534,27 @@ def _check_gateway_running(profile_dir: Path) -> bool: 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. - """ + """True when the live default gateway multiplexes ``profile_name`` (such a profile has no + gateway.pid of its own, so ``_check_gateway_running`` alone reports it stopped).""" 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). +# In-process skill-count cache. ``rglob("SKILL.md")`` walks every skill's sub-trees; the +# default profile alone has ~270 skills and ``list_profiles`` counts EVERY profile (16+), so +# an uncached scan costs ~6s — enough for the desktop's per-request calls to time out and +# the sidebar to render "全部智能体 0". Keyed by skills dir, invalidated when the tree +# signature changes (skill add/remove) or after a short TTL (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). - """ + """Max mtime of ``skills_dir`` and its immediate children (adding/removing a category + bumps the root, a skill bumps its category). One scandir: O(#categories), not O(#files).""" try: sig = skills_dir.stat().st_mtime except OSError: @@ -764,9 +564,7 @@ def _skills_dir_signature(skills_dir: Path) -> float: 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 + sig = max(sig, entry.stat(follow_symlinks=False).st_mtime) except OSError: continue except OSError: @@ -779,51 +577,28 @@ def _count_skills(profile_dir: Path) -> int: 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 - ): + 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 + count = sum(1 for md in skills_dir.rglob("SKILL.md") if not is_excluded_skill_path(md)) _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. +# Deliberately tiny and separate from ``config.yaml`` (user-facing Hermes config, ~5000 +# lines of defaults): this is metadata ABOUT the profile. Missing file -> empty defaults, +# never an error; the kanban decomposer falls back to the profile name. def read_profile_meta(profile_dir: Path) -> dict: - """Read ``/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": ""} + """Read ``profile.yaml`` -> ``{description, description_auto, display_name}`` (empty + defaults when missing/unreadable). Never raises — a corrupt file on one profile must not + break ``hermes profile list``.""" + data = _load_yaml_dict(profile_dir / "profile.yaml") or {} return { "description": str(data.get("description") or "").strip(), "description_auto": bool(data.get("description_auto", False)), @@ -832,17 +607,11 @@ def read_profile_meta(profile_dir: Path) -> dict: def write_profile_meta( - profile_dir: Path, - *, - description: Optional[str] = None, - description_auto: Optional[bool] = None, + profile_dir: Path, *, description: Optional[str] = None, description_auto: Optional[bool] = None, display_name: Optional[str] = None, ) -> None: - """Update ``/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. - """ + """Update ``profile.yaml`` in place: only passed fields are overwritten; the file is + created if missing. The 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" @@ -857,36 +626,23 @@ def write_profile_meta( 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). + # Atomic write: bare open("w") truncates before the dump, and the read path swallows + # parse errors as {}, so a crashed write would silently drop unspecified fields. 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. - """ + """``display_name (canonical_id)``, or the bare id when no display name is set (or it + equals the id) — byte-for-byte the pre-feature rendering.""" 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.") + """Set (or clear, with ``""``) a presentation-only display name. Returns the stored value; + raises ``ValueError`` over 64 chars.""" + canon, profile_dir = _existing_profile_dir(profile_name) cleaned = (display_name or "").strip() if len(cleaned) > 64: raise ValueError(f"Display name too long ({len(cleaned)} chars, max 64).") @@ -894,9 +650,7 @@ def set_profile_display_name(profile_name: str, display_name: str) -> str: 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.""" @@ -910,22 +664,11 @@ def _profile_info(name: str, path: Path, *, is_default: bool, alias_name: Option 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", ""), + 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, + **meta, ) @@ -935,35 +678,26 @@ def list_profiles() -> List[ProfileInfo]: 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() + alias_map = build_alias_map() # ONCE, not per profile (was the dominant cost) 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. +def profiles_to_serve(multiplex: bool, profile_allowlist: Optional[List[str]] = None) -> List[Tuple[str, Path]]: + """``(profile_name, hermes_home)`` pairs a gateway should serve — the single chokepoint + for "which profiles does the inbound gateway handle". - 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. - """ + ``multiplex=False``: exactly one entry for the *active* profile (byte-for-byte the + historical single-profile behavior; name is ``"default"`` or the named profile's id). + ``multiplex=True``: default plus every live named profile, optionally filtered by + *profile_allowlist* (invalid entries skipped, missing ones warned once).""" 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: @@ -972,26 +706,19 @@ def profiles_to_serve( if not isinstance(entry, str): continue try: - name = normalize_profile_name(entry) - validate_profile_name(name) + name = _canon_valid(entry) 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), - ) - + logger.warning("Skipping missing gateway.multiplex_profile_allowlist profile(s): %s", ", ".join(missing)) return serve @@ -1001,13 +728,10 @@ def _resolve_clone_source(clone_from: Optional[str]) -> Path: 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) + clone_from = _canon_valid(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}" - ) + raise FileNotFoundError(f"Source profile '{clone_from or 'active'}' does not exist at {source_dir}") return source_dir @@ -1015,20 +739,15 @@ def _seed_file_if_missing(path: Path, text: str, mode: Optional[int] = None) -> """Best-effort: write *text* to *path* unless it already exists; never raises.""" if path.exists(): return - try: + with contextlib.suppress(OSError): 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. - """ + """Copy one profile-relative file if it exists. ``.env`` is tightened to owner-only: + ``copy2`` preserves source mode bits, so a loose source (umask 0o644) would leak.""" src = source_dir / relpath if not src.exists(): return @@ -1036,170 +755,126 @@ def _clone_file(source_dir: Path, profile_dir: Path, relpath: str) -> None: dst.parent.mkdir(parents=True, exist_ok=True) shutil.copy2(src, dst) if relpath == ".env": - try: + with contextlib.suppress(OSError): os.chmod(str(dst), 0o600) - except OSError: - pass + + +def _clone_all_into(source_dir: Path, profile_dir: Path, canon: str) -> None: + """--clone-all: full copytree minus infrastructure/history, then strip runtime files + and cloned single-use OAuth grants.""" + shutil.copytree(source_dir, profile_dir, symlinks=True, ignore=_clone_all_copytree_ignore(source_dir)) + for stale in _CLONE_ALL_STRIP: + (profile_dir / stale).unlink(missing_ok=True) + # auth.json / .anthropic_oauth.json copied verbatim fork single-use OAuth grants + # (Anthropic / Codex / xAI): one credential with two owners, and the first profile to + # refresh revokes the pair for every sibling. 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, + ) + + +def _bootstrap_profile_dir(profile_dir: Path, source_dir: Optional[Path]) -> None: + """Fresh layout: bootstrap dirs, then either seed a model block (no source) or clone + config files, installed skills (the dashboard's "clone from default" must keep bundled + AND user-installed skills), and memory/identity files from *source_dir*.""" + 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) + return + 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) 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, + 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. - """ + 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) - + canon = _canon_valid(name) if canon == "default": - raise ValueError( - "Cannot create a profile named 'default' — it is the built-in profile (~/.hermes)." - ) - + 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. + # 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, - ) + _clone_all_into(source_dir, profile_dir, canon) 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) + _bootstrap_profile_dir(profile_dir, source_dir) - 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 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 an empty .env so the profile owns a credentials file from day one. Without it, + # profile-scoped env writes (dashboard Channels/Keys pages, `hermes -p auth add`) + # had no file until first write and the profile silently inherited shell API keys — + # read by users as "the new profile reads the root .env". Skipped when a clone copied one. _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: + # Default SOUL.md to customize immediately (skipped when a clone already provided one). + with contextlib.suppress(Exception): # best-effort — don't fail profile creation over this 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 + # Opt-out marker read by seed_profile_skills() and `hermes update`'s all-profile sync # (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" + "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 + # Migrate config-only clones now so desktop/status don't warn that a just-created + # profile is v0/outdated; --clone-all snapshots stay 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. + # Description last, so a partial-create failure doesn't strand a description file. 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` + with contextlib.suppress(Exception): # non-fatal — `hermes profile describe` works later + write_profile_meta(profile_dir, description=description.strip(), description_auto=False) - # Phase 4: when running inside a container under s6, register the - # new profile's gateway as a runtime s6 service so - # `hermes -p 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. + # Inside a container under s6, register the gateway as a runtime s6 service so + # `hermes -p gateway start` supervises via `s6-svc -u` instead of a bare + # process. No-op on host (systemd/launchd/windows unit generation handles 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. - """ + """Seed bundled skills into a profile via subprocess (sync_skills() caches HERMES_HOME at + module level). Returns the sync result dict, or None on failure. ``--no-skills`` profiles + still run the sync: ``sync_skills()`` detects the marker and seeds only essentials.""" project_root = Path(__file__).parent.parent.resolve() try: result = subprocess.run( @@ -1228,14 +903,10 @@ def seed_profile_skills(profile_dir: Path, quiet: bool = False) -> Optional[dict 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``. - """ + """Give every named profile predating per-profile ``.env`` one (copy of the default's, or + the placeholder header). 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(): @@ -1250,30 +921,25 @@ def backfill_profile_envs(quiet: bool = False) -> List[str]: 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 +# python / python3 / python3.12 / pythonw(.exe): the interpreter basenames a +# `#!/…/python3` console-script shim is exec'd through when something (e.g. Electron's +# `findOnPath('hermes')`) spawns the shim by handing the interpreter its path — then 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. +# Console-script entry points this project ships (pyproject.toml [project.scripts]). +# argv[1] is matched against exact names, not ``startswith("hermes")``: with a bare +# interpreter argv[0], argv[1] can be ANY user script ("hermes-notes.py"). _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. - """ + """True for a Hermes process: entrypoint marker in argv, 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"): @@ -1294,42 +960,32 @@ def _argv_profile_selectors(argv: list): 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. - """ + """PIDs of running Hermes *backends* bound to this profile (``gateway.pid`` only tracks + the messaging gateway). Tightly scoped: current-user processes, backend subcommands only + (never an interactive ``chat``/``tui``), never this process or its ancestors. Empty when + ``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 profile - # delete` runs under the very profile it's deleting). + # Never terminate ourselves or a parent (`hermes -p profile delete` runs under + # the very profile it's deleting). skip: set[int] = {os.getpid()} - try: + with contextlib.suppress(Exception): 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 @@ -1338,75 +994,45 @@ def _profile_bound_backend_pids(canon: str, profile_dir: Path) -> list[int]: 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. + # Bound to THIS profile by selector flag, or by HERMES_HOME pointing at its dir. + bound = any(normalize_profile_name(sel) == canon for sel in _argv_profile_selectors(argv)) if not bound: - try: + with contextlib.suppress(Exception): # environ() can raise AccessDenied even same-user 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 - + bound = bool(env_home) and Path(env_home).resolve() == resolved_dir if bound: pids.append(pid) - except (psutil.NoSuchProcess, psutil.AccessDenied, psutil.ZombieProcess): - continue except Exception: - continue - + continue # NoSuchProcess / AccessDenied / ZombieProcess and anything else 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. - """ + """After a graceful ``terminate_pid``, wait up to *wait* seconds (0.5s polls) for *pids* + to exit, then force-kill stragglers. True when every pid exited gracefully. + ``start_times`` pins each force kill to the same process incarnation (PID reuse guard).""" 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 + with contextlib.suppress(ProcessLookupError, PermissionError, OSError): + terminate_pid(pid, force=True, expected_start_time=start_times.get(pid, get_process_start_time(pid))) 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. - """ + """Terminate Desktop-spawned / stray backends bound to this profile. Complements + ``_stop_gateway_process`` (which only knows ``gateway.pid``): a live ``serve``/``dashboard`` + keeps creating files while ``rmtree`` walks, so the final rmdir fails ENOTEMPTY.""" pids = _profile_bound_backend_pids(canon, profile_dir) if not pids: return @@ -1414,57 +1040,41 @@ def _stop_profile_backends(canon: str, profile_dir: Path) -> None: 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 + """onexc/onerror handler: add +w on PermissionError so rmtree can proceed. Covers NixOS- + style read-only copies where the path itself (0444) or its parent (0555) isn't writable.""" + # onexc(func, path, exc_instance) on 3.12+; onerror(func, path, exc_info_tuple) on 3.11. if isinstance(exc, tuple): - exc = exc[1] # exc_info → actual exception object + exc = exc[1] 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)): + for target in (path, os.path.dirname(path)): # parent needed for unlink/rmdir if target: - try: + with contextlib.suppress(OSError): 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). - """ + """``shutil.rmtree`` with a short retry loop: a just-terminated process can leave in-flight + writes (SQLite -wal/-shm checkpoints, sandbox temp files) landing after rmtree walked + past a directory — ENOTEMPTY on POSIX, transient PermissionError on Windows.""" 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: + except TypeError: # ``onexc`` is 3.12+; 3.11 has ``onerror`` shutil.rmtree(profile_dir, onerror=onexc_handler) return except OSError as e: @@ -1482,7 +1092,6 @@ def _print_delete_summary(canon: str, profile_dir: Path, gw_running: bool, wrapp 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: @@ -1493,7 +1102,6 @@ def _print_delete_summary(canon: str, profile_dir: Path, gw_running: bool, wrapp 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: @@ -1503,29 +1111,16 @@ def _print_delete_summary(canon: str, profile_dir: Path, gw_running: bool, wrapp 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. - """ + """Delete a profile, its wrapper script, and its gateway service (service disabled first + to prevent auto-restart, gateway stopped if running).""" 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.") - + raise ValueError("Cannot delete the default profile (~/.hermes).\nTo remove everything, use: hermes uninstall") + canon, profile_dir = _existing_profile_dir(canon) 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: @@ -1537,43 +1132,29 @@ def delete_profile(name: str, yes: bool = False) -> Path: print("Cancelled.") return profile_dir - # 1. Disable service (prevents auto-restart) + # 1. Disable service (prevents auto-restart); drop the s6 slot on container (host no-op). _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-/ so s6-supervise drops it. _maybe_unregister_gateway_service(canon) - # 2. Stop running gateway + # 2. Stop the gateway, then other backends bound to this profile (Desktop-spawned + # serve/dashboard the pid file never names): they hold the SQLite connection open and + # keep writing, which made rmtree fail ENOTEMPTY and resurrected the deleted tree. 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. + # Tombstone before rmtree so a stale serve/logging mkdir cannot relist this name live. 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/ route) the handles live in this process and are - # closed here; from the CLI this finds nothing and is a no-op. - try: + # Release this process's holographic memory-store connections into the profile. The + # Desktop's main serve process opens memory_store.db for every profile and is + # deliberately not stopped above; on Windows its handles fail rmtree with WinError 32. + # Inside serve (DELETE /api/profiles/) the handles live here; from the CLI no-op. + with contextlib.suppress(Exception): # best-effort: never block the delete on the release path 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): @@ -1590,20 +1171,15 @@ def delete_profile(name: str, yes: bool = False) -> Path: # 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. - """ + """The s6 service manager inside the container, else None. Silent on host: a failing/ + absent detector must never print a confusing s6 warning to non-container users.""" try: from hermes_cli.service_manager import detect_service_manager, get_service_manager if detect_service_manager() != "s6": @@ -1615,33 +1191,21 @@ def _s6_runtime_manager(): 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. - """ + """Register a profile's gateway with s6 inside the container. Best-effort: profile + creation must not fail over a supervision-tree hiccup; `gateway start` re-registers.""" 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 + pass # already registered (e.g. the container-boot reconciler brought up a stale slot) 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. - """ + """Tear down a profile's s6 gateway service inside the container; host no-op, idempotent.""" mgr = _s6_runtime_manager() if mgr is None: return @@ -1655,8 +1219,7 @@ 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 + # HERMES_HOME is set temporarily so _profile_suffix resolves the service name. old_home = os.environ.get("HERMES_HOME") try: os.environ["HERMES_HOME"] = str(profile_dir) @@ -1684,10 +1247,9 @@ def _cleanup_gateway_service(name: str, profile_dir: Path) -> None: except Exception as e: print(f"⚠ Service cleanup: {e}") finally: + os.environ.pop("HERMES_HOME", None) 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: @@ -1695,21 +1257,14 @@ def _stop_gateway_process(profile_dir: Path) -> None: 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, - ) - + # Cross-profile kill refusal: the record's hermes_home stamp names the gateway's TRUE + # owner. A poisoned gateway.pid in this dir can point at another profile's live + # gateway — killing it starts a mutual SIGTERM restart loop. + 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 " @@ -1717,11 +1272,8 @@ def _stop_gateway_process(profile_dir: Path) -> None: "(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. + # terminate_pid picks the Windows primitive (taskkill /T cascades to children; raw + # os.kill with SIGKILL fails at import on Windows). expected_start_time = data.get("start_time") if expected_start_time is None: expected_start_time = get_process_start_time(pid) @@ -1736,9 +1288,7 @@ def _stop_gateway_process(profile_dir: Path) -> None: print(f"⚠ Could not stop gateway: {e}") -# --------------------------------------------------------------------------- # Active profile (sticky default) -# --------------------------------------------------------------------------- def get_active_profile() -> str: """Read the sticky active profile name.""" @@ -1750,110 +1300,78 @@ def get_active_profile() -> str: def set_active_profile(name: str) -> None: - """Set the sticky active profile.""" - canon = normalize_profile_name(name) - validate_profile_name(canon) + """Set the sticky active profile (``default`` = remove the file).""" + canon = _canon_valid(name) if canon != "default" and not profile_exists(canon): - raise FileNotFoundError( - f"Profile '{canon}' does not exist. " - f"Create it with: hermes profile create {canon}" - ) - + raise _missing_profile_error(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 = path.with_suffix(".tmp") # atomic write 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: + with contextlib.suppress(Exception): 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/``; ``"custom"`` for any other path. - """ + """Profile name inferred from HERMES_HOME: ``"default"`` when unset or ``~/.hermes``, the + name under ``~/.hermes/profiles/``, ``"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: + resolved = get_hermes_home().resolve() + if resolved == _get_default_hermes_home().resolve(): return "default" - profiles_root = _get_profiles_root().resolve() try: - rel = resolved.relative_to(profiles_root) - parts = rel.parts + parts = resolved.relative_to(profiles_root).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. - """ + """True when *path* lies inside a Git checkout. Walks the path's OWN resolved ancestry + (not cwd) so the check holds when HERMES_HOME sits in a checkout but the process runs + elsewhere (cron, service manager). Resolution failure reports True (fail closed).""" try: resolved = path.resolve() - except (OSError, RuntimeError): - # RuntimeError: symlink loops on Python <= 3.12; fail closed either way. + except (OSError, RuntimeError): # RuntimeError: symlink loops on Python <= 3.12 return True - return any( - (candidate / ".git").exists() for candidate in (resolved, *resolved.parents) - ) + 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. + # A custom deployment may point HERMES_HOME at its source checkout: use a sibling store, + # falling back to the OS temp dir only when the user's home itself is a checkout (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) first. 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}", + 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. + # Fail closed: writing a secret-bearing archive into a source tree is the incident this + # helper prevents; a stderr warning would not stop a scripted export. raise ValueError( "No safe automatic export destination: every candidate directory is " "inside a Git checkout. Provide an explicit output path outside the " @@ -1862,18 +1380,13 @@ def _profile_export_directory() -> Path: 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 ``.tar.gz`` default looked like a repo artifact and got committed by accident. - """ - canon = normalize_profile_name(name) - validate_profile_name(canon) + """Managed destination for an export with no explicit output — outside the cwd and every + profile, since a ``.tar.gz`` default in a source checkout got committed by accident.""" + canon = _canon_valid(name) 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. + # exist_ok=True silently accepts 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 " @@ -1882,22 +1395,16 @@ def get_profile_export_path(name: str, *, timestamp: Optional[str] = None) -> Pa 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." + "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. - """ + """copytree ignore for the default-profile export: root-level allow-list + (``_DEFAULT_EXPORT_INCLUDE_ROOT``) plus universal exclusions. Surviving text files are + then force-redacted by :func:`_scrub_export_secrets`.""" def _ignore(directory: str, contents: list) -> set: # Universal exclusions (any depth) plus npm lockfiles that can appear at root. @@ -1907,12 +1414,8 @@ def _default_export_ignore(root_dir: Path): 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 - ) + ignored.update(entry for entry in contents if entry not in _DEFAULT_EXPORT_INCLUDE_ROOT) return ignored return _ignore @@ -1921,24 +1424,16 @@ def _default_export_ignore(root_dir: Path): # 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). +# Text/config suffixes secret-scrubbed on export; binary DBs, images etc. are left alone. _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", + ".md", ".txt", ".yaml", ".yml", ".json", ".jsonl", ".toml", ".ini", ".cfg", ".conf", ".py", ".sh", + ".bash", ".zsh", ".js", ".ts", ".tsx", ".jsx", ".css", ".html", ".xml", ".csv", }) +# ``Path(".cursorrules").suffix`` is "" — name-match; ``*.env.example`` uses endswith. +_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 @@ -1948,70 +1443,42 @@ def _should_redact_export_file(path: Path) -> bool: 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)``). - """ + """Force-redact secret-shaped strings in a staged export tree (same pass as ``hermes + sessions export --redact``). Runs on the staged copy only; symlinks to text files are + materialized when content changes so redaction never follows a link back into the source.""" 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(): + if not path.is_file(): # broken links, symlinked dirs, non-files 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. - """ + """Export a profile to a tar.gz archive; credential files are excluded and staged text is + force-redacted first. 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) + canon, profile_dir = _existing_profile_dir(name) # Archive base name without extension (.tar.gz appended by the writer). - base = str(output).removesuffix(".tar.gz").removesuffix(".tgz") + base = str(Path(output_path)).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. + # The default profile IS ~/.hermes (dir name ".hermes"), so both paths stage a filtered + # copy under a temp dir named after the canonical id: root allow-list for default, + # credential exclusion for named profiles. def _ignore_credentials(directory: str, contents: list) -> set: return _EXPORT_CREDENTIAL_FILES & set(contents) @@ -2019,7 +1486,10 @@ def export_profile(name: str, output_path: str, extra_files: Optional[Dict[str, with tempfile.TemporaryDirectory() as tmpdir: staged = Path(tmpdir) / canon shutil.copytree(profile_dir, staged, symlinks=True, ignore=ignore) - _stage_extras(staged) + for rel, content in (extra_files or {}).items(): + target = staged.joinpath(*normalize_archive_parts(rel)) + target.parent.mkdir(parents=True, exist_ok=True) + target.write_text(content, encoding="utf-8") _scrub_export_secrets(staged) return Path(make_targz(base, tmpdir, canon)) @@ -2027,11 +1497,9 @@ def export_profile(name: str, output_path: str, extra_files: Optional[Dict[str, 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 @@ -2041,51 +1509,35 @@ def import_profile(archive_path: str, name: Optional[str] = None) -> Path: "Specify it explicitly: hermes profile import --name " ) if archive_root is None: - raise ValueError( - "Profile archive must contain exactly one top-level directory." - ) + 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) + # Default-profile archives have "default/" at top level; importing as "default" would + # target ~/.hermes itself. + canon = _canon_valid(inferred_name) if canon == "default": raise ValueError( "Cannot import as 'default' — that is the built-in root profile (~/.hermes). " "Specify a different name: hermes profile import --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) - + _get_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}" - ) - + 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.""" @@ -2095,10 +1547,8 @@ def _atomic_write_json(path: Path, data: dict) -> bool: tmp.replace(path) return True except OSError: - try: + with contextlib.suppress(OSError): tmp.unlink(missing_ok=True) - except OSError: - pass return False @@ -2107,13 +1557,9 @@ def _migrate_honcho_profile_host(old_name: str, new_name: str, new_dir: Path) -> 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", + new_dir / "honcho.json", _get_default_hermes_home() / "honcho.json", Path.home() / ".honcho" / "config.json" ] - seen: set[Path] = set() for path in candidates: try: @@ -2123,58 +1569,43 @@ def _migrate_honcho_profile_host(old_name: str, new_name: str, new_dir: 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_`` or legacy ``hermes.``. - block["aiPeer"] = old_name + block["aiPeer"] = old_name # source_host is ``hermes_`` or legacy ``hermes.`` 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) - + """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`` instead — the canonical id stays ``default``.""" + old_canon = _canon_valid(old_name) 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) - + new_canon = _canon_valid(new_name) 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(): @@ -2203,38 +1634,26 @@ def rename_profile(old_name: str, new_name: str) -> Path: # 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) + """Resolve a profile name to a HERMES_HOME path string. Called early in the CLI entry + point, before hermes modules are imported, to set HERMES_HOME.""" + canon = _canon_valid(profile_name) 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()). + # 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}" - ) - + raise _missing_profile_error(canon) return str(profile_dir) diff --git a/hermes_cli/projects_cmd.py b/hermes_cli/projects_cmd.py index c357efc1e7..0b1ff36d13 100644 --- a/hermes_cli/projects_cmd.py +++ b/hermes_cli/projects_cmd.py @@ -9,9 +9,7 @@ import sys from hermes_cli import projects_db as pdb -def build_parser( - parent_subparsers: argparse._SubParsersAction, -) -> argparse.ArgumentParser: +def build_parser(parent_subparsers: argparse._SubParsersAction) -> argparse.ArgumentParser: """Attach the ``project`` subcommand tree. Returns the top parser.""" parser = parent_subparsers.add_parser( "project", @@ -83,7 +81,6 @@ def projects_command(args: argparse.Namespace) -> int: file=sys.stderr, ) return 0 - handler = _HANDLERS.get(action) if handler is None: print(f"Unknown project action: {action}", file=sys.stderr) @@ -91,10 +88,15 @@ def projects_command(args: argparse.Namespace) -> int: return handler(args) +def _err(message: str) -> int: + print(f"project: {message}", file=sys.stderr) + return 1 + + def _resolve(conn, ident: str): proj = pdb.get_project(conn, ident) if proj is None: - print(f"project: no such project: {ident}", file=sys.stderr) + _err(f"no such project: {ident}") return proj @@ -133,23 +135,14 @@ def _print_project(proj) -> None: if proj.folders: print(" folders:") for f in proj.folders: - mark = " *" if f.is_primary else " " - label = f" ({f.label})" if f.label else "" - print(f" {mark} {f.path}{label}") + print(f" {' *' if f.is_primary else ' '} {f.path}{f' ({f.label})' if f.label else ''}") @_db_command def _cmd_create(args, conn) -> int: pid = pdb.create_project( - conn, - name=args.name, - slug=args.slug, - folders=args.folders, - primary_path=args.primary, - description=args.description, - icon=args.icon, - color=args.color, - board_slug=args.board, + conn, name=args.name, slug=args.slug, folders=args.folders, primary_path=args.primary, + description=args.description, icon=args.icon, color=args.color, board_slug=args.board, ) if args.use: pdb.set_active(conn, pid) @@ -165,16 +158,13 @@ def _cmd_create(args, conn) -> int: @_db_command def _cmd_list(args, conn) -> int: active = pdb.get_active_id(conn) - projs = pdb.list_projects( - conn, include_archived=getattr(args, "include_archived", False) - ) + projs = pdb.list_projects(conn, include_archived=getattr(args, "include_archived", False)) if not projs: print("No projects yet. Create one with `hermes project create `.") return 0 for p in projs: - marker = "*" if p.id == active else " " flags = " (archived)" if p.archived else "" - print(f"{marker} {p.slug:<24} {p.name}{flags} [{len(p.folders)} folder(s)]") + print(f"{'*' if p.id == active else ' '} {p.slug:<24} {p.name}{flags} [{len(p.folders)} folder(s)]") return 0 @@ -194,8 +184,7 @@ def _cmd_add_folder(args, conn, proj) -> int: @_with_project def _cmd_remove_folder(args, conn, proj) -> int: if not pdb.remove_folder(conn, proj.id, args.path): - print(f"project: folder not in project: {args.path}", file=sys.stderr) - return 1 + return _err(f"folder not in project: {args.path}") print(f"Removed {args.path} from {proj.slug}") return 0 @@ -210,12 +199,7 @@ def _cmd_rename(args, conn, proj) -> int: @_with_project def _cmd_set_primary(args, conn, proj) -> int: if not pdb.set_primary(conn, proj.id, args.path): - print( - f"project: '{args.path}' is not a folder of {proj.slug}; " - f"add it first with `hermes project add-folder`.", - file=sys.stderr, - ) - return 1 + return _err(f"'{args.path}' is not a folder of {proj.slug}; add it first with `hermes project add-folder`.") print(f"Set primary of {proj.slug} -> {args.path}") return 0 @@ -234,48 +218,37 @@ def _cmd_use(args, conn) -> int: return 0 -@_with_project -def _cmd_archive(args, conn, proj) -> int: - pdb.archive_project(conn, proj.id) - print(f"Archived {proj.slug}") - return 0 +def _flag_command(op: str, verb: str): + """Handler for ``pdb.(conn, proj.id)`` followed by ``" "``.""" + @_with_project + def handler(args, conn, proj) -> int: + getattr(pdb, op)(conn, proj.id) + print(f"{verb} {proj.slug}") + return 0 -@_with_project -def _cmd_restore(args, conn, proj) -> int: - pdb.restore_project(conn, proj.id) - print(f"Restored {proj.slug}") - return 0 + return handler @_with_project def _cmd_bind_board(args, conn, proj) -> int: pdb.update_project(conn, proj.id, board_slug=args.board) - if args.board.strip(): - print(f"Bound {proj.slug} -> board {args.board}") - _sync_board_default_workdir(proj, args.board) - else: + if not args.board.strip(): print(f"Unbound board from {proj.slug}") + return 0 + print(f"Bound {proj.slug} -> board {args.board}") + if proj.primary_path: # best-effort: point the bound board's default_workdir at the primary repo + try: + from hermes_cli import kanban_db as kb + + slug = kb._normalize_board_slug(args.board) + if slug and (slug == kb.DEFAULT_BOARD or kb.board_exists(slug)): + kb.write_board_metadata(slug, default_workdir=proj.primary_path) + except Exception: + pass return 0 -def _sync_board_default_workdir(proj, board_slug: str) -> None: - """Best-effort: point the bound board's default_workdir at the primary repo.""" - if not proj.primary_path: - return - try: - from hermes_cli import kanban_db as kb - - slug = kb._normalize_board_slug(board_slug) - if not slug: - return - if slug != kb.DEFAULT_BOARD and not kb.board_exists(slug): - return - kb.write_board_metadata(slug, default_workdir=proj.primary_path) - except Exception: - pass - - _HANDLERS = { "create": _cmd_create, "list": _cmd_list, @@ -286,7 +259,7 @@ _HANDLERS = { "rename": _cmd_rename, "set-primary": _cmd_set_primary, "use": _cmd_use, - "archive": _cmd_archive, - "restore": _cmd_restore, + "archive": _flag_command("archive_project", "Archived"), + "restore": _flag_command("restore_project", "Restored"), "bind-board": _cmd_bind_board, } diff --git a/hermes_cli/projects_db.py b/hermes_cli/projects_db.py index 5a70fbb453..a90c761a0e 100644 --- a/hermes_cli/projects_db.py +++ b/hermes_cli/projects_db.py @@ -1,7 +1,7 @@ """Per-profile first-class Project store. -The schema is intentionally small and additive: column additions go through -:func:`_add_column_if_missing` so opening an old DB is always safe. +The schema is small and additive: column additions go through ``add_column_if_missing`` so +opening an old DB is always safe. """ from __future__ import annotations @@ -19,20 +19,12 @@ from typing import Iterable, List, Optional from hermes_cli.sqlite_util import add_column_if_missing as _add_column_if_missing, write_txn from hermes_constants import get_hermes_home -# --------------------------------------------------------------------------- -# Paths -# --------------------------------------------------------------------------- - def projects_db_path() -> Path: """The per-profile projects DB path (``$HERMES_HOME/projects.db``).""" return get_hermes_home() / "projects.db" -# --------------------------------------------------------------------------- -# Schema -# --------------------------------------------------------------------------- - SCHEMA_SQL = """ CREATE TABLE IF NOT EXISTS projects ( id TEXT PRIMARY KEY, @@ -74,24 +66,17 @@ CREATE TABLE IF NOT EXISTS discovered_repos ( ); """ +# --- Slug + id helpers ------------------------------------------------------- -# --------------------------------------------------------------------------- -# Slug + id helpers -# --------------------------------------------------------------------------- - -# Lowercase alphanumerics, hyphens, underscores; 1-64 chars; no leading -# separator. Strict enough to stop traversal and path separators, loose enough -# for kebab-case names like ``hermes-agent``. Display formatting (spaces, -# emoji, capitalisation) lives in ``name``; the slug is just a stable handle. +# Lowercase alphanumerics, hyphens, underscores; 1-64 chars; no leading separator. Strict enough to +# stop traversal/path separators, loose enough for kebab-case. Display formatting lives in ``name``. _SLUG_RE = re.compile(r"^[a-z0-9][a-z0-9\-_]{0,63}$") def _slugify(name: str) -> str: """Derive a slug candidate from a human name (best-effort).""" - s = str(name or "").strip().lower() - s = re.sub(r"[^a-z0-9]+", "-", s).strip("-_") - s = s[:64].strip("-_") - return s or "project" + s = re.sub(r"[^a-z0-9]+", "-", str(name or "").strip().lower()).strip("-_") + return s[:64].strip("-_") or "project" def normalize_slug(slug: Optional[str]) -> Optional[str]: @@ -110,10 +95,6 @@ def normalize_slug(slug: Optional[str]) -> Optional[str]: return s -def _new_project_id() -> str: - return "p_" + secrets.token_hex(4) - - def _now() -> int: return int(time.time()) @@ -124,19 +105,20 @@ def _normalize_path(path: str) -> str: return p.rstrip("/\\") or p -# --------------------------------------------------------------------------- -# Connection management -# --------------------------------------------------------------------------- +# --- Connection management --------------------------------------------------- _INITIALIZED_PATHS: set[str] = set() +# TEXT columns added to `projects` after v1; re-applied idempotently on every open so a legacy DB +# upgrades in place. +_OPTIONAL_PROJECT_COLUMNS = ("board_slug", "primary_path", "icon", "color") + def connect(db_path: Optional[Path] = None) -> sqlite3.Connection: """Open (and initialize if needed) the per-profile projects DB. - WAL with DELETE fallback for network filesystems (shared helper from ``hermes_state``). Schema - init is idempotent (``CREATE TABLE IF NOT EXISTS`` + additive migrations) and cached per-path - per-process. + WAL with DELETE fallback for network filesystems (``hermes_state`` helper). Schema init is + idempotent (``CREATE TABLE IF NOT EXISTS`` + additive migrations) and cached per-path per-process. """ path = db_path if db_path is not None else projects_db_path() path.parent.mkdir(parents=True, exist_ok=True) @@ -150,7 +132,10 @@ def connect(db_path: Optional[Path] = None) -> sqlite3.Connection: conn.execute("PRAGMA foreign_keys=ON") if resolved not in _INITIALIZED_PATHS: conn.executescript(SCHEMA_SQL) - _migrate_add_optional_columns(conn) + cols = {row["name"] for row in conn.execute("PRAGMA table_info(projects)")} + for col in _OPTIONAL_PROJECT_COLUMNS: + if col not in cols: + _add_column_if_missing(conn, "projects", col, f"{col} TEXT") _INITIALIZED_PATHS.add(resolved) except Exception: conn.close() @@ -160,13 +145,8 @@ def connect(db_path: Optional[Path] = None) -> sqlite3.Connection: @contextlib.contextmanager def connect_closing(db_path: Optional[Path] = None): - """Open a projects DB connection and guarantee it is closed on exit. - - sqlite3's connection context manager only commits/rollbacks; it does NOT close the file - descriptor. Long-lived processes (gateway, dashboard) route many project operations through - ``connect()``; without closing, FDs to ``projects.db`` accumulate. Mirrors - ``kanban_db.connect_closing``. - """ + """Open a projects DB connection and close it on exit (sqlite3's own context manager only + commits/rollbacks, so long-lived gateway/dashboard processes would leak fds otherwise).""" conn = connect(db_path=db_path) try: yield conn @@ -175,23 +155,7 @@ def connect_closing(db_path: Optional[Path] = None): conn.close() -# TEXT columns added to `projects` after v1; re-applied idempotently on every -# open so a legacy DB upgrades in place. -_OPTIONAL_PROJECT_COLUMNS = ("board_slug", "primary_path", "icon", "color") - - -def _migrate_add_optional_columns(conn: sqlite3.Connection) -> None: - """Add columns introduced after v1 to legacy DBs (safe on every open).""" - cols = {row["name"] for row in conn.execute("PRAGMA table_info(projects)")} - for col in _OPTIONAL_PROJECT_COLUMNS: - if col not in cols: - _add_column_if_missing(conn, "projects", col, f"{col} TEXT") - - -# --------------------------------------------------------------------------- -# Dataclasses -# --------------------------------------------------------------------------- - +# --- Dataclasses ------------------------------------------------------------- @dataclass class ProjectFolder: @@ -201,12 +165,7 @@ class ProjectFolder: added_at: int = 0 def to_dict(self) -> dict: - return { - "path": self.path, - "label": self.label, - "is_primary": bool(self.is_primary), - "added_at": self.added_at, - } + return {"path": self.path, "label": self.label, "is_primary": bool(self.is_primary), "added_at": self.added_at} @dataclass @@ -224,55 +183,33 @@ class Project: folders: List[ProjectFolder] = field(default_factory=list) def to_dict(self) -> dict: - return { - "id": self.id, - "slug": self.slug, - "name": self.name, - "description": self.description, - "icon": self.icon, - "color": self.color, - "board_slug": self.board_slug, - "primary_path": self.primary_path, - "archived": bool(self.archived), - "created_at": self.created_at, - "folders": [f.to_dict() for f in self.folders], - } + d = {k: getattr(self, k) for k in ("id", "slug", "name", "description", "icon", "color", "board_slug", "primary_path")} + return {**d, "archived": bool(self.archived), "created_at": self.created_at, "folders": [f.to_dict() for f in self.folders]} # Nullable TEXT columns that may be absent from a legacy row. _OPTIONAL_ROW_FIELDS = ("description", "icon", "color", "board_slug", "primary_path") -def _project_from_row(row: sqlite3.Row) -> Project: +def _load_project(conn: sqlite3.Connection, row: sqlite3.Row) -> Project: + """Materialize a ``projects`` row together with its folders.""" keys = row.keys() - return Project( - id=row["id"], - slug=row["slug"], - name=row["name"], - created_at=row["created_at"], + project = Project( + id=row["id"], slug=row["slug"], name=row["name"], created_at=row["created_at"], archived=bool(row["archived"]) if "archived" in keys else False, **{f: row[f] for f in _OPTIONAL_ROW_FIELDS if f in keys}, ) - - -def _load_project(conn: sqlite3.Connection, row: sqlite3.Row) -> Project: - """Materialize a ``projects`` row together with its folders.""" - project = _project_from_row(row) project.folders = [ ProjectFolder(path=r["path"], label=r["label"], is_primary=bool(r["is_primary"]), added_at=r["added_at"]) for r in conn.execute( - "SELECT path, label, is_primary, added_at FROM project_folders " - "WHERE project_id = ? ORDER BY is_primary DESC, added_at ASC", + "SELECT path, label, is_primary, added_at FROM project_folders WHERE project_id = ? ORDER BY is_primary DESC, added_at ASC", (project.id,), ).fetchall() ] return project -# --------------------------------------------------------------------------- -# CRUD -# --------------------------------------------------------------------------- - +# --- CRUD -------------------------------------------------------------------- def _unique_slug(conn: sqlite3.Connection, candidate: str) -> str: """Return ``candidate`` or ``candidate-2``, ``-3`` ... if taken.""" @@ -290,21 +227,15 @@ def _primary_path_key(path: str) -> str: return os.path.normcase(_normalize_path(path)) -def find_by_primary_path( - conn: sqlite3.Connection, path: str, *, include_archived: bool = False -) -> Optional[Project]: - """The first (oldest) project whose primary path matches ``path``, else None. - - Comparison is separator/case normalized so equivalent Windows spellings of the same folder do - not slip past the dedup check. - """ +def find_by_primary_path(conn: sqlite3.Connection, path: str, *, include_archived: bool = False) -> Optional[Project]: + """The first (oldest) project whose primary path matches ``path`` (separator/case normalized so + equivalent Windows spellings don't slip past the dedup check), else None.""" key = _primary_path_key(path) if not key: return None for proj in list_projects(conn, include_archived=include_archived): primary = proj.primary_path or next( - (f.path for f in proj.folders if f.is_primary), - proj.folders[0].path if proj.folders else None, + (f.path for f in proj.folders if f.is_primary), proj.folders[0].path if proj.folders else None ) if primary and _primary_path_key(primary) == key: return proj @@ -312,30 +243,18 @@ def find_by_primary_path( def create_project( - conn: sqlite3.Connection, - *, - name: str, - slug: Optional[str] = None, - folders: Optional[Iterable[str]] = None, - primary_path: Optional[str] = None, - description: Optional[str] = None, - icon: Optional[str] = None, - color: Optional[str] = None, - board_slug: Optional[str] = None, - allow_duplicate_path: bool = False, + conn: sqlite3.Connection, *, name: str, slug: Optional[str] = None, folders: Optional[Iterable[str]] = None, + primary_path: Optional[str] = None, description: Optional[str] = None, icon: Optional[str] = None, + color: Optional[str] = None, board_slug: Optional[str] = None, allow_duplicate_path: bool = False, ) -> str: - """Create a project and return its id. - - ``folders`` are normalized to absolute paths. If ``primary_path`` is given it is added to the - folder set (if not already present) and marked primary; otherwise the first folder becomes - primary. - """ + """Create a project and return its id. ``folders`` are normalized to absolute paths; ``primary_path`` + is added to the folder set (if absent) and marked primary, else the first folder becomes primary.""" name = str(name or "").strip() if not name: raise ValueError("project name must not be empty") slug_candidate = normalize_slug(slug) if slug else _slugify(name) - pid = _new_project_id() + pid = "p_" + secrets.token_hex(4) now = _now() folder_paths: List[str] = [] @@ -359,76 +278,40 @@ def create_project( ) with write_txn(conn): - unique = _unique_slug(conn, slug_candidate) conn.execute( - "INSERT INTO projects " - "(id, slug, name, description, icon, color, board_slug, " - " primary_path, created_at, archived) " + "INSERT INTO projects (id, slug, name, description, icon, color, board_slug, primary_path, created_at, archived) " "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 0)", - ( - pid, - unique, - name, - description, - icon, - color, - normalize_slug(board_slug) if board_slug else None, - primary, - now, - ), + (pid, _unique_slug(conn, slug_candidate), name, description, icon, color, + normalize_slug(board_slug) if board_slug else None, primary, now), ) for path in folder_paths: conn.execute( - "INSERT INTO project_folders " - "(project_id, path, label, is_primary, added_at) " - "VALUES (?, ?, ?, ?, ?)", + "INSERT INTO project_folders (project_id, path, label, is_primary, added_at) VALUES (?, ?, ?, ?, ?)", (pid, path, None, 1 if path == primary else 0, now), ) return pid -def list_projects( - conn: sqlite3.Connection, *, include_archived: bool = False -) -> List[Project]: - sql = "SELECT * FROM projects" - if not include_archived: - sql += " WHERE archived = 0" - sql += " ORDER BY created_at ASC" - rows = conn.execute(sql).fetchall() - return [_load_project(conn, r) for r in rows] +def list_projects(conn: sqlite3.Connection, *, include_archived: bool = False) -> List[Project]: + sql = "SELECT * FROM projects" + ("" if include_archived else " WHERE archived = 0") + " ORDER BY created_at ASC" + return [_load_project(conn, r) for r in conn.execute(sql).fetchall()] -def get_project( - conn: sqlite3.Connection, id_or_slug: str -) -> Optional[Project]: +def get_project(conn: sqlite3.Connection, id_or_slug: str) -> Optional[Project]: """Look up a project by id first, then by slug.""" - row = conn.execute( - "SELECT * FROM projects WHERE id = ?", (id_or_slug,) - ).fetchone() - if row is None: - row = conn.execute( - "SELECT * FROM projects WHERE slug = ?", (str(id_or_slug).lower(),) - ).fetchone() - if row is None: - return None - return _load_project(conn, row) + row = ( + conn.execute("SELECT * FROM projects WHERE id = ?", (id_or_slug,)).fetchone() + or conn.execute("SELECT * FROM projects WHERE slug = ?", (str(id_or_slug).lower(),)).fetchone() + ) + return None if row is None else _load_project(conn, row) def update_project( - conn: sqlite3.Connection, - project_id: str, - *, - name: Optional[str] = None, - description: Optional[str] = None, - icon: Optional[str] = None, - color: Optional[str] = None, - board_slug: Optional[str] = None, + conn: sqlite3.Connection, project_id: str, *, name: Optional[str] = None, description: Optional[str] = None, + icon: Optional[str] = None, color: Optional[str] = None, board_slug: Optional[str] = None, ) -> bool: - """Patch top-level project fields. Only provided fields change. - - ``icon``, ``color``, and ``board_slug`` accept an empty string to clear (store NULL) — passing - ``None`` leaves the field untouched, so callers that want to clear must send ``""``. - """ + """Patch top-level project fields; only provided (non-None) fields change. ``icon``, ``color`` and + ``board_slug`` take ``""`` to clear (store NULL) — ``None`` leaves the field untouched.""" if name is not None: name = str(name).strip() if not name: @@ -447,52 +330,35 @@ def update_project( if not sets: return False params = [stored for _, given, stored in fields if given is not None] + [project_id] + return _execute_rowcount(conn, f"UPDATE projects SET {', '.join(sets)} WHERE id = ?", params) > 0 + + +def _execute_rowcount(conn: sqlite3.Connection, sql: str, params) -> int: + """Run one write statement in its own txn and return the affected row count.""" with write_txn(conn): - cur = conn.execute( - f"UPDATE projects SET {', '.join(sets)} WHERE id = ?", params - ) - return cur.rowcount > 0 + cur = conn.execute(sql, params) + return cur.rowcount -def add_folder( - conn: sqlite3.Connection, - project_id: str, - path: str, - *, - label: Optional[str] = None, - is_primary: bool = False, -) -> str: +def add_folder(conn: sqlite3.Connection, project_id: str, path: str, *, label: Optional[str] = None, is_primary: bool = False) -> str: """Add a folder to a project. Returns the normalized path.""" norm = _normalize_path(path) if not norm: raise ValueError("folder path must not be empty") if get_project(conn, project_id) is None: raise ValueError(f"no such project: {project_id}") - now = _now() with write_txn(conn): conn.execute( - "INSERT OR IGNORE INTO project_folders " - "(project_id, path, label, is_primary, added_at) " - "VALUES (?, ?, ?, 0, ?)", - (project_id, norm, label, now), + "INSERT OR IGNORE INTO project_folders (project_id, path, label, is_primary, added_at) VALUES (?, ?, ?, 0, ?)", + (project_id, norm, label, _now()), ) if label is not None: - conn.execute( - "UPDATE project_folders SET label = ? " - "WHERE project_id = ? AND path = ?", - (label, project_id, norm), - ) - if is_primary: + conn.execute("UPDATE project_folders SET label = ? WHERE project_id = ? AND path = ?", (label, project_id, norm)) + # An explicit primary, or the first folder of an empty project, becomes primary. + if is_primary or conn.execute( + "SELECT 1 FROM project_folders WHERE project_id = ? AND is_primary = 1", (project_id,) + ).fetchone() is None: _set_primary_locked(conn, project_id, norm) - else: - # First folder of an empty project becomes primary implicitly. - existing_primary = conn.execute( - "SELECT 1 FROM project_folders " - "WHERE project_id = ? AND is_primary = 1", - (project_id,), - ).fetchone() - if existing_primary is None: - _set_primary_locked(conn, project_id, norm) return norm @@ -501,90 +367,50 @@ def remove_folder(conn: sqlite3.Connection, project_id: str, path: str) -> bool: norm = _normalize_path(path) with write_txn(conn): was_primary = conn.execute( - "SELECT is_primary FROM project_folders " - "WHERE project_id = ? AND path = ?", - (project_id, norm), + "SELECT is_primary FROM project_folders WHERE project_id = ? AND path = ?", (project_id, norm) ).fetchone() - cur = conn.execute( - "DELETE FROM project_folders WHERE project_id = ? AND path = ?", - (project_id, norm), - ) + cur = conn.execute("DELETE FROM project_folders WHERE project_id = ? AND path = ?", (project_id, norm)) if was_primary is not None and was_primary["is_primary"]: nxt = conn.execute( - "SELECT path FROM project_folders WHERE project_id = ? " - "ORDER BY added_at ASC LIMIT 1", - (project_id,), + "SELECT path FROM project_folders WHERE project_id = ? ORDER BY added_at ASC LIMIT 1", (project_id,) ).fetchone() - new_primary = nxt["path"] if nxt else None - if new_primary: - _set_primary_locked(conn, project_id, new_primary) + if nxt and nxt["path"]: + _set_primary_locked(conn, project_id, nxt["path"]) else: - conn.execute( - "UPDATE projects SET primary_path = NULL WHERE id = ?", - (project_id,), - ) + conn.execute("UPDATE projects SET primary_path = NULL WHERE id = ?", (project_id,)) return cur.rowcount > 0 -def _set_primary_locked( - conn: sqlite3.Connection, project_id: str, path: str -) -> None: +def _set_primary_locked(conn: sqlite3.Connection, project_id: str, path: str) -> None: """Set the primary folder (caller already holds a write txn).""" - conn.execute( - "UPDATE project_folders SET is_primary = 0 WHERE project_id = ?", - (project_id,), - ) - conn.execute( - "UPDATE project_folders SET is_primary = 1 " - "WHERE project_id = ? AND path = ?", - (project_id, path), - ) - conn.execute( - "UPDATE projects SET primary_path = ? WHERE id = ?", - (path, project_id), - ) + conn.execute("UPDATE project_folders SET is_primary = 0 WHERE project_id = ?", (project_id,)) + conn.execute("UPDATE project_folders SET is_primary = 1 WHERE project_id = ? AND path = ?", (project_id, path)) + conn.execute("UPDATE projects SET primary_path = ? WHERE id = ?", (path, project_id)) def set_primary(conn: sqlite3.Connection, project_id: str, path: str) -> bool: norm = _normalize_path(path) with write_txn(conn): - exists = conn.execute( - "SELECT 1 FROM project_folders WHERE project_id = ? AND path = ?", - (project_id, norm), - ).fetchone() - if exists is None: + if conn.execute("SELECT 1 FROM project_folders WHERE project_id = ? AND path = ?", (project_id, norm)).fetchone() is None: return False _set_primary_locked(conn, project_id, norm) return True -def _set_archived(conn: sqlite3.Connection, project_id: str, archived: int) -> bool: - with write_txn(conn): - cur = conn.execute( - f"UPDATE projects SET archived = {int(archived)} WHERE id = ?", (project_id,) - ) - return cur.rowcount > 0 - - def archive_project(conn: sqlite3.Connection, project_id: str) -> bool: - return _set_archived(conn, project_id, 1) + return _execute_rowcount(conn, "UPDATE projects SET archived = 1 WHERE id = ?", (project_id,)) > 0 def restore_project(conn: sqlite3.Connection, project_id: str) -> bool: - return _set_archived(conn, project_id, 0) + return _execute_rowcount(conn, "UPDATE projects SET archived = 0 WHERE id = ?", (project_id,)) > 0 def delete_project(conn: sqlite3.Connection, project_id: str) -> bool: """Hard-delete a project and its folders (cascade).""" - with write_txn(conn): - cur = conn.execute("DELETE FROM projects WHERE id = ?", (project_id,)) - return cur.rowcount > 0 + return _execute_rowcount(conn, "DELETE FROM projects WHERE id = ?", (project_id,)) > 0 -# --------------------------------------------------------------------------- -# Active-project pointer (project_meta KV) -# --------------------------------------------------------------------------- - +# --- Active-project pointer + discovery policy (project_meta KV) -------------- _ACTIVE_META_KEY = "active_id" _DISCOVERY_POLICY_META_KEY = "repo_discovery_policy" @@ -593,8 +419,7 @@ _DISCOVERY_POLICY_META_KEY = "repo_discovery_policy" def _upsert_meta_locked(conn: sqlite3.Connection, key: str, value: str) -> None: """Upsert a project_meta row (caller already holds a write txn).""" conn.execute( - "INSERT INTO project_meta (key, value) VALUES (?, ?) " - "ON CONFLICT(key) DO UPDATE SET value = excluded.value", + "INSERT INTO project_meta (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value", (key, value), ) @@ -621,17 +446,9 @@ def get_discovery_policy_key(conn: sqlite3.Connection) -> Optional[str]: return _get_meta(conn, _DISCOVERY_POLICY_META_KEY) -def reconcile_discovered_repos_policy( - conn: sqlite3.Connection, - policy_key: str, - *, - preserve_unversioned: bool = False, -) -> bool: - """Clear cached scan rows when their discovery policy changes. - - Existing pre-policy rows are retained only for the backward-compatible default policy. Returns - whether rows were cleared. - """ +def reconcile_discovered_repos_policy(conn: sqlite3.Connection, policy_key: str, *, preserve_unversioned: bool = False) -> bool: + """Clear cached scan rows when their discovery policy changes; pre-policy rows are retained only + for the backward-compatible default policy. Returns whether rows were cleared.""" current = get_discovery_policy_key(conn) if current == policy_key: return False @@ -644,42 +461,28 @@ def reconcile_discovered_repos_policy( return cleared -def clear_discovered_repos( - conn: sqlite3.Connection, *, policy_key: Optional[str] = None -) -> None: +def clear_discovered_repos(conn: sqlite3.Connection, *, policy_key: Optional[str] = None) -> None: with write_txn(conn): conn.execute("DELETE FROM discovered_repos") if policy_key is not None: _upsert_meta_locked(conn, _DISCOVERY_POLICY_META_KEY, policy_key) -# --------------------------------------------------------------------------- -# Discovered repos (filesystem scan cache) -# --------------------------------------------------------------------------- - +# --- Discovered repos (filesystem scan cache) -------------------------------- def record_discovered_repos( - conn: sqlite3.Connection, - repos: Iterable[tuple[str, Optional[str]]], - *, - replace: bool = False, + conn: sqlite3.Connection, repos: Iterable[tuple[str, Optional[str]]], *, replace: bool = False, policy_key: Optional[str] = None, ) -> int: - """Persist scanned git repo roots into the cache. - - ``repos`` is an iterable of ``(root, label)``. Roots are normalized; the label falls back to the - basename. Returns the number of rows written. - - When ``replace`` is true, this is the authoritative result of a fresh disk scan: delete stale - rows first so old eval/worktree noise disappears instead of living forever in the cache. - """ + """Persist scanned ``(root, label)`` repo roots (normalized; label falls back to basename) and + return the row count. ``replace`` = authoritative fresh scan: stale rows are deleted first so old + eval/worktree noise doesn't live forever.""" now = _now() rows = [] for root, label in repos: norm = _normalize_path(root) - if not norm: - continue - rows.append((norm, (label or os.path.basename(norm) or norm), now)) + if norm: + rows.append((norm, (label or os.path.basename(norm) or norm), now)) with write_txn(conn): if replace: @@ -687,8 +490,7 @@ def record_discovered_repos( if rows: conn.executemany( "INSERT INTO discovered_repos (root, label, last_seen) VALUES (?, ?, ?) " - "ON CONFLICT(root) DO UPDATE SET label = excluded.label, " - "last_seen = excluded.last_seen", + "ON CONFLICT(root) DO UPDATE SET label = excluded.label, last_seen = excluded.last_seen", rows, ) if policy_key is not None: @@ -698,49 +500,31 @@ def record_discovered_repos( def list_discovered_repos(conn: sqlite3.Connection) -> List[dict]: """All cached discovered repo roots, most-recently-seen first.""" - rows = conn.execute( - "SELECT root, label, last_seen FROM discovered_repos ORDER BY last_seen DESC" - ).fetchall() + rows = conn.execute("SELECT root, label, last_seen FROM discovered_repos ORDER BY last_seen DESC").fetchall() return [dict(r) for r in rows] -# --------------------------------------------------------------------------- -# Resolution + naming -# --------------------------------------------------------------------------- +# --- Resolution + naming ----------------------------------------------------- - -def project_for_path( - conn: sqlite3.Connection, path: str, *, include_archived: bool = False -) -> Optional[Project]: - """Return the project owning ``path`` (longest-prefix folder match). - - A folder owns ``path`` when ``path`` equals the folder or is nested under it. The most specific - (longest) folder wins, so nested projects resolve to the innermost one. - """ +def project_for_path(conn: sqlite3.Connection, path: str, *, include_archived: bool = False) -> Optional[Project]: + """Return the project owning ``path``: a folder owns it when equal or an ancestor, and the longest + folder wins so nested projects resolve to the innermost one.""" if not str(path or "").strip(): return None target = _normalize_path(path) - sql = ( - "SELECT pf.project_id AS pid, pf.path AS folder " - "FROM project_folders pf JOIN projects p ON p.id = pf.project_id" - ) + sql = "SELECT pf.project_id AS pid, pf.path AS folder FROM project_folders pf JOIN projects p ON p.id = pf.project_id" if not include_archived: sql += " WHERE p.archived = 0" best_pid: Optional[str] = None best_len = -1 for row in conn.execute(sql).fetchall(): folder = row["folder"] - owns = ( - target == folder - or target.startswith(folder.rstrip("/\\") + os.sep) - or target.startswith(folder.rstrip("/\\") + "/") - ) + stem = folder.rstrip("/\\") + owns = target == folder or target.startswith(stem + os.sep) or target.startswith(stem + "/") if owns and len(folder) > best_len: best_len = len(folder) best_pid = row["pid"] - if best_pid is None: - return None - return get_project(conn, best_pid) + return None if best_pid is None else get_project(conn, best_pid) # Deterministic branch slug: lowercase, separators collapsed, capped. @@ -748,16 +532,11 @@ _BRANCH_SAFE_RE = re.compile(r"[^a-z0-9._-]+") def branch_name_for(project: Project, task_id: str, *, title: str = "") -> str: - """Deterministic branch name for a project-linked kanban task. - - Shape: ``/`` (optionally ``-``). Stable and human-meaningful, - replacing the random ``wt/`` fallback. - """ - slug = project.slug or _slugify(project.name) - base = f"{slug}/{task_id}" + """Deterministic ``/[-]`` branch name for a project-linked kanban + task (stable and human-meaningful, replacing the random ``wt/`` fallback).""" + base = f"{project.slug or _slugify(project.name)}/{task_id}" if title: - tslug = _BRANCH_SAFE_RE.sub("-", str(title).strip().lower()).strip("-") - tslug = tslug[:40].strip("-") + tslug = _BRANCH_SAFE_RE.sub("-", str(title).strip().lower()).strip("-")[:40].strip("-") if tslug: base = f"{base}-{tslug}" return base diff --git a/hermes_cli/prompt_size.py b/hermes_cli/prompt_size.py index ef0bf54255..ca0b5f13f8 100644 --- a/hermes_cli/prompt_size.py +++ b/hermes_cli/prompt_size.py @@ -1,9 +1,8 @@ """Prompt-size diagnostic: ``hermes prompt-size``. -The diagnostic builds a real inspection agent (so the numbers match what actually ships on the wire) -but never makes a network call: it passes dummy credentials so ``AIAgent.__init__`` takes the -direct-construction path, then calls ``build_system_prompt_parts`` / inspects ``agent.tools`` -offline. +Builds a real inspection agent (so the numbers match what ships on the wire) but never makes a +network call: dummy credentials force ``AIAgent.__init__`` down the direct-construction path, then +``build_system_prompt_parts`` / ``agent.tools`` are inspected offline. """ from __future__ import annotations @@ -13,11 +12,9 @@ import re from pathlib import Path from typing import Any, Dict, List, Optional, Tuple -# The skills index is wrapped in this tag pair inside the stable tier. _SKILLS_BLOCK_RE = re.compile(r".*?", re.DOTALL) -# A rendered skill entry inside is `` - name: desc`` (or -# `` - name`` when the skill has no description). Category headers use two +# A rendered skill entry is `` - name: desc`` (or `` - name``); category headers use two # leading spaces, so the four-space + ``- `` prefix isolates skill lines. _SKILL_LINE_PREFIX = " - " @@ -32,62 +29,49 @@ def _bytes(s: str) -> int: return len(s.encode("utf-8")) +def _size(text: str) -> Dict[str, int]: + return {"chars": len(text), "bytes": _bytes(text)} + + +def _fmt_kb(n: int) -> str: + return f"{n / 1024:.1f} KB" + + def _tool_name(tool: Any) -> str: - """Return the callable name of a tool schema (OpenAI ``function`` shape).""" + """Callable name of a tool schema (OpenAI ``function`` shape).""" if not isinstance(tool, dict): return "" fn = tool.get("function") - if isinstance(fn, dict) and fn.get("name"): - return str(fn["name"]) - return str(tool.get("name", "")) + return str(fn["name"]) if isinstance(fn, dict) and fn.get("name") else str(tool.get("name", "")) def _build_inspection_agent(platform: str) -> Any: - """Construct an offline AIAgent for prompt inspection. - - Dummy ``api_key`` + ``base_url`` force the direct-construction path (no provider - auto-detection, no network). Toolsets and platform come from the caller so the breakdown - matches a real session. + """Offline AIAgent for prompt inspection: dummy ``api_key`` + ``base_url`` force the + direct-construction path (no provider auto-detection, no network); toolsets resolve the way + the gateway does so the breakdown matches a real session. """ from run_agent import AIAgent from hermes_cli.config import load_config from hermes_cli.tools_config import _get_platform_tools + from agent.skill_utils import parse_config_string_list cfg = load_config() model_cfg = cfg.get("model", {}) if isinstance(cfg.get("model"), dict) else {} - model = model_cfg.get("default") or model_cfg.get("model") or "" - - # Resolve platform-specific toolsets the same way the gateway does. - enabled_toolsets = sorted(_get_platform_tools(cfg, platform)) agent_cfg = cfg.get("agent") or {} - from agent.skill_utils import parse_config_string_list - - disabled_toolsets = parse_config_string_list(agent_cfg.get("disabled_toolsets")) or None - return AIAgent( - model=model, - api_key="inspect-only", - base_url="https://openrouter.ai/api/v1", - quiet_mode=True, - save_trajectories=False, - platform=platform, - enabled_toolsets=enabled_toolsets, - disabled_toolsets=disabled_toolsets, + model=model_cfg.get("default") or model_cfg.get("model") or "", + api_key="inspect-only", base_url="https://openrouter.ai/api/v1", quiet_mode=True, save_trajectories=False, + platform=platform, enabled_toolsets=sorted(_get_platform_tools(cfg, platform)), + disabled_toolsets=parse_config_string_list(agent_cfg.get("disabled_toolsets")) or None, ) def _skill_md_paths_by_name() -> Dict[str, Path]: - """Map each installed skill's name to its ``SKILL.md`` path on disk. - - Keyed by both the frontmatter ``name`` (what the index renders) and the skill directory name, so - either resolves. Local skills win over external dirs (``get_all_skills_dirs`` yields local - first), matching the index's own precedence. + """Map each installed skill's frontmatter ``name`` AND directory name to its ``SKILL.md``. + Local skills win over external dirs (``get_all_skills_dirs`` yields local first), matching + the index's own precedence. """ - from agent.skill_utils import ( - get_all_skills_dirs, - iter_skill_index_files, - parse_frontmatter, - ) + from agent.skill_utils import get_all_skills_dirs, iter_skill_index_files, parse_frontmatter mapping: Dict[str, Path] = {} for skills_dir in get_all_skills_dirs(): @@ -100,8 +84,7 @@ def _skill_md_paths_by_name() -> Dict[str, Path]: frontmatter_name = str(frontmatter.get("name") or dir_name) except Exception: frontmatter_name = dir_name - # setdefault keeps the first (local) occurrence on name collisions. - mapping.setdefault(frontmatter_name, skill_file) + mapping.setdefault(frontmatter_name, skill_file) # first (local) occurrence wins mapping.setdefault(dir_name, skill_file) return mapping @@ -109,77 +92,52 @@ def _skill_md_paths_by_name() -> Dict[str, Path]: def _compute_skills_breakdown(skills_block: str) -> List[Dict[str, Any]]: """Per-skill byte breakdown parsed from the rendered ````. - * ``index_line_bytes`` — the skill's attributed bytes in the always-on index (the fixed per-call - cost of *listing* the skill). For a compact ``[names only]`` line, each name keeps its own bytes - and receives an even share of the category prefix and separators. + ``index_line_bytes`` is the skill's attributed always-on index cost. For a compact + ``[names only]`` line each name keeps its own bytes plus an even share of the shared prefix + and separators. """ name_to_path = _skill_md_paths_by_name() entries: List[Dict[str, Any]] = [] - def append_entry( - name: str, *, attributed_bytes: int, total_bytes: int, shared_bytes: int, skill_count: int - ) -> None: + def append_entry(name: str, **index_fields: int) -> None: # kwarg order == output key order path = name_to_path.get(name) md_bytes: Optional[int] = None - if path is not None: - try: - md_bytes = path.stat().st_size - except OSError: - pass - entries.append({ - "name": name, - "index_line_bytes": attributed_bytes, - "index_line_total_bytes": total_bytes, - "index_line_shared_bytes": shared_bytes, - "index_line_skill_count": skill_count, - "skill_md_bytes": md_bytes, - "path": str(path) if path is not None else "", - }) + try: + md_bytes = path.stat().st_size if path is not None else None + except OSError: + pass + entries.append({"name": name, **index_fields, "skill_md_bytes": md_bytes, "path": str(path) if path is not None else ""}) for line in skills_block.splitlines(): - compact_match = _NAMES_ONLY_LINE_RE.match(line) line_bytes = _bytes(line) - if compact_match is not None: + if (compact_match := _NAMES_ONLY_LINE_RE.match(line)) is not None: names = [n.strip() for n in compact_match.group("names").split(",") if n.strip()] - if not names: - continue name_bytes = [_bytes(name) for name in names] - shared_base, shared_remainder = divmod(line_bytes - sum(name_bytes), len(names)) + shared_base, shared_remainder = divmod(line_bytes - sum(name_bytes), len(names)) if names else (0, 0) for index, name in enumerate(names): - shared_bytes = shared_base + (1 if index < shared_remainder else 0) - append_entry( - name, attributed_bytes=name_bytes[index] + shared_bytes, - total_bytes=line_bytes, shared_bytes=shared_bytes, skill_count=len(names), - ) - continue - - if not line.startswith(_SKILL_LINE_PREFIX): - continue - # ``name: desc`` — the first ``": "`` separates name from description. - # Namespaced names (``codex:rescue``) have no space after their colon, - # so partitioning on ``": "`` keeps the full name intact. - name = line[len(_SKILL_LINE_PREFIX):].partition(": ")[0].strip() - if name: - append_entry(name, attributed_bytes=line_bytes, total_bytes=line_bytes, - shared_bytes=0, skill_count=1) + shared = shared_base + (1 if index < shared_remainder else 0) + append_entry(name, index_line_bytes=name_bytes[index] + shared, index_line_total_bytes=line_bytes, + index_line_shared_bytes=shared, index_line_skill_count=len(names)) + elif line.startswith(_SKILL_LINE_PREFIX): + # Partition on ``": "`` (not ``:``) so namespaced names like ``codex:rescue`` stay intact. + name = line[len(_SKILL_LINE_PREFIX):].partition(": ")[0].strip() + if name: + append_entry(name, index_line_bytes=line_bytes, index_line_total_bytes=line_bytes, + index_line_shared_bytes=0, index_line_skill_count=1) entries.sort(key=lambda e: (-(e["skill_md_bytes"] or 0), e["name"])) return entries def _compute_toolsets_breakdown(tools: List[Any]) -> List[Dict[str, Any]]: - """Per-toolset schema-byte breakdown of the resolved tool list. - - Each tool is attributed to its single canonical toolset so ``json_bytes`` sums are fully - attributable (grand total = sum of per-tool serializations). Sorted largest-first, tie-broken - by toolset name. + """Per-toolset schema-byte breakdown, largest-first (tie-broken by name). Each tool is + attributed to its single canonical toolset so ``json_bytes`` sums to the grand total. """ from tools.registry import registry tool_to_toolset = registry.get_tool_to_toolset_map() groups: Dict[str, Dict[str, Any]] = {} for tool in tools: - name = _tool_name(tool) - toolset = tool_to_toolset.get(name) or "(unknown)" + toolset = tool_to_toolset.get(_tool_name(tool)) or "(unknown)" group = groups.setdefault(toolset, {"toolset": toolset, "tool_count": 0, "json_bytes": 0}) group["tool_count"] += 1 group["json_bytes"] += _bytes(json.dumps(tool, ensure_ascii=False)) @@ -187,34 +145,25 @@ def _compute_toolsets_breakdown(tools: List[Any]) -> List[Dict[str, Any]]: def compute_prompt_breakdown(platform: str = "cli") -> Dict[str, Any]: - """Return a dict of prompt-size measurements for a fresh session. - - Keys: ``system_prompt``, ``skills_index``, ``memory``, ``user_profile``, ``tools``, ``sections`` - (the three prompt tiers), ``skills_breakdown`` and ``toolsets_breakdown`` (largest-first); the - last two answer "what should I disable to cut tokens?". + """Prompt-size measurements for a fresh session: ``system_prompt``, ``skills_index``, + ``memory``, ``user_profile``, ``tools``, ``sections`` (the three prompt tiers), and the + largest-first ``skills_breakdown`` / ``toolsets_breakdown`` ("what should I disable?"). """ from agent.system_prompt import build_system_prompt, build_system_prompt_parts agent = _build_inspection_agent(platform) - parts = build_system_prompt_parts(agent) full = build_system_prompt(agent) + stable, context, volatile = (parts.get(k, "") for k in ("stable", "context", "volatile")) - stable = parts.get("stable", "") - context = parts.get("context", "") - volatile = parts.get("volatile", "") - - # Skills index — the block (the largest single block - # when many skills are installed). Lives in the volatile tier (moved from - # stable so skill edits don't invalidate the cached identity prefix). + # The skills index lives in the volatile tier (moved from stable so skill edits don't + # invalidate the cached identity prefix); fall back to stable for older layouts. skills_match = _SKILLS_BLOCK_RE.search(volatile) or _SKILLS_BLOCK_RE.search(stable) skills_index = skills_match.group(0) if skills_match else "" - # Memory + user profile live in the volatile tier. We re-derive their - # blocks directly from the memory store so the numbers are attributable - # even though they're joined into ``volatile``. - memory_block = "" - user_block = "" + # Memory + user profile are joined into ``volatile``; re-derive them from the store so the + # numbers stay attributable. + memory_block = user_block = "" store = getattr(agent, "_memory_store", None) if store is not None: try: @@ -225,18 +174,12 @@ def compute_prompt_breakdown(platform: str = "cli") -> Dict[str, Any]: except Exception: pass - # Tool-schema JSON — the other half of the fixed per-call payload. tools = getattr(agent, "tools", None) or [] - - def _size(text: str) -> Dict[str, int]: - return {"chars": len(text), "bytes": _bytes(text)} - sections: List[Tuple[str, int, int]] = [ - ("stable (identity/guidance/skills)", len(stable), _bytes(stable)), - ("context (AGENTS.md/cwd files)", len(context), _bytes(context)), - ("volatile (memory/profile/timestamp)", len(volatile), _bytes(volatile)), + (label, len(text), _bytes(text)) + for label, text in (("stable (identity/guidance/skills)", stable), ("context (AGENTS.md/cwd files)", context), + ("volatile (memory/profile/timestamp)", volatile)) ] - return { "platform": platform, "model": getattr(agent, "model", "") or "", @@ -251,72 +194,45 @@ def compute_prompt_breakdown(platform: str = "cli") -> Dict[str, Any]: } -def _fmt_kb(n: int) -> str: - return f"{n / 1024:.1f} KB" - - def render_breakdown(data: Dict[str, Any]) -> str: """Render the breakdown as plain text suitable for a terminal.""" sp = data["system_prompt"] tools = data["tools"] lines: List[str] = [ - f"Prompt-size breakdown (platform={data['platform']}, model={data['model'] or 'unset'})", - "", - f" System prompt total : {sp['bytes']:>8,} B ({_fmt_kb(sp['bytes'])}, {sp['chars']:,} chars)", - "", + f"Prompt-size breakdown (platform={data['platform']}, model={data['model'] or 'unset'})", "", + f" System prompt total : {sp['bytes']:>8,} B ({_fmt_kb(sp['bytes'])}, {sp['chars']:,} chars)", "", " Major blocks:", ] - for label, key in (("skills index", "skills_index"), ("memory", "memory"), - ("user profile", "user_profile")): + for label, key in (("skills index", "skills_index"), ("memory", "memory"), ("user profile", "user_profile")): byts = data[key]["bytes"] lines.append(f" {label:<19}: {byts:>8,} B ({_fmt_kb(byts)})") - lines += ["", " Prompt tiers:"] - for label, chars, byts in data["sections"]: - lines.append(f" {label:<36}: {byts:>8,} B ({_fmt_kb(byts)})") + lines += ["", " Prompt tiers:"] + [f" {label:<36}: {byts:>8,} B ({_fmt_kb(byts)})" for label, _chars, byts in data["sections"]] lines += ["", f" Tool schemas : {tools['json_bytes']:>8,} B ({_fmt_kb(tools['json_bytes'])}, {tools['count']} tools)"] - # Per-toolset schema cost — which toolset's tools cost the most to ship. - toolsets = data.get("toolsets_breakdown") or [] - if toolsets: - lines += ["", " Toolsets by size (tool-schema JSON, largest first):", - f" {'toolset':<22} {'tools':>5} {'schema':>10}"] - for ts in toolsets: - lines.append( - f" {ts['toolset']:<22} {ts['tool_count']:>5} " - f"{ts['json_bytes']:>8,} B ({_fmt_kb(ts['json_bytes'])})" - ) + if toolsets := data.get("toolsets_breakdown") or []: + lines += ["", " Toolsets by size (tool-schema JSON, largest first):", f" {'toolset':<22} {'tools':>5} {'schema':>10}"] + lines += [f" {ts['toolset']:<22} {ts['tool_count']:>5} {ts['json_bytes']:>8,} B ({_fmt_kb(ts['json_bytes'])})" for ts in toolsets] # Per-skill cost — index line (always shipped) vs SKILL.md (read on load). - skills = data.get("skills_breakdown") or [] - if skills: - lines += ["", - " Skills by size (SKILL.md on-disk = read cost; index cost = " - "attributed always-on bytes, largest first):", + if skills := data.get("skills_breakdown") or []: + lines += ["", " Skills by size (SKILL.md on-disk = read cost; index cost = attributed always-on bytes, largest first):", f" {'skill':<28} {'SKILL.md':>10} {'index cost':>10}"] shown = skills[:_SKILLS_TABLE_LIMIT] for sk in shown: md = sk["skill_md_bytes"] md_str = f"{md:>8,} B" if md is not None else f"{'n/a':>10}" - name = sk["name"] - if len(name) > 28: - name = name[:27] + "…" + name = sk["name"] if len(sk["name"]) <= 28 else sk["name"][:27] + "…" lines.append(f" {name:<28} {md_str} {sk['index_line_bytes']:>8,} B") - remaining = len(skills) - len(shown) - if remaining > 0: + if (remaining := len(skills) - len(shown)) > 0: lines.append(f" … and {remaining} more (use --json for the full list)") return "\n".join(lines) def cmd_prompt_size(args: Any) -> None: """Entry point for ``hermes prompt-size``.""" - platform = getattr(args, "platform", "cli") or "cli" - as_json = getattr(args, "json", False) try: - data = compute_prompt_breakdown(platform) + data = compute_prompt_breakdown(getattr(args, "platform", "cli") or "cli") except Exception as e: print(f"Could not compute prompt-size breakdown: {e}") return - if as_json: - print(json.dumps(data, ensure_ascii=False, indent=2)) - else: - print(render_breakdown(data)) + print(json.dumps(data, ensure_ascii=False, indent=2) if getattr(args, "json", False) else render_breakdown(data)) diff --git a/hermes_cli/prompt_stash.py b/hermes_cli/prompt_stash.py index cacbbe4d7d..e56affcc7b 100644 --- a/hermes_cli/prompt_stash.py +++ b/hermes_cli/prompt_stash.py @@ -1,10 +1,8 @@ """Ctrl+S prompt stash — pure state machine for the classic CLI composer. -The state machine lives here (no prompt_toolkit imports) so it can be unit tested directly; -``cli.py`` owns only the keybinding and the rendering. - -Newest-first ordering: index 0 is always the most recently stashed draft, so the common "undo my -last Ctrl+S" case is a single keystroke. +No prompt_toolkit imports so it can be unit tested directly; ``cli.py`` owns only the keybinding +and the rendering. Newest-first: index 0 is always the most recently stashed draft, so "undo my +last Ctrl+S" is a single keystroke. """ from __future__ import annotations @@ -42,13 +40,8 @@ class StashEntry: preview: str = "" def as_dict(self) -> dict: - """Render in the shape ``HermesCLI._render_stash_panel`` consumes.""" - return { - "text": self.text, - "images": list(self.images), - "stashed_at": self.stashed_at, - "preview": self.preview, - } + """Shape ``HermesCLI._render_stash_panel`` consumes.""" + return {"text": self.text, "images": list(self.images), "stashed_at": self.stashed_at, "preview": self.preview} class PromptStash: @@ -61,8 +54,6 @@ class PromptStash: self.panel_open = False self.panel_cursor = 0 - # ---------------------------------------------------------------- queries - def __len__(self) -> int: return len(self._items) @@ -72,50 +63,32 @@ class PromptStash: return list(self._items) def panel_rows(self) -> List[dict]: - """Entries as plain dicts for the panel renderer.""" return [e.as_dict() for e in self._items] def indicator(self) -> str: - """Status-bar indicator, or ``""`` when the stash is empty. - - ``📌 2`` when idle, ``📌 2 ▲`` while the browse panel is open, so the user can always tell a - parked draft exists without opening anything. - """ + """Status-bar indicator: ``📌 2`` idle, ``📌 2 ▲`` while browsing, ``""`` when empty.""" n = len(self._items) return "" if not n else f"📌 {n} ▲" if self.panel_open else f"📌 {n}" def placeholder_hint(self) -> str: """Composer placeholder text advertising the stashed draft.""" n = len(self._items) - if not n: - return "" if n == 1: return f"Ctrl+S to restore: {self._items[0].preview}" - return f"Ctrl+S to browse {n} stashed drafts" - - # --------------------------------------------------------------- mutators + return f"Ctrl+S to browse {n} stashed drafts" if n else "" def stash(self, text: str, images: Optional[Sequence[Any]] = None) -> bool: - """Push a draft. Returns False (no-op) for a blank buffer. - - A buffer that is empty or whitespace-only is not worth parking and must stay a no-op, - otherwise Ctrl+S on an empty composer would push a junk entry instead of triggering the - restore half of the gesture. + """Push a draft. A blank buffer with no images is a no-op (returns False) so Ctrl+S on an + empty composer triggers the restore half of the gesture instead of pushing junk. """ if not (text or "").strip() and not images: return False - entry = StashEntry( - text=text or "", - images=list(images or []), - stashed_at=self._clock(), - preview=build_preview(text or "") or "(images only)", - ) + entry = StashEntry(text=text or "", images=list(images or []), stashed_at=self._clock(), + preview=build_preview(text or "") or "(images only)") self._items.insert(0, entry) - # Drop the oldest entries past the cap. - del self._items[self._max_items:] - # A push invalidates any open browse session. - self.close_panel() + del self._items[self._max_items:] # drop the oldest past the cap + self.close_panel() # a push invalidates any open browse session return True def pop(self, index: int = 0) -> Optional[Tuple[str, List[Any]]]: @@ -129,7 +102,6 @@ class PromptStash: return entry.text, list(entry.images) def peek(self, index: int = 0) -> Optional[StashEntry]: - """Return the entry at ``index`` without removing it.""" return self._items[index] if 0 <= index < len(self._items) else None def clear(self) -> None: @@ -139,9 +111,7 @@ class PromptStash: # ------------------------------------------------------------ panel state def _clamp_cursor(self, value: int) -> int: - if not self._items: - return 0 - return max(0, min(int(value), len(self._items) - 1)) + return max(0, min(int(value), len(self._items) - 1)) if self._items else 0 def open_panel(self) -> bool: """Open the browse panel. False when there is nothing to browse.""" @@ -181,8 +151,6 @@ class PromptStash: return result -# --------------------------------------------------------------------- gesture - # Outcomes of a single Ctrl+S press. ACTION_NOOP = "noop" ACTION_STASHED = "stashed" @@ -192,22 +160,17 @@ ACTION_CLOSE_PANEL = "close_panel" def resolve_ctrl_s( - stash: PromptStash, - buffer_text: str, - images: Optional[Sequence[Any]] = None, + stash: PromptStash, buffer_text: str, images: Optional[Sequence[Any]] = None ) -> Tuple[str, Optional[Tuple[str, List[Any]]]]: - """Decide what one Ctrl+S press does. Returns ``(action, payload)``. - - ``payload`` is ``(text, images)`` for :data:`ACTION_RESTORED`, else None. The whole decision - table lives in this pure function so the ``cli.py`` keybinding handler stays a thin adapter. + """Decide what one Ctrl+S press does. Returns ``(action, payload)`` where ``payload`` is + ``(text, images)`` for :data:`ACTION_RESTORED`, else None. """ # Panel open → Ctrl+S is the "close it" escape hatch. if stash.panel_open: stash.close_panel() return ACTION_CLOSE_PANEL, None - # Something to park → park it. Never silently clobbers an existing stash: - # entries push onto a stack, so an earlier draft is still reachable. + # Something to park → park it (onto a stack, so an earlier draft is still reachable). if (buffer_text or "").strip() or images: return (ACTION_STASHED if stash.stash(buffer_text, images) else ACTION_NOOP), None diff --git a/hermes_cli/proxy/adapters/__init__.py b/hermes_cli/proxy/adapters/__init__.py index 32c35779a5..4104749c48 100644 --- a/hermes_cli/proxy/adapters/__init__.py +++ b/hermes_cli/proxy/adapters/__init__.py @@ -1,9 +1,4 @@ -"""Upstream adapter registry for the local proxy server. - -Each adapter wraps a provider's OAuth state and exposes a uniform interface the proxy server can use -to forward requests with a freshly-minted bearer token. See :class:`UpstreamAdapter` for the -contract. -""" +"""Upstream adapter registry for the local proxy server (see :class:`UpstreamAdapter`).""" from typing import Dict, Type @@ -11,12 +6,8 @@ from hermes_cli.proxy.adapters.base import UpstreamAdapter from hermes_cli.proxy.adapters.nous_portal import NousPortalAdapter from hermes_cli.proxy.adapters.xai import XAIGrokAdapter -# Registry of available adapter classes keyed by provider name as used on -# the ``hermes proxy start --provider `` CLI flag. -ADAPTERS: Dict[str, Type[UpstreamAdapter]] = { - "nous": NousPortalAdapter, - "xai": XAIGrokAdapter, -} +# Keyed by the ``hermes proxy start --provider `` value. +ADAPTERS: Dict[str, Type[UpstreamAdapter]] = {"nous": NousPortalAdapter, "xai": XAIGrokAdapter} def get_adapter(name: str) -> UpstreamAdapter: @@ -24,9 +15,7 @@ def get_adapter(name: str) -> UpstreamAdapter: key = (name or "").strip().lower() if key not in ADAPTERS: available = ", ".join(sorted(ADAPTERS)) or "(none)" - raise ValueError( - f"Unknown proxy upstream provider: {name!r}. Available: {available}" - ) + raise ValueError(f"Unknown proxy upstream provider: {name!r}. Available: {available}") return ADAPTERS[key]() diff --git a/hermes_cli/proxy/adapters/base.py b/hermes_cli/proxy/adapters/base.py index 4bf2af31ba..0042b2ef29 100644 --- a/hermes_cli/proxy/adapters/base.py +++ b/hermes_cli/proxy/adapters/base.py @@ -1,7 +1,4 @@ -"""Abstract base for proxy upstream adapters. - -The proxy server is otherwise provider-agnostic. -""" +"""Abstract base for proxy upstream adapters; the proxy server is otherwise provider-agnostic.""" from __future__ import annotations @@ -14,17 +11,10 @@ from typing import FrozenSet, Optional class UpstreamCredential: """A resolved bearer + base URL ready to forward to.""" - bearer: str - """Authorization header value to send upstream (token only, no ``Bearer`` prefix).""" - - base_url: str - """Upstream base URL, e.g. ``https://inference-api.nousresearch.com/v1``.""" - + bearer: str # token only, no ``Bearer`` prefix + base_url: str # e.g. ``https://inference-api.nousresearch.com/v1`` token_type: str = "Bearer" - """Auth scheme — currently always ``Bearer`` for supported providers.""" - - expires_at: Optional[str] = None - """ISO-8601 expiry timestamp for the bearer, when known. Informational.""" + expires_at: Optional[str] = None # ISO-8601, informational class UpstreamAdapter(ABC): @@ -43,40 +33,24 @@ class UpstreamAdapter(ABC): @property @abstractmethod def allowed_paths(self) -> FrozenSet[str]: - """Set of relative request paths the upstream accepts. - - Paths are relative to the proxy's ``/v1`` mount (``"/chat/completions"`` ⇒ - ``/v1/chat/completions``). Requests outside this set get a 404 with a helpful body. - """ + """Paths relative to the proxy's ``/v1`` mount (``"/chat/completions"`` ⇒ + ``/v1/chat/completions``); anything else gets a 404 with a helpful body.""" @abstractmethod def is_authenticated(self) -> bool: - """Return True if the user has usable credentials for this upstream. - - Should be cheap — no network calls. Used by ``proxy start`` for a clear up-front error - before binding a port. - """ + """Cheap (no network) usable-credentials check; ``proxy start`` uses it for a clear + up-front error before binding a port.""" @abstractmethod def get_credential(self) -> UpstreamCredential: - """Return a fresh credential, refreshing or rotating if necessary. - - Implementations refresh a near-expiry access token, rotate a near-expiry upstream bearer key - and persist refreshed state to disk. Raises RuntimeError when unauthenticated or refresh - fails; the proxy then returns 401 to the client. - """ + """Fresh credential (refreshing/rotating + persisting as needed). Raises RuntimeError when + unauthenticated or refresh fails; the proxy then returns 401 to the client.""" def get_retry_credential( - self, - *, - failed_credential: UpstreamCredential, - status_code: int, + self, *, failed_credential: UpstreamCredential, status_code: int ) -> Optional[UpstreamCredential]: - """Return an alternate credential after an upstream auth failure. - - The default is no retry. Providers can override this for one-shot fallback paths after the - upstream rejects the first request. - """ + """Alternate credential for a one-shot retry after the upstream rejects the first request; + default is no retry.""" _ = failed_credential, status_code return None diff --git a/hermes_cli/proxy/adapters/nous_portal.py b/hermes_cli/proxy/adapters/nous_portal.py index 08346a2a11..25015ed072 100644 --- a/hermes_cli/proxy/adapters/nous_portal.py +++ b/hermes_cli/proxy/adapters/nous_portal.py @@ -24,25 +24,16 @@ from hermes_cli.proxy.adapters.base import UpstreamAdapter, UpstreamCredential logger = logging.getLogger(__name__) -# Endpoints inference-api.nousresearch.com actually serves. Anything else -# the proxy will reject with 404 — keeps stray clients from leaking weird -# requests to the upstream. -_ALLOWED_PATHS: FrozenSet[str] = frozenset( - { - "/chat/completions", - "/completions", - "/embeddings", - "/models", - } -) +# Endpoints inference-api.nousresearch.com actually serves; anything else is a 404 so stray +# clients cannot leak odd requests upstream. +_ALLOWED_PATHS: FrozenSet[str] = frozenset({"/chat/completions", "/completions", "/embeddings", "/models"}) class NousPortalAdapter(UpstreamAdapter): """Proxy upstream for the Nous Portal inference API.""" def __init__(self) -> None: - # Serialize proxy requests in this process; cross-process token refresh - # and persistence are handled by resolve_nous_runtime_credentials(). + # In-process serialization; cross-process refresh/persistence is resolve_nous_runtime_credentials(). self._lock = threading.Lock() @property @@ -58,22 +49,15 @@ class NousPortalAdapter(UpstreamAdapter): return _ALLOWED_PATHS def is_authenticated(self) -> bool: - # We need either a usable inference JWT OR (refresh_token + access_token) - # to recover. The refresh helper validates and refreshes as needed. + # Usable inference JWT, OR refresh_token + access_token to recover via the refresh helper. state = self._read_state() or {} - return bool( - state.get("agent_key") - or (state.get("refresh_token") and state.get("access_token")) - ) + return bool(state.get("agent_key") or (state.get("refresh_token") and state.get("access_token"))) def get_credential(self) -> UpstreamCredential: return self._get_credential() def get_retry_credential( - self, - *, - failed_credential: UpstreamCredential, - status_code: int, + self, *, failed_credential: UpstreamCredential, status_code: int ) -> Optional[UpstreamCredential]: _ = failed_credential if status_code != 401: @@ -81,64 +65,36 @@ class NousPortalAdapter(UpstreamAdapter): logger.info("proxy: Nous upstream rejected bearer; force-refreshing invoke JWT") return self._get_credential(force_refresh=True) - def _get_credential( - self, - *, - force_refresh: bool = False, - ) -> UpstreamCredential: + def _get_credential(self, *, force_refresh: bool = False) -> UpstreamCredential: with self._lock: state = self._read_state() if state is None: - raise RuntimeError( - "Not logged into Nous Portal. Run `hermes auth add nous` first." - ) - + raise RuntimeError("Not logged into Nous Portal. Run `hermes auth add nous` first.") try: refreshed = resolve_nous_runtime_credentials(force_refresh=force_refresh) except Exception as exc: if isinstance(exc, AuthError) and _is_terminal_nous_refresh_error(exc): _quarantine_nous_oauth_state(state, exc, reason="proxy_refresh_failure") - self._save_state( - state, - quarantine_error=exc, - quarantine_reason="proxy_refresh_failure", - ) - raise RuntimeError( - f"Failed to refresh Nous Portal credentials: {exc}" - ) from exc - + self._save_state(state, quarantine_error=exc, quarantine_reason="proxy_refresh_failure") + raise RuntimeError(f"Failed to refresh Nous Portal credentials: {exc}") from exc runtime_key = refreshed.get("api_key") if not runtime_key: raise RuntimeError( "Nous Portal refresh did not return a usable inference JWT. " "Try `hermes auth add nous` to re-authenticate." ) - - # base_url returned by resolve_nous_runtime_credentials() already - # honors the NOUS_INFERENCE_BASE_URL env override (the documented - # dev/staging escape hatch). Re-validating it here against the prod - # host allowlist would wrongly reject a legitimate staging override, - # so layer the same env-first overlay on top of the network-validated - # value: env override wins, else validate the returned URL, else - # fall back to the production default (defense-in-depth for a future - # source-layer bypass). + # The returned base_url already honors the NOUS_INFERENCE_BASE_URL override (documented + # dev/staging hatch); validating it against the prod allowlist would reject a legit + # staging URL. So: env override wins, else network-validate the returned URL, else the + # production default (defense-in-depth against a future source-layer bypass). base_url = ( _nous_inference_env_override() or _validate_nous_inference_url_from_network(refreshed.get("base_url")) or DEFAULT_NOUS_INFERENCE_URL - ) - base_url = base_url.rstrip("/") + ).rstrip("/") + return UpstreamCredential(bearer=runtime_key, base_url=base_url, expires_at=refreshed.get("expires_at")) - return UpstreamCredential( - bearer=runtime_key, - base_url=base_url, - expires_at=refreshed.get("expires_at"), - ) - - # ------------------------------------------------------------------ - # Internal helpers — auth.json access. Kept local rather than added - # to hermes_cli.auth to avoid expanding that module's public surface. - # ------------------------------------------------------------------ + # auth.json access — kept local so hermes_cli.auth's public surface does not grow. def _read_state(self) -> Optional[Dict[str, Any]]: try: @@ -148,7 +104,6 @@ class NousPortalAdapter(UpstreamAdapter): logger.warning("proxy: failed to load auth store: %s", exc) return None state = (store.get("providers") or {}).get("nous") - # copy so the refresh helper can mutate freely return dict(state) if isinstance(state, dict) else None def _save_state( @@ -162,11 +117,7 @@ class NousPortalAdapter(UpstreamAdapter): with _auth_store_lock(): store = _load_auth_store() if quarantine_error is not None and quarantine_reason: - _quarantine_nous_pool_entries( - store, - quarantine_error, - reason=quarantine_reason, - ) + _quarantine_nous_pool_entries(store, quarantine_error, reason=quarantine_reason) providers = store.setdefault("providers", {}) providers["nous"] = state _save_auth_store(store) diff --git a/hermes_cli/proxy/adapters/xai.py b/hermes_cli/proxy/adapters/xai.py index 2e533f668c..8cefde9116 100644 --- a/hermes_cli/proxy/adapters/xai.py +++ b/hermes_cli/proxy/adapters/xai.py @@ -14,17 +14,9 @@ logger = logging.getLogger(__name__) _POOL_PROVIDER = "xai-oauth" -# xAI's public API is OpenAI-compatible for the endpoints Hermes commonly -# uses. The Responses endpoint is included because Hermes' native xAI runtime -# uses codex_responses mode. +# OpenAI-compatible endpoints; ``/responses`` because the native xAI runtime uses codex_responses. _ALLOWED_PATHS: FrozenSet[str] = frozenset( - { - "/responses", - "/chat/completions", - "/completions", - "/embeddings", - "/models", - } + {"/responses", "/chat/completions", "/completions", "/embeddings", "/models"} ) @@ -58,52 +50,37 @@ class XAIGrokAdapter(UpstreamAdapter): pool = self._load_pool() if pool is None or not pool.has_credentials(): raise RuntimeError( - "No xAI OAuth credentials found. Run " - "`hermes auth add xai-oauth --type oauth` first." + "No xAI OAuth credentials found. Run `hermes auth add xai-oauth --type oauth` first." ) - entry = pool.select() if entry is None: raise RuntimeError( "No available xAI OAuth credentials found. Run " - "`hermes auth reset xai-oauth` or re-authenticate with " - "`hermes auth add xai-oauth --type oauth`." + "`hermes auth reset xai-oauth` or re-authenticate with `hermes auth add xai-oauth --type oauth`." ) - self._pool = pool return self._credential_from_entry(entry) def get_retry_credential( - self, - *, - failed_credential: UpstreamCredential, - status_code: int, + self, *, failed_credential: UpstreamCredential, status_code: int ) -> Optional[UpstreamCredential]: if status_code not in {401, 429}: return None - with self._lock: pool = self._pool or self._load_pool() if pool is None: return None - - # 401: try refreshing the current key first. 429: never refresh — mark - # the rate-limited key with its 1-hour cooldown and rotate to the next - # available credential. Returns None when the pool has no other key to - # offer — the 429 will flow back to the client. + # 401: refresh the current key first. 429: never refresh — cooldown the rate-limited + # key and rotate. None when the pool has nothing else → the status flows to the client. refreshed = pool.try_refresh_current() if status_code == 401 else None if refreshed is None: refreshed = pool.mark_exhausted_and_rotate(status_code=status_code) if refreshed is None: return None - retry_cred = self._credential_from_entry(refreshed) if retry_cred.bearer == failed_credential.bearer: return None - logger.info( - "proxy: xAI upstream returned %s; retrying with rotated pool credential", - status_code, - ) + logger.info("proxy: xAI upstream returned %s; retrying with rotated pool credential", status_code) return retry_cred def _load_pool(self) -> Optional[CredentialPool]: @@ -120,15 +97,11 @@ class XAIGrokAdapter(UpstreamAdapter): "xAI OAuth credential pool entry did not contain an access token. " "Re-authenticate with `hermes auth add xai-oauth --type oauth`." ) - base_url = str( getattr(entry, "runtime_base_url", None) or entry.base_url or DEFAULT_XAI_OAUTH_BASE_URL ).strip().rstrip("/") - return UpstreamCredential( - bearer=bearer, - base_url=base_url or DEFAULT_XAI_OAUTH_BASE_URL, - expires_at=entry.expires_at, + bearer=bearer, base_url=base_url or DEFAULT_XAI_OAUTH_BASE_URL, expires_at=entry.expires_at ) diff --git a/hermes_cli/proxy/cli.py b/hermes_cli/proxy/cli.py index 2fb457836e..0f510049de 100644 --- a/hermes_cli/proxy/cli.py +++ b/hermes_cli/proxy/cli.py @@ -9,63 +9,47 @@ from typing import Any from hermes_cli.proxy.adapters import ADAPTERS, get_adapter from hermes_cli.proxy.server import ( - AIOHTTP_AVAILABLE, - DEFAULT_HOST, - DEFAULT_PORT, - run_server, + AIOHTTP_AVAILABLE, DEFAULT_HOST, DEFAULT_PORT, run_server ) logger = logging.getLogger(__name__) -def _print_aiohttp_missing() -> None: - print( - "hermes proxy requires aiohttp. Run `hermes setup` to install it.", - file=sys.stderr, - ) +def _err(msg: str) -> None: + print(msg, file=sys.stderr) def cmd_proxy_start(args: Any) -> int: """Run the proxy server in the foreground.""" if not AIOHTTP_AVAILABLE: - _print_aiohttp_missing() + _err("hermes proxy requires aiohttp. Run `hermes setup` to install it.") return 1 - provider = getattr(args, "provider", None) or "nous" try: adapter = get_adapter(provider) except ValueError as exc: - print(f"Error: {exc}", file=sys.stderr) + _err(f"Error: {exc}") return 2 - if not adapter.is_authenticated(): auth_hint = getattr(adapter, "auth_hint", f"hermes auth add {adapter.name}") - print( - f"Not logged into {adapter.display_name}. " - f"Run `{auth_hint}` first.", - file=sys.stderr, - ) + _err(f"Not logged into {adapter.display_name}. Run `{auth_hint}` first.") return 2 - host = getattr(args, "host", None) or DEFAULT_HOST port = getattr(args, "port", None) or DEFAULT_PORT - - print( + _err( f"Starting Hermes proxy for {adapter.display_name}\n" f" Listening on: http://{host}:{port}/v1\n" f" Forwarding to: (resolved per-request from your subscription)\n" f" Use any bearer token in the client — the proxy attaches your real credential.\n" f"\n" - f"Press Ctrl+C to stop.", - file=sys.stderr, + f"Press Ctrl+C to stop." ) - try: asyncio.run(run_server(adapter, host=host, port=port)) except KeyboardInterrupt: - print("\nproxy: stopped", file=sys.stderr) + _err("\nproxy: stopped") except OSError as exc: - print(f"proxy: failed to bind {host}:{port}: {exc}", file=sys.stderr) + _err(f"proxy: failed to bind {host}:{port}: {exc}") return 1 return 0 @@ -81,16 +65,11 @@ def cmd_proxy_status(args: Any) -> int: try: cred = adapter.get_credential() except Exception as exc: - print( - f" [{name:8s}] {adapter.display_name} — credentials need attention " - f"({exc})" - ) + print(f" [{name:8s}] {adapter.display_name} — credentials need attention ({exc})") continue expires = f" (bearer expires {cred.expires_at})" if cred.expires_at else "" print(f" [{name:8s}] {adapter.display_name} — ready{expires}") - print( - "\nStart the proxy with: hermes proxy start [--provider ]" - ) + print("\nStart the proxy with: hermes proxy start [--provider ]") return 0 @@ -103,17 +82,20 @@ def cmd_proxy_list_providers(args: Any) -> int: return 0 +_SUBCOMMANDS = { + "start": cmd_proxy_start, + "status": cmd_proxy_status, + "providers": cmd_proxy_list_providers, + "list": cmd_proxy_list_providers, +} + + def cmd_proxy(args: Any) -> int: - """Dispatch ``hermes proxy ``.""" - sub = getattr(args, "proxy_command", None) - if sub == "start": - return cmd_proxy_start(args) - if sub == "status": - return cmd_proxy_status(args) - if sub in {"providers", "list"}: - return cmd_proxy_list_providers(args) - # No subcommand → print short help. - print( + """Dispatch ``hermes proxy ``; no/unknown subcommand prints the short help.""" + handler = _SUBCOMMANDS.get(getattr(args, "proxy_command", None)) + if handler is not None: + return handler(args) + _err( "hermes proxy — local OpenAI-compatible proxy that attaches your\n" "OAuth-authenticated provider credentials to outbound requests.\n" "\n" @@ -123,15 +105,9 @@ def cmd_proxy(args: Any) -> int: " hermes proxy status\n" " Show which upstream adapters are ready.\n" " hermes proxy providers\n" - " List available upstream providers.\n", - file=sys.stderr, + " List available upstream providers.\n" ) return 0 -__all__ = [ - "cmd_proxy", - "cmd_proxy_start", - "cmd_proxy_status", - "cmd_proxy_list_providers", -] +__all__ = ["cmd_proxy", "cmd_proxy_start", "cmd_proxy_status", "cmd_proxy_list_providers"] diff --git a/hermes_cli/proxy/server.py b/hermes_cli/proxy/server.py index 1b848718cf..3baea21d17 100644 --- a/hermes_cli/proxy/server.py +++ b/hermes_cli/proxy/server.py @@ -1,11 +1,8 @@ """HTTP server that forwards OpenAI-compatible requests to a configured upstream. -One narrow SSE compatibility shim applies after a *clean* upstream EOF: when a ``text/event-stream`` -response carries a terminal ``finish_reason`` or ``lastOne: true`` but omits the OpenAI ``data: -[DONE]`` sentinel, the proxy appends a single ``[DONE]`` frame. - -Otherwise the server does not mediate, log, or rewrite request/response bodies — it is a credential- -attaching forwarder. +A credential-attaching forwarder: request/response bodies are never mediated, logged, or rewritten. +The one shim: after a *clean* upstream EOF, a ``text/event-stream`` response that carried a terminal +``finish_reason`` or ``lastOne: true`` but omitted ``data: [DONE]`` gets a single ``[DONE]`` frame. """ from __future__ import annotations @@ -25,238 +22,162 @@ except ImportError: AIOHTTP_AVAILABLE = False from hermes_cli.proxy.adapters.base import UpstreamAdapter, UpstreamCredential -from hermes_cli.proxy.sse_done import ( - DONE_SSE_FRAME, - SseDoneTracker, - content_type_is_sse, -) +from hermes_cli.proxy.sse_done import DONE_SSE_FRAME, SseDoneTracker, content_type_is_sse logger = logging.getLogger(__name__) -# Headers we strip when forwarding to the upstream. ``host``/``content-length`` -# are recomputed by aiohttp; ``authorization`` is replaced with our bearer. -# Everything else (content-type, accept, user-agent, x-* headers) passes through. -_HOP_BY_HOP_HEADERS = frozenset( - { - "host", - "content-length", - "connection", - "keep-alive", - "proxy-authenticate", - "proxy-authorization", - "te", - "trailers", - "transfer-encoding", - "upgrade", - "authorization", # we replace this one - } -) +# Stripped when forwarding upstream: ``host``/``content-length`` are recomputed by aiohttp, +# ``authorization`` is replaced with our bearer; everything else passes through. +_HOP_BY_HOP_HEADERS = frozenset({ + "host", "content-length", "connection", "keep-alive", "proxy-authenticate", + "proxy-authorization", "te", "trailers", "transfer-encoding", "upgrade", "authorization", +}) +# aiohttp recomputes Content-Encoding/Content-Length on stream — let it. +_RESPONSE_DROP_HEADERS = _HOP_BY_HOP_HEADERS | {"content-encoding", "content-length"} DEFAULT_PORT = 8645 DEFAULT_HOST = "127.0.0.1" -# Body cap for forwarded requests. Chat-completion payloads with long agent -# conversations can be large; mirror api_server's MAX_REQUEST_BYTES (10 MB). -# client_max_size bounds every read path, including chunked bodies. +# Mirrors api_server's MAX_REQUEST_BYTES (10 MB); client_max_size bounds every read path, +# including chunked bodies. MAX_REQUEST_BYTES = 10_000_000 def _require_aiohttp() -> None: if not AIOHTTP_AVAILABLE: - raise RuntimeError( - "aiohttp is required for `hermes proxy`. Run `hermes setup` to install it." - ) + raise RuntimeError("aiohttp is required for `hermes proxy`. Run `hermes setup` to install it.") def _json_error(status: int, message: str, code: str = "proxy_error") -> "web.Response": - """Return an OpenAI-style error JSON response.""" + """OpenAI-style error JSON response.""" body = {"error": {"message": message, "type": code, "code": code}} return web.json_response(body, status=status) -# aiohttp recomputes Content-Encoding/Content-Length on stream — let it. -_RESPONSE_DROP_HEADERS = _HOP_BY_HOP_HEADERS | {"content-encoding", "content-length"} - - def _filter_headers(headers, drop: frozenset = _HOP_BY_HOP_HEADERS) -> dict: """Strip hop-by-hop (+ auth) headers; ``drop`` widens the set for upstream responses.""" return {key: value for key, value in headers.items() if key.lower() not in drop} -def create_app(adapter: UpstreamAdapter) -> "web.Application": - """Build the aiohttp application bound to a specific upstream adapter.""" - _require_aiohttp() +async def _open_upstream(request: "web.Request", rel_path: str, body: bytes, cred: UpstreamCredential): + """Send the request upstream with ``cred``; returns ``(session, response)`` or + ``(error_response, None)``.""" + upstream_url = f"{cred.base_url.rstrip('/')}{rel_path}" + if request.query_string: # preserved verbatim + upstream_url = f"{upstream_url}?{request.query_string}" + fwd_headers = _filter_headers(request.headers) + fwd_headers["Authorization"] = f"{cred.token_type} {cred.bearer}" + logger.debug("proxy: forwarding %s %s -> %s (body=%d bytes)", request.method, rel_path, upstream_url, len(body)) + try: + session = aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=None, sock_connect=15, sock_read=300)) + except Exception as exc: # pragma: no cover - aiohttp setup issue + return _json_error(500, f"proxy session init failed: {exc}"), None + try: + upstream_resp = await session.request( + request.method, upstream_url, data=body if body else None, headers=fwd_headers, allow_redirects=False + ) + except RuntimeError as exc: + await session.close() + return _json_error(500, str(exc)), None + except aiohttp.ClientError as exc: + await session.close() + logger.warning("proxy: upstream connection failed: %s", exc) + return _json_error(502, f"upstream connection failed: {exc}", code="upstream_unreachable"), None + except asyncio.TimeoutError: + await session.close() + return _json_error(504, "upstream request timed out", code="upstream_timeout"), None + except Exception: + await session.close() + raise + return session, upstream_resp + +async def _stream_back(request: "web.Request", session, upstream_resp) -> "web.StreamResponse": + """Relay status + filtered headers, then the body chunk-by-chunk, appending a missing SSE + ``[DONE]`` only after a clean EOF.""" + resp = web.StreamResponse( + status=upstream_resp.status, headers=_filter_headers(upstream_resp.headers, _RESPONSE_DROP_HEADERS) + ) + await resp.prepare(request) + done_tracker: Optional[SseDoneTracker] = None + if content_type_is_sse(upstream_resp.headers): + done_tracker = SseDoneTracker() + try: + async for chunk in upstream_resp.content.iter_any(): + if chunk: + if done_tracker is not None: + done_tracker.feed(chunk) + await resp.write(chunk) + if done_tracker is not None and done_tracker.should_append_done(): + try: + await resp.write(DONE_SSE_FRAME) + except Exception as exc: # client hung up at EOF — harmless + logger.debug("proxy: DONE append skipped: %s", exc) + except (aiohttp.ClientError, asyncio.CancelledError, OSError) as exc: + if done_tracker is not None: + done_tracker.mark_interrupted() + logger.warning("proxy: streaming interrupted: %s", exc) + finally: + upstream_resp.release() + await session.close() + await resp.write_eof() + return resp + + +def create_app(adapter: UpstreamAdapter) -> "web.Application": + """Build the aiohttp application bound to a specific upstream adapter. + + Every adapter method is synchronous and blocking (the Nous adapter takes the 15s cross-process + ``_auth_store_lock()`` and may POST a token refresh; xAI rotates its key pool under a lock), + so all three are run via ``asyncio.to_thread`` — a contended lock or refresh must never freeze + the single loop and every other in-flight streaming completion. + """ + _require_aiohttp() app = web.Application(client_max_size=MAX_REQUEST_BYTES) - # AppKey ensures forward-compat with future aiohttp versions that strip - # bare-string keys. - _adapter_key = web.AppKey("adapter", UpstreamAdapter) - app[_adapter_key] = adapter + # AppKey: forward-compat with aiohttp versions that strip bare-string keys. + app[web.AppKey("adapter", UpstreamAdapter)] = adapter async def handle_health(request: "web.Request") -> "web.Response": - # ``is_authenticated`` is documented as cheap (see UpstreamAdapter), - # but both shipped adapters read auth state off disk, and the Nous one - # does it under ``_auth_store_lock()`` — 15s cross-process. Offload it - # so a healthcheck poll can never freeze the loop behind a lock held by - # a concurrent ``hermes auth`` command. authenticated = await asyncio.to_thread(adapter.is_authenticated) - return web.json_response( - { - "status": "ok", - "upstream": adapter.display_name, - "authenticated": authenticated, - } - ) + return web.json_response({"status": "ok", "upstream": adapter.display_name, "authenticated": authenticated}) async def handle_proxy(request: "web.Request") -> "web.StreamResponse": - # Extract the path *after* /v1 - rel_path = request.match_info.get("tail", "") - rel_path = "/" + rel_path.lstrip("/") - + rel_path = "/" + request.match_info.get("tail", "").lstrip("/") if rel_path not in adapter.allowed_paths: allowed = ", ".join(sorted(adapter.allowed_paths)) return _json_error( - 404, - f"Path /v1{rel_path} is not forwarded by this proxy. " - f"Allowed: {allowed}", - code="path_not_allowed", + 404, f"Path /v1{rel_path} is not forwarded by this proxy. Allowed: {allowed}", code="path_not_allowed" ) - - # ``UpstreamAdapter.get_credential`` is synchronous and hard-blocking: - # the Nous adapter takes ``_auth_store_lock()`` (a cross-process lock - # with a 15s timeout), reads auth.json, and may perform a token-refresh - # POST, taking the lock a second time to persist a terminal error. Run - # it on a worker thread so a refresh or a contended lock cannot freeze - # every other in-flight streaming completion on this single loop. try: cred = await asyncio.to_thread(adapter.get_credential) except Exception as exc: logger.warning("proxy: credential resolution failed: %s", exc) return _json_error(401, str(exc), code="upstream_auth_failed") - - # Forward body verbatim. Read into memory once — request bodies for - # chat/completions/embeddings are small (<1MB typically). If we ever - # need to forward large multipart uploads we'll switch to streaming - # the request body too. + # Body read into memory once (chat/embeddings payloads are small); switch to streaming + # if large multipart uploads ever need forwarding. body = await request.read() - - timeout = aiohttp.ClientTimeout(total=None, sock_connect=15, sock_read=300) - - async def _send_upstream(active_cred: UpstreamCredential): - upstream_url = f"{active_cred.base_url.rstrip('/')}{rel_path}" - # Preserve query string verbatim. - if request.query_string: - upstream_url = f"{upstream_url}?{request.query_string}" - - fwd_headers = _filter_headers(request.headers) - fwd_headers["Authorization"] = f"{active_cred.token_type} {active_cred.bearer}" - - logger.debug( - "proxy: forwarding %s %s -> %s (body=%d bytes)", - request.method, rel_path, upstream_url, len(body), - ) - - try: - session = aiohttp.ClientSession(timeout=timeout) - except Exception as exc: # pragma: no cover - aiohttp setup issue - raise RuntimeError(f"proxy session init failed: {exc}") from exc - - try: - upstream_resp = await session.request( - request.method, - upstream_url, - data=body if body else None, - headers=fwd_headers, - allow_redirects=False, - ) - except Exception: - await session.close() - raise - return session, upstream_resp - - async def _open_upstream(active_cred: UpstreamCredential): - try: - return await _send_upstream(active_cred) - except RuntimeError as exc: - return _json_error(500, str(exc)), None - except aiohttp.ClientError as exc: - logger.warning("proxy: upstream connection failed: %s", exc) - return _json_error( - 502, f"upstream connection failed: {exc}", code="upstream_unreachable" - ), None - except asyncio.TimeoutError: - return _json_error(504, "upstream request timed out", code="upstream_timeout"), None - - session_or_response, upstream_resp = await _open_upstream(cred) + session, upstream_resp = await _open_upstream(request, rel_path, body, cred) if upstream_resp is None: - return session_or_response - session = session_or_response - + return session if upstream_resp.status in {401, 429}: - # Third and last blocking method on the adapter contract, and the - # most expensive: the Nous adapter routes this straight into - # ``_get_credential(force_refresh=True)``, so the refresh POST that - # ``get_credential`` only performs near expiry is unconditional - # here — under the same 15s cross-process ``_auth_store_lock()``. - # The xAI adapter loads its key pool off disk and rotates it under - # ``self._lock``. Offload it for the same reason as the two above. + # One-shot retry with a refreshed/rotated credential (Nous: unconditional refresh + # POST under the auth lock; xAI: pool rotation). try: retry_cred = await asyncio.to_thread( - adapter.get_retry_credential, - failed_credential=cred, - status_code=upstream_resp.status, + adapter.get_retry_credential, failed_credential=cred, status_code=upstream_resp.status ) except Exception as exc: logger.warning("proxy: retry credential resolution failed: %s", exc) retry_cred = None - if retry_cred is not None: upstream_resp.release() await session.close() - session_or_response, upstream_resp = await _open_upstream(retry_cred) + session, upstream_resp = await _open_upstream(request, rel_path, body, retry_cred) if upstream_resp is None: - return session_or_response - session = session_or_response - - # Stream response back. Headers first, then chunked body. - resp = web.StreamResponse( - status=upstream_resp.status, - headers=_filter_headers(upstream_resp.headers, _RESPONSE_DROP_HEADERS), - ) - await resp.prepare(request) - - # Track SSE terminal markers so we can append a missing [DONE] - # after clean EOF without rewriting any earlier frames. - done_tracker: Optional[SseDoneTracker] = None - if content_type_is_sse(upstream_resp.headers): - done_tracker = SseDoneTracker() - - try: - async for chunk in upstream_resp.content.iter_any(): - if chunk: - if done_tracker is not None: - done_tracker.feed(chunk) - await resp.write(chunk) - if done_tracker is not None and done_tracker.should_append_done(): - try: - await resp.write(DONE_SSE_FRAME) - except Exception as exc: # client hung up at EOF — harmless - logger.debug("proxy: DONE append skipped: %s", exc) - except (aiohttp.ClientError, asyncio.CancelledError, OSError) as exc: - if done_tracker is not None: - done_tracker.mark_interrupted() - logger.warning("proxy: streaming interrupted: %s", exc) - finally: - upstream_resp.release() - await session.close() - - await resp.write_eof() - return resp - - # /health doesn't go through the upstream - app.router.add_get("/health", handle_health) - # Catch-all under /v1 — forwards if the path is allowed. - app.router.add_route("*", "/v1/{tail:.*}", handle_proxy) + return session + return await _stream_back(request, session, upstream_resp) + app.router.add_get("/health", handle_health) # never goes upstream + app.router.add_route("*", "/v1/{tail:.*}", handle_proxy) # forwards if the path is allowed return app @@ -268,31 +189,20 @@ async def run_server( ) -> None: """Run the proxy in the current event loop until shutdown_event is set.""" _require_aiohttp() - app = create_app(adapter) runner = web.AppRunner(app, access_log=None) await runner.setup() site = web.TCPSite(runner, host=host, port=port) await site.start() - - logger.info( - "proxy: listening on http://%s:%d/v1 -> %s", - host, port, adapter.display_name, - ) - + logger.info("proxy: listening on http://%s:%d/v1 -> %s", host, port, adapter.display_name) stop_event = shutdown_event or asyncio.Event() - - # Wire signal handlers when we own the loop's lifetime. - if shutdown_event is None: + if shutdown_event is None: # we own the loop's lifetime → wire signal handlers loop = asyncio.get_running_loop() for sig in (signal.SIGINT, signal.SIGTERM): try: loop.add_signal_handler(sig, stop_event.set) # windows-footgun: ok except NotImplementedError: - # Windows / restricted environments — Ctrl+C will still - # raise KeyboardInterrupt and unwind us. - pass - + pass # Windows / restricted envs — Ctrl+C still raises KeyboardInterrupt try: await stop_event.wait() finally: @@ -300,10 +210,4 @@ async def run_server( await runner.cleanup() -__all__ = [ - "create_app", - "run_server", - "DEFAULT_HOST", - "DEFAULT_PORT", - "AIOHTTP_AVAILABLE", -] +__all__ = ["create_app", "run_server", "DEFAULT_HOST", "DEFAULT_PORT", "AIOHTTP_AVAILABLE"] diff --git a/hermes_cli/proxy/sse_done.py b/hermes_cli/proxy/sse_done.py index 46f39e7acd..d895c6ae62 100644 --- a/hermes_cli/proxy/sse_done.py +++ b/hermes_cli/proxy/sse_done.py @@ -1,13 +1,9 @@ """SSE ``[DONE]`` sentinel normalization for OpenAI-compatible proxies. -Strict OpenAI-compatible clients treat that shape as a truncated stream. This module watches the -forwarded SSE byte stream and reports whether the proxy should append a single ``data: [DONE]`` -frame after a *clean* upstream EOF. - -- Append ``[DONE]`` only after a complete terminal choice (``finish_reason`` non-null) **or** an -upstream ``lastOne: true`` marker. - Never synthesize ``[DONE]`` after an error event, or when the -stream was interrupted before clean EOF. - Never emit a second ``[DONE]`` when the upstream already -sent one. +Strict OpenAI clients treat a stream without ``data: [DONE]`` as truncated. The tracker watches the +forwarded SSE bytes and says whether to append ONE ``[DONE]`` after a *clean* upstream EOF: only after +a terminal choice (``finish_reason`` non-null) or ``lastOne: true``; never after an error event, an +interrupted stream, or when the upstream already sent ``[DONE]``. """ from __future__ import annotations @@ -37,10 +33,7 @@ class SseDoneTracker: if not chunk: return self._buf.extend(chunk) - while True: - nl = self._buf.find(b"\n") - if nl < 0: - break + while (nl := self._buf.find(b"\n")) >= 0: line = bytes(self._buf[:nl]) del self._buf[: nl + 1] self._consume_line(line) @@ -49,60 +42,48 @@ class SseDoneTracker: """Upstream stream ended via error/cancel — do not synthesize DONE.""" self.interrupted = True + def _blocked(self) -> bool: + return self.saw_done or self.saw_error_event or self.saw_malformed_event + def should_append_done(self) -> bool: """True when a single terminal ``[DONE]`` should be appended.""" - if ( - self.interrupted - or self.saw_done - or self.saw_error_event - or self.saw_malformed_event - ): + if self.interrupted or self._blocked(): return False - # Flush any trailing line without a final newline (rare but valid), - # then dispatch a final event that never saw its blank-line boundary. + # Flush a trailing line without a final newline (rare but valid), then dispatch a final + # event that never saw its blank-line boundary. if self._buf: self._consume_line(bytes(self._buf)) self._buf.clear() self._dispatch_event() - if self.saw_done or self.saw_error_event or self.saw_malformed_event: + if self._blocked(): return False return self.saw_terminal_finish or self.saw_last_one def _consume_line(self, line: bytes) -> None: - # Strip CR from CRLF-delimited SSE. - if line.endswith(b"\r"): + if line.endswith(b"\r"): # CRLF-delimited SSE line = line[:-1] - if not line: - # Blank line = SSE event boundary: dispatch accumulated data. + if not line: # blank line = event boundary self._dispatch_event() return if not line.startswith(b"data:"): return - # Per the SSE spec one event may span several consecutive ``data:`` - # lines whose payloads are joined with "\n" at dispatch time. - # Parsing each line independently would misread a split JSON event - # as two malformed fragments. + # One event may span several ``data:`` lines joined with "\n" at dispatch; parsing each + # line alone would misread a split JSON event as two malformed fragments. self._data_lines.append(line[5:].strip()) def _dispatch_event(self) -> None: if not self._data_lines: return - payload = b"\n".join(self._data_lines) + payload = b"\n".join(self._data_lines).strip() self._data_lines = [] - payload = payload.strip() if payload == b"[DONE]": self.saw_done = True return if not payload: return try: - text = payload.decode("utf-8") - except UnicodeDecodeError: - self.saw_malformed_event = True - return - try: - event = json.loads(text) - except json.JSONDecodeError: + event = json.loads(payload.decode("utf-8")) + except (UnicodeDecodeError, json.JSONDecodeError): self.saw_malformed_event = True return if not isinstance(event, dict): @@ -110,23 +91,22 @@ class SseDoneTracker: if event.get("error") is not None: self.saw_error_event = True return - # Accept integer-truthy sentinels too — relabelled upstreams have - # been observed sending ``"lastOne": 1`` / ``"true"``. + # Relabelled upstreams have been observed sending ``"lastOne": 1`` / ``"true"``. if event.get("lastOne") in (True, 1, "true"): self.saw_last_one = True for choice in event.get("choices") or []: if not isinstance(choice, dict): continue - if choice.get("finish_reason") is not None: + fr = choice.get("finish_reason") + if fr is not None: self.saw_terminal_finish = True # OpenAI error-shaped finish reasons should not unlock DONE. - fr = choice.get("finish_reason") if isinstance(fr, str) and fr.lower() in {"error", "provider_error"}: self.saw_error_event = True def content_type_is_sse(headers) -> bool: - """Return True when response headers advertise an SSE body.""" + """True when response headers advertise an SSE body.""" try: value = headers.get("Content-Type") or headers.get("content-type") or "" except Exception: @@ -134,8 +114,4 @@ def content_type_is_sse(headers) -> bool: return "text/event-stream" in str(value).lower() -__all__ = [ - "DONE_SSE_FRAME", - "SseDoneTracker", - "content_type_is_sse", -] +__all__ = ["DONE_SSE_FRAME", "SseDoneTracker", "content_type_is_sse"] diff --git a/hermes_cli/proxy_cli.py b/hermes_cli/proxy_cli.py index c3cfa30910..53a03da0d3 100644 --- a/hermes_cli/proxy_cli.py +++ b/hermes_cli/proxy_cli.py @@ -14,57 +14,40 @@ from rich.panel import Panel from rich.table import Table from agent.proxy_sources import iron_proxy as ip -from hermes_cli.config import load_config, save_config - - -# --------------------------------------------------------------------------- -# Argparse wiring — called from hermes_cli.main -# --------------------------------------------------------------------------- +from hermes_cli.config import load_config, load_env, save_config def register_cli(parent_parser: argparse.ArgumentParser) -> None: """Attach the egress subcommand tree to a parent parser.""" - - # dest='egress_command' — keeps this subparser tree disjoint from the - # inbound OAuth ``hermes proxy`` subparser (which uses dest='proxy_command'). - # No runtime collision today since they live in separate parser trees, - # but a future grep-and-refactor on ``proxy_command`` would otherwise - # hit both handlers. + # dest='egress_command' keeps this tree disjoint from the inbound OAuth ``hermes proxy`` + # subparser (dest='proxy_command') so a grep-and-refactor on one never hits the other. sub = parent_parser.add_subparsers(dest="egress_command") # (name, help, handler, [(flag, add_argument kwargs), ...]) — declaration order is the # ``--help`` order, so keep it stable. commands = [ ("install", f"Download iron-proxy binary (v{ip._IRON_PROXY_VERSION})", cmd_install, [ - ("--force", dict(action="store_true", - help="Re-download even if a managed copy already exists")), + ("--force", dict(action="store_true", help="Re-download even if a managed copy already exists")), ]), ("setup", "Interactive wizard: install + CA + mint tokens + write config", cmd_setup, [ - ("--tunnel-port", dict( - type=int, default=None, - help=f"Override the tunnel port (default {ip._DEFAULT_TUNNEL_PORT})")), - ("--from-bitwarden", dict( - action="store_true", - help="Treat secrets as managed by Bitwarden — discover provider keys " - "from secrets.bitwarden config instead of the current env. Fails " - "loudly if BW is unreachable rather than silently falling back.")), - ("--no-bitwarden", dict( - action="store_true", - help="Explicitly switch credential_source back to env on re-setup " - "(only meaningful when the previous setup used --from-bitwarden).")), - ("--rotate-tokens", dict( - action="store_true", - help="Mint fresh proxy tokens for every provider (default is to " - "preserve tokens for providers that already had one — avoids " - "401-ing already-running sandboxes on re-setup).")), - ("--restart", dict( - dest="restart", action="store_true", default=None, - help="If a daemon is already running, restart it automatically after " - "writing the new config/tokens (non-interactive default on a tty " - "is to ask).")), - ("--no-restart", dict( - dest="restart", action="store_false", - help="Do not restart a running daemon after setup; you'll need to run " - "`hermes egress restart` yourself for changes to take effect.")), + ("--tunnel-port", dict(type=int, default=None, + help=f"Override the tunnel port (default {ip._DEFAULT_TUNNEL_PORT})")), + ("--from-bitwarden", dict(action="store_true", help=( + "Treat secrets as managed by Bitwarden — discover provider keys " + "from secrets.bitwarden config instead of the current env. Fails " + "loudly if BW is unreachable rather than silently falling back."))), + ("--no-bitwarden", dict(action="store_true", help=( + "Explicitly switch credential_source back to env on re-setup " + "(only meaningful when the previous setup used --from-bitwarden)."))), + ("--rotate-tokens", dict(action="store_true", help=( + "Mint fresh proxy tokens for every provider (default is to " + "preserve tokens for providers that already had one — avoids " + "401-ing already-running sandboxes on re-setup)."))), + ("--restart", dict(dest="restart", action="store_true", default=None, help=( + "If a daemon is already running, restart it automatically after " + "writing the new config/tokens (non-interactive default on a tty is to ask)."))), + ("--no-restart", dict(dest="restart", action="store_false", help=( + "Do not restart a running daemon after setup; you'll need to run " + "`hermes egress restart` yourself for changes to take effect."))), ]), ("start", "Start the managed iron-proxy", cmd_start, []), ("stop", "Stop the managed iron-proxy", cmd_stop, []), @@ -72,10 +55,9 @@ def register_cli(parent_parser: argparse.ArgumentParser) -> None: ("reload", "Hot-reload the running daemon's ruleset from proxy.yaml " "(management API — no restart, no dropped connections)", cmd_reload, []), ("status", "Show proxy state and mappings", cmd_status, [ - ("--show-tokens", dict( - action="store_true", - help="Print the proxy tokens (default: redacted prefix only). " - "Beware: tokens may persist in your shell history.")), + ("--show-tokens", dict(action="store_true", help=( + "Print the proxy tokens (default: redacted prefix only). " + "Beware: tokens may persist in your shell history."))), ]), ("disable", "Turn off the proxy integration", cmd_disable, []), ("config", "Print the generated proxy.yaml path", cmd_config, []), @@ -87,20 +69,13 @@ def register_cli(parent_parser: argparse.ArgumentParser) -> None: parser.set_defaults(func=func) -# --------------------------------------------------------------------------- -# Handlers -# --------------------------------------------------------------------------- - - def cmd_install(args: argparse.Namespace) -> int: console = Console() try: binary = ip.install_iron_proxy(force=bool(args.force)) except Exception as exc: # noqa: BLE001 — top-level user-facing error funnel console.print(f"[red]✗ install failed:[/red] {exc}") - console.print( - " Manual install: https://github.com/ironsh/iron-proxy/releases" - ) + console.print(" Manual install: https://github.com/ironsh/iron-proxy/releases") return 1 version = ip.iron_proxy_version(binary) or "(version unknown)" console.print(f"[green]✓[/green] installed {binary} {version}") @@ -108,6 +83,7 @@ def cmd_install(args: argparse.Namespace) -> int: def cmd_setup(args: argparse.Namespace) -> int: + """Four-step wizard; each ``_setup_*`` phase prints its own step and returns ``None`` to abort.""" console = Console() console.print(Panel.fit( "[bold]iron-proxy setup[/bold]\n\n" @@ -116,10 +92,34 @@ def cmd_setup(args: argparse.Namespace) -> int: "[dim]Project: https://github.com/ironsh/iron-proxy (Apache-2.0)[/dim]", border_style="cyan", )) - - # ------------------------------------------------------------------ binary + if not _setup_install_binary(console): + return 1 + ca = _setup_ca_cert(console) + if ca is None: + return 1 + mappings = _setup_mint_tokens(console, args) + if mappings is None: + return 1 + proxy_cfg = _setup_write_config(console, args, mappings, *ca) + if proxy_cfg is None: + return 1 + _setup_restart_daemon(console, args, proxy_cfg) console.print() - console.print("[bold]Step 1[/bold] Install the iron-proxy binary") + console.print("[green]✓ iron-proxy is configured.[/green] Sandboxes will route outbound traffic through it.") + console.print( + " Start: [cyan]hermes egress start[/cyan]\n" + " Restart: [cyan]hermes egress restart[/cyan] (after any re-setup)\n" + " Reload: [cyan]hermes egress reload[/cyan] (apply ruleset edits " + "in-place, no restart)\n" + " Status: [cyan]hermes egress status[/cyan]\n" + " Stop: [cyan]hermes egress stop[/cyan]\n" + " Disable: [cyan]hermes egress disable[/cyan]" + ) + return 0 + + +def _setup_install_binary(console: Console) -> bool: + _step(console, 1, "Install the iron-proxy binary") try: binary = ip.find_iron_proxy(install_if_missing=False) if binary is None: @@ -129,72 +129,52 @@ def cmd_setup(args: argparse.Namespace) -> int: console.print(f" [green]✓[/green] {binary} {version}") except Exception as exc: # noqa: BLE001 console.print(f" [red]✗ install failed: {exc}[/red]") - return 1 + return False + return True - # ------------------------------------------------------------------ CA - console.print() - console.print("[bold]Step 2[/bold] Generate a CA cert") + +def _setup_ca_cert(console: Console): + _step(console, 2, "Generate a CA cert") try: ca_crt, ca_key = ip.ensure_ca_cert() except Exception as exc: # noqa: BLE001 console.print(f" [red]✗ CA generation failed: {exc}[/red]") - return 1 + return None console.print(f" [green]✓[/green] {ca_crt}") + return ca_crt, ca_key - # ------------------------------------------------------------------ mint - console.print() - console.print("[bold]Step 3[/bold] Mint proxy tokens for known providers") +def _setup_mint_tokens(console: Console, args: argparse.Namespace): + """Discover providers, merge with existing tokens (rotating on request), print the table.""" + _step(console, 3, "Mint proxy tokens for known providers") available_env_names: List[str] = [] if args.from_bitwarden: available_env_names = _bitwarden_env_names(console) if available_env_names is None: - return 1 + return None else: - # Env-based discovery reads os.environ. Operators commonly keep their - # provider keys only in ~/.hermes/.env (loaded automatically when the - # agent runs, but NOT exported into an interactive shell). Fall back - # to loading that file so `hermes egress setup` finds the same keys the - # agent would — otherwise a user with keys solely in .env sees a - # confusing "no provider keys found" when the keys clearly "exist". + # Operators commonly keep provider keys only in ~/.hermes/.env (loaded when the agent + # runs, NOT exported into an interactive shell); backfill so discovery sees them. loaded = _load_env_file_into_environ() if loaded: - console.print( - f" [dim]Loaded {loaded} provider key name(s) from " - f"~/.hermes/.env for discovery.[/dim]" - ) - - discovered = ip.discover_provider_mappings( - available_env_names=available_env_names or None, - ) - - # Preserve tokens for providers we already had unless the operator - # explicitly requested rotation. This prevents re-running `hermes - # egress setup` from invalidating tokens baked into already-running - # sandboxes. + console.print(f" [dim]Loaded {loaded} provider key name(s) from ~/.hermes/.env for discovery.[/dim]") + discovered = ip.discover_provider_mappings(available_env_names=available_env_names or None) + # Preserve existing tokens unless rotation was requested — re-running setup must not + # invalidate tokens baked into already-running sandboxes. existing = ip.load_mappings() rotate = bool(getattr(args, "rotate_tokens", False)) - - # P3 confirmation gate: --rotate-tokens invalidates every running - # sandbox's proxy tokens immediately. An accidental re-run (history - # scroll-back, tmux paste) is unrecoverable, so require explicit - # confirmation when there's something to actually rotate. Skipped - # when stdin isn't a tty (CI / non-interactive use), in which case - # the operator passed the flag deliberately. if rotate and existing: + # Rotation is unrecoverable for running sandboxes, so gate it on an explicit confirmation + # when stdin is a tty (non-interactive callers passed the flag deliberately). if sys.stdin.isatty(): console.print( "[yellow]⚠[/yellow] --rotate-tokens will invalidate proxy " - "tokens in every running Hermes sandbox. They will start " - "401-ing against upstreams until restarted." + "tokens in every running Hermes sandbox. They will start 401-ing against upstreams until restarted." ) if _prompt("Type 'rotate' to confirm: ") != "rotate": console.print("[yellow]Cancelled.[/yellow]") - return 1 - # Backup the existing mappings before we overwrite. The - # resulting ``.rotated-`` sibling is plain JSON and lets - # the operator manually recover tokens if they realise the - # rotation was a mistake. + return None + # Plain-JSON ``.rotated-`` backup lets the operator recover tokens by hand. try: state_dir = ip._proxy_state_dir() mappings_src = state_dir / "mappings.json" @@ -204,113 +184,65 @@ def cmd_setup(args: argparse.Namespace) -> int: shutil.copy2(str(mappings_src), str(backup)) console.print(f" [dim]backup: {backup}[/dim]") except OSError as exc: - console.print( - f" [yellow]Could not back up mappings before rotation: " - f"{exc}[/yellow]" - ) + console.print(f" [yellow]Could not back up mappings before rotation: {exc}[/yellow]") elif rotate and not existing: console.print( - "[dim]Note: --rotate-tokens is a no-op on first-time setup " - "(no existing tokens to rotate).[/dim]" + "[dim]Note: --rotate-tokens is a no-op on first-time setup (no existing tokens to rotate).[/dim]" ) - - mappings = ip.merge_mappings( - existing=existing, - discovered=discovered, - rotate=rotate, - ) - + mappings = ip.merge_mappings(existing=existing, discovered=discovered, rotate=rotate) if not mappings: - console.print( - " [yellow]No known provider API keys found in env/Bitwarden.[/yellow]" - ) - console.print( - " Set at least one of these and rerun setup:" - ) + console.print(" [yellow]No known provider API keys found in env/Bitwarden.[/yellow]") + console.print(" Set at least one of these and rerun setup:") for env_name in sorted(ip._BEARER_PROVIDERS): console.print(f" - {env_name}") - return 1 - - # Warn the operator about providers we recognize but can't proxy - # (AWS Bedrock SigV4, GCP Vertex service-account OAuth). These still - # work — they just bypass the egress isolation. - uncovered = ip.discover_uncovered_providers( - available_env_names=available_env_names or None, - ) + return None + # Providers we recognise but can't proxy (SigV4, service-account OAuth) still work — they + # just bypass the egress isolation, so say so. + uncovered = ip.discover_uncovered_providers(available_env_names=available_env_names or None) if uncovered: console.print() - console.print( - " [yellow]⚠[/yellow] Detected provider env vars that the " - "proxy does not yet cover:" - ) + console.print(" [yellow]⚠[/yellow] Detected provider env vars that the proxy does not yet cover:") for name in uncovered: console.print(f" - {name}") console.print( " [dim]These providers use request signing or SDK-minted " "OAuth (SigV4, service-account files) and will hold real " - "credentials inside the sandbox. Egress isolation is " - "INCOMPLETE for these.[/dim]" + "credentials inside the sandbox. Egress isolation is INCOMPLETE for these.[/dim]" ) + console.print(_mappings_table(mappings, "Provider env", "Upstream hosts", show_tokens=False)) + return mappings - table = Table(show_header=True, header_style="bold") - table.add_column("Provider env", style="cyan") - table.add_column("Upstream hosts", style="dim") - table.add_column("Proxy token", style="green") - for m in mappings: - table.add_row( - m.real_env_name, - ", ".join(m.upstream_hosts), - _redact_token(m.proxy_token), - ) - console.print(table) - - # ------------------------------------------------------------------ write - console.print() - console.print("[bold]Step 4[/bold] Write config and persist mappings") +def _setup_write_config(console: Console, args: argparse.Namespace, mappings, ca_crt, ca_key): + """Write proxy.yaml + mappings, then enable the integration in config; returns ``proxy_cfg``.""" + _step(console, 4, "Write config and persist mappings") cfg = load_config() proxy_cfg = cfg.setdefault("proxy", {}) - # ``args.tunnel_port`` is None when the flag was not given; ``0`` is - # invalid for a TCP listener so we treat it as an explicit refusal - # and surface a clear error rather than silently substituting the - # default. + # None = flag not given. ``0`` is not a valid TCP listener, so it is a hard error rather + # than a silent fallback to the default. if args.tunnel_port is not None: if args.tunnel_port < 1 or args.tunnel_port > 65534: console.print( - " [red]✗ --tunnel-port must be between 1 and 65534 " - "(the plain-HTTP listener uses port+1).[/red]" + " [red]✗ --tunnel-port must be between 1 and 65534 (the plain-HTTP listener uses port+1).[/red]" ) - return 1 + return None tunnel_port = int(args.tunnel_port) else: tunnel_port = int(proxy_cfg.get("tunnel_port", ip._DEFAULT_TUNNEL_PORT)) proxy_cfg["tunnel_port"] = tunnel_port - extra_hosts = list(proxy_cfg.get("extra_allowed_hosts") or []) - allowed = list(ip._DEFAULT_ALLOWED_HOSTS) + [ - h for h in extra_hosts if h not in ip._DEFAULT_ALLOWED_HOSTS - ] - + allowed = list(ip._DEFAULT_ALLOWED_HOSTS) + [h for h in extra_hosts if h not in ip._DEFAULT_ALLOWED_HOSTS] + # Pre-create the audit log 0o600. The pinned v0.39 daemon never writes it (reserved for + # v0.40+ per-request records), so a pre-create failure is a WARNING, not a setup abort. audit_log_path = ip._proxy_state_dir() / "audit.log" - # Pre-create the audit log with 0o600. On the pinned v0.39 the - # daemon does NOT write to this file (no ``log.audit_path`` field in - # its config schema) — it's reserved for the v0.40+ upgrade where - # per-request records start flowing. Because the file is - # non-load-bearing today, a pre-create failure (immutable parent, - # pre-existing foreign-owned file, full disk) is a WARNING, not a - # setup abort. audit_log_ok = True try: ip.ensure_audit_log(audit_log_path) except RuntimeError as exc: audit_log_ok = False console.print(f" [yellow]⚠ {exc}[/yellow]") - - # Allow operator override of the deny list via - # ``proxy.upstream_deny_cidrs`` — but the default (None) gives a safe - # default-deny list (loopback, IMDS, RFC1918) that matches the docs - # promise. - deny_cidrs = proxy_cfg.get("upstream_deny_cidrs") + # ``proxy.upstream_deny_cidrs`` overrides the deny list; None yields the documented safe + # default-deny set (loopback, IMDS, RFC1918). iron_cfg = ip.build_proxy_config( mappings=mappings, ca_cert=ca_crt, @@ -318,15 +250,12 @@ def cmd_setup(args: argparse.Namespace) -> int: tunnel_port=tunnel_port, audit_log=audit_log_path, allowed_hosts=allowed, - upstream_deny_cidrs=deny_cidrs, + upstream_deny_cidrs=proxy_cfg.get("upstream_deny_cidrs"), ) cfg_path = ip.write_proxy_config(iron_cfg) mappings_path = ip.write_mappings(mappings) - # Mint (or keep) the management-API bearer key. The generated config - # enables a loopback management listener whose /v1/reload lets - # `hermes egress reload` apply future ruleset changes without a - # restart; the daemon requires the key env var to be non-empty at - # startup, so make sure the token exists before first start. + # The generated config enables a loopback management listener (used by ``egress reload``); + # the daemon requires its bearer key env var to be non-empty at startup, so mint it now. ip.ensure_management_token() console.print(f" [green]✓[/green] config: {cfg_path}") console.print(f" [green]✓[/green] mappings: {mappings_path}") @@ -336,29 +265,20 @@ def cmd_setup(args: argparse.Namespace) -> int: f"[dim](reserved — not written by iron-proxy v0.39; " f"per-request records land in iron-proxy.log)[/dim]" ) - - # ------------------------------------------------------------------ enable proxy_cfg["enabled"] = True proxy_cfg.setdefault("auto_install", True) proxy_cfg.setdefault("enforce_on_docker", True) - # CRITICAL: do NOT silently downgrade credential_source on re-run. - # If the operator previously configured `bitwarden` mode (e.g. for - # rotation), running `hermes egress setup` again WITHOUT - # --from-bitwarden must not rewrite credential_source to "env" — - # that silently breaks the Bitwarden rotation guarantee the docs - # make. Require an explicit --no-bitwarden to switch back. + # CRITICAL: never silently downgrade credential_source on re-run. A previous + # ``--from-bitwarden`` setup keeps bitwarden mode (the documented rotation guarantee) + # unless the operator passes an explicit --no-bitwarden. existing_source = proxy_cfg.get("credential_source") if args.from_bitwarden: proxy_cfg["credential_source"] = "bitwarden" elif getattr(args, "no_bitwarden", False): proxy_cfg["credential_source"] = "env" if existing_source == "bitwarden": - console.print( - "[yellow]Switched credential_source from bitwarden to env.[/yellow]" - ) + console.print("[yellow]Switched credential_source from bitwarden to env.[/yellow]") elif existing_source == "bitwarden": - # Preserve the existing bitwarden mode. Surface the decision so - # the operator knows we kept it. console.print( "[dim]Keeping credential_source=bitwarden from existing config. " "Pass --no-bitwarden to switch to env-based credentials.[/dim]" @@ -366,21 +286,19 @@ def cmd_setup(args: argparse.Namespace) -> int: else: proxy_cfg["credential_source"] = "env" save_config(cfg) + return proxy_cfg - live_status = ip.get_status() - was_running = live_status.pid is not None + +def _setup_restart_daemon(console: Console, args: argparse.Namespace, proxy_cfg: dict) -> None: + """Stop a running daemon and decide whether to (re)start it with the new config. + + --restart → always (re)start; --no-restart → never (print the manual hint); neither + tty → + ask only when a daemon was running; neither + !tty → restart iff one was running (first-time + setup never auto-starts — matches the "configured, now run start" flow). + """ + was_running = ip.get_status().pid is not None if was_running: ip.stop_proxy() - - # Decide whether to (re)start the daemon so the new config/tokens take - # effect, rather than leaving the operator to remember a manual restart - # (the #1 UX papercut for this feature). - # --restart → always (re)start, even if nothing was running - # --no-restart → never; leave it as-is and print the manual hint - # neither + tty → ask (only when a daemon was running) - # neither + !tty → restart when a daemon was running; otherwise no-op - # (first-time setup never auto-starts — matches the - # "configured, now run start" flow) restart_pref = getattr(args, "restart", None) if restart_pref is True or restart_pref is False: do_restart = restart_pref @@ -393,21 +311,12 @@ def cmd_setup(args: argparse.Namespace) -> int: ) else: do_restart = False - if do_restart: try: - new_status = ip.start_proxy( - install_if_missing=bool(proxy_cfg.get("auto_install", True)), - ) + new_status = ip.start_proxy(install_if_missing=bool(proxy_cfg.get("auto_install", True))) except Exception as exc: # noqa: BLE001 — user-facing funnel - console.print( - f" [yellow]⚠ could not start iron-proxy with the new " - f"config: {exc}[/yellow]" - ) - console.print( - " Run [cyan]hermes egress start[/cyan] manually before " - "launching new Docker sandboxes." - ) + console.print(f" [yellow]⚠ could not start iron-proxy with the new config: {exc}[/yellow]") + console.print(" Run [cyan]hermes egress start[/cyan] manually before launching new Docker sandboxes.") else: listening = "listening" if new_status.listening else "not yet listening" verb = "restarted" if was_running else "started" @@ -422,118 +331,60 @@ def cmd_setup(args: argparse.Namespace) -> int: "[cyan]start[/cyan]) before launching new Docker sandboxes.[/yellow]" ) - console.print() - console.print( - "[green]✓ iron-proxy is configured.[/green] " - "Sandboxes will route outbound traffic through it." - ) - console.print( - " Start: [cyan]hermes egress start[/cyan]\n" - " Restart: [cyan]hermes egress restart[/cyan] (after any re-setup)\n" - " Reload: [cyan]hermes egress reload[/cyan] (apply ruleset edits " - "in-place, no restart)\n" - " Status: [cyan]hermes egress status[/cyan]\n" - " Stop: [cyan]hermes egress stop[/cyan]\n" - " Disable: [cyan]hermes egress disable[/cyan]" - ) - return 0 - def cmd_start(args: argparse.Namespace) -> int: console = Console() cfg = load_config() proxy_cfg = cfg.get("proxy") or {} if not proxy_cfg.get("enabled"): - console.print( - "[yellow]proxy.enabled is false — run `hermes egress setup` " - "first.[/yellow]" - ) + console.print("[yellow]proxy.enabled is false — run `hermes egress setup` first.[/yellow]") return 1 - - # If the operator opted in to Bitwarden-rotation semantics, refresh - # upstream secrets from BSM at startup. This is what delivers the - # rotation guarantee that distinguishes ``credential_source: - # bitwarden`` from ``credential_source: env``. Without it, rotating - # a key in the Bitwarden web app doesn't reach the proxy. + # ``credential_source: bitwarden`` refreshes upstream secrets from BSM at startup — that is + # the rotation guarantee distinguishing it from ``env``. credential_source = proxy_cfg.get("credential_source", "env") bw_cfg = (cfg.get("secrets") or {}).get("bitwarden") - refresh_bw = ( - credential_source == "bitwarden" - and bw_cfg is not None - and bool(bw_cfg.get("enabled")) - ) - # Silent-degrade guard: the operator explicitly chose - # ``credential_source: bitwarden``, but secrets.bitwarden has since - # been disabled or removed. Proceeding would quietly start on host - # env — exactly the bug class the BW mode is meant to defeat. Refuse - # unless the documented escape hatch is set. + refresh_bw = (credential_source == "bitwarden" and bw_cfg is not None and bool(bw_cfg.get("enabled"))) + # Silent-degrade guard: bitwarden mode chosen but secrets.bitwarden disabled/removed. Refuse + # (quietly starting on host env is the bug class BW mode exists to defeat) unless the + # documented escape hatch is set. if credential_source == "bitwarden" and not refresh_bw: if bool(proxy_cfg.get("allow_env_fallback", False)): console.print( - "[yellow]⚠ credential_source=bitwarden but " - "secrets.bitwarden is disabled or missing — falling back " - "to host-env secrets (allow_env_fallback=true). Rotated " - "Bitwarden keys will NOT propagate.[/yellow]" + "[yellow]⚠ credential_source=bitwarden but secrets.bitwarden is disabled or missing — falling back " + "to host-env secrets (allow_env_fallback=true). Rotated Bitwarden keys will NOT propagate.[/yellow]" ) else: - console.print( - "[red]✗ Refusing to start: proxy.credential_source is " - "'bitwarden' but secrets.bitwarden is disabled or " - "missing.[/red]" - ) - console.print( - " Re-enable it (`secrets.bitwarden.enabled: true`), switch " + return _refuse( + console, + "proxy.credential_source is 'bitwarden' but secrets.bitwarden is disabled or missing.", + "Re-enable it (`secrets.bitwarden.enabled: true`), switch " "back to env credentials with `hermes egress setup " - "--no-bitwarden`, or set `proxy.allow_env_fallback: true` " - "to opt into the host-env fallback." + "--no-bitwarden`, or set `proxy.allow_env_fallback: true` to opt into the host-env fallback.", ) - return 1 - # Pass the proxy-side allow_env_fallback opt-in through to - # start_proxy. This is a deliberate, documented escape hatch: when - # set, the daemon silently falls back to host env if BWS is - # unreachable, instead of raising. Default is strict (raise). + # Pass the allow_env_fallback opt-in through to start_proxy: when set the daemon falls back + # to host env if BWS is unreachable instead of raising. Default is strict (raise). if refresh_bw and bw_cfg is not None: bw_cfg = dict(bw_cfg) - bw_cfg["allow_env_fallback"] = bool( - proxy_cfg.get("allow_env_fallback", False) - ) - - # fail_on_uncovered_providers is intentionally gone: the LLM-specific - # providers it guarded (Anthropic native, Azure OpenAI, Gemini) are now - # swapped via per-provider match_headers rules, so the fail-closed tier - # is empty and the flag would be a dead toggle. - - # stephenschoettler #1: when `credential_source: bitwarden`, the - # operator picked BWS specifically to get the rotation guarantee — - # silently falling back to parent-env at start_proxy time reintroduces - # exactly the bug class the BW mode is supposed to defeat (host env - # is stale / mismatched). Pre-check at the wizard layer so we fail - # loud with actionable error messages BEFORE start_proxy degrades. + bw_cfg["allow_env_fallback"] = bool(proxy_cfg.get("allow_env_fallback", False)) + # (fail_on_uncovered_providers was removed: the fail-closed provider tier is now empty.) + # Pre-check the BWS token + project here so bitwarden mode fails loud with actionable + # messages BEFORE start_proxy could silently degrade to a stale/mismatched host env. if refresh_bw: bw_access_env = (bw_cfg or {}).get("access_token_env", "BWS_ACCESS_TOKEN") if not os.environ.get(bw_access_env, "").strip(): - console.print( - f"[red]✗ Refusing to start: credential_source=bitwarden but " - f"{bw_access_env} is not set in the environment.[/red]" + return _refuse( + console, + f"credential_source=bitwarden but {bw_access_env} is not set in the environment.", + "Either export the access token, or run " + "`hermes egress setup --no-bitwarden` to switch back to env-based credentials.", ) - console.print( - " Either export the access token, or run " - "`hermes egress setup --no-bitwarden` to switch back to " - "env-based credentials." - ) - return 1 if not (bw_cfg or {}).get("project_id"): - console.print( - "[red]✗ Refusing to start: credential_source=bitwarden but " - "secrets.bitwarden.project_id is empty.[/red]" + return _refuse( + console, + "credential_source=bitwarden but secrets.bitwarden.project_id is empty.", + "Run `hermes secrets bitwarden setup` to configure the " + "project, or switch back via `hermes egress setup --no-bitwarden`.", ) - console.print( - " Run `hermes secrets bitwarden setup` to configure the " - "project, or switch back via `hermes egress setup " - "--no-bitwarden`." - ) - return 1 - try: status = ip.start_proxy( install_if_missing=bool(proxy_cfg.get("auto_install", True)), @@ -543,19 +394,13 @@ def cmd_start(args: argparse.Namespace) -> int: except Exception as exc: # noqa: BLE001 — top-level user-facing funnel console.print(f"[red]✗ failed to start iron-proxy:[/red] {exc}") return 1 - if status.pid: - listening = ( - "[green]listening[/green]" - if status.listening - else "[yellow]not yet listening[/yellow]" - ) - console.print( - f"[green]✓[/green] iron-proxy running pid={status.pid} " - f"port={status.tunnel_port} {listening}" - ) - else: + if not status.pid: console.print("[red]✗ iron-proxy did not come up cleanly[/red]") return 1 + listening = ("[green]listening[/green]" if status.listening else "[yellow]not yet listening[/yellow]") + console.print( + f"[green]✓[/green] iron-proxy running pid={status.pid} port={status.tunnel_port} {listening}" + ) return 0 @@ -569,26 +414,18 @@ def cmd_stop(args: argparse.Namespace) -> int: def cmd_restart(args: argparse.Namespace) -> int: - """Stop the running daemon (if any) and start it with the current config. - - The one-command way to apply config changes (new allowlist hosts, rotated tokens, a - Bitwarden key rotation). Delegates to ``cmd_start`` so all credential-source guards run - exactly as for ``start``. - """ + """Stop the daemon (if any) then delegate to ``cmd_start`` so every credential-source guard runs.""" console = Console() - was_running = ip.stop_proxy() - if was_running: + if ip.stop_proxy(): console.print("[dim]stopped the running iron-proxy[/dim]") return cmd_start(args) def cmd_reload(args: argparse.Namespace) -> int: - """Hot-reload the running daemon's ruleset via the management API. + """Hot-reload the ruleset via the management API (no restart, no dropped connections). - Applies allowlist/token/mapping changes already written to proxy.yaml WITHOUT restarting — - no dropped connections. For new upstream SECRETS (a Bitwarden rotation, a new provider key) - use ``hermes egress restart`` instead: the daemon reads credentials from its own environment - at spawn time and a reload does not re-populate that env. + New upstream SECRETS still need ``hermes egress restart``: the daemon reads credentials from + its own environment at spawn time and a reload does not re-populate that env. """ console = Console() try: @@ -596,10 +433,7 @@ def cmd_reload(args: argparse.Namespace) -> int: except Exception as exc: # noqa: BLE001 — top-level user-facing funnel console.print(f"[red]✗ reload failed:[/red] {exc}") return 1 - console.print( - "[green]✓[/green] iron-proxy ruleset reloaded in-place " - "(no restart, connections preserved)" - ) + console.print("[green]✓[/green] iron-proxy ruleset reloaded in-place (no restart, connections preserved)") console.print( "[dim]Note: new upstream secrets (rotated keys, new providers) " "still need `hermes egress restart` — the daemon reads real " @@ -613,37 +447,27 @@ def format_status_text(*, show_tokens: bool = False) -> str: cfg = load_config() proxy_cfg = cfg.get("proxy") or {} status = ip.get_status() - lines = ["Egress proxy status", ""] lines.extend( f"{label}: {value}" - for label, value in _status_rows( - proxy_cfg, status, yn=lambda v: "yes" if v else "no", dim=lambda t: t - ) + for label, value in _status_rows(proxy_cfg, status, yn=lambda v: "yes" if v else "no", dim=lambda t: t) ) lines.append("Scope: Docker backend only in this release") - mappings = ip.load_mappings() if mappings: lines.extend(["", "Token mappings:"]) for m in mappings: tok = m.proxy_token if show_tokens else _redact_token(m.proxy_token) lines.append(f" - {m.real_env_name}: {tok} ({', '.join(m.upstream_hosts)})") - uncovered = ip.discover_uncovered_providers() if uncovered: - lines.extend([ - "", - "Uncovered providers (real credentials still visible inside the sandbox):", - ]) + lines.extend(["", "Uncovered providers (real credentials still visible inside the sandbox):"]) for name in uncovered: lines.append(f" - {name}") - if bool(proxy_cfg.get("enabled")) and not status.configured: lines.extend(["", "Next: run `hermes egress setup` to mint tokens and write proxy.yaml."]) elif bool(proxy_cfg.get("enabled")) and not (status.pid and status.listening): lines.extend(["", "Next: run `hermes egress start` before launching Docker sandboxes."]) - return "\n".join(lines) @@ -652,45 +476,29 @@ def cmd_status(args: argparse.Namespace) -> int: cfg = load_config() proxy_cfg = cfg.get("proxy") or {} status = ip.get_status() - table = Table(show_header=False, box=None, padding=(0, 2)) table.add_column("", style="bold") table.add_column("") for label, value in _status_rows(proxy_cfg, status, yn=_yn, dim=lambda t: f"[dim]{t}[/dim]"): table.add_row(label, value) console.print(table) - mappings = ip.load_mappings() if mappings: console.print() console.print("[bold]Token mappings[/bold]") - m_table = Table(show_header=True, header_style="bold") - m_table.add_column("Real env", style="cyan") - m_table.add_column("Upstream", style="dim") - m_table.add_column("Proxy token", style="green") - for m in mappings: - tok = m.proxy_token if args.show_tokens else _redact_token(m.proxy_token) - m_table.add_row(m.real_env_name, ", ".join(m.upstream_hosts), tok) - console.print(m_table) + console.print(_mappings_table(mappings, "Real env", "Upstream", show_tokens=args.show_tokens)) if args.show_tokens: console.print( "[yellow]⚠[/yellow] proxy tokens just printed in full — " - "they may persist in your shell history. Consider clearing " - "it after this command." + "they may persist in your shell history. Consider clearing it after this command." ) - - # Surface uncovered providers so the operator knows the isolation - # boundary is incomplete for those upstreams. + # Uncovered providers = the isolation boundary is incomplete for those upstreams. uncovered = ip.discover_uncovered_providers() if uncovered: console.print() - console.print( - "[yellow]Uncovered providers[/yellow] " - "(real credentials still visible inside the sandbox):" - ) + console.print("[yellow]Uncovered providers[/yellow] (real credentials still visible inside the sandbox):") for name in uncovered: console.print(f" - {name}") - return 0 @@ -704,15 +512,11 @@ def cmd_disable(args: argparse.Namespace) -> int: proxy_cfg["enabled"] = False save_config(cfg) console.print("[green]✓[/green] proxy.enabled set to false") - # Use the public get_status() pid (which already incorporates the - # _pid_alive check) instead of reaching into ip._read_pid(). That - # private accessor only proves the pidfile is non-empty — a stale - # pidfile from a crashed previous run would fire the warning - # spuriously. + # get_status().pid already applies the liveness check; ip._read_pid() alone would fire + # spuriously on a stale pidfile from a crashed run. if ip.get_status().pid is not None: console.print( - " iron-proxy is still running — stop it with " - "[cyan]hermes egress stop[/cyan] if you want it down too." + " iron-proxy is still running — stop it with [cyan]hermes egress stop[/cyan] if you want it down too." ) return 0 @@ -721,19 +525,12 @@ def cmd_config(args: argparse.Namespace) -> int: console = Console() status = ip.get_status() if status.config_path is None: - console.print( - "[yellow](no config generated — run `hermes egress setup`)[/yellow]" - ) + console.print("[yellow](no config generated — run `hermes egress setup`)[/yellow]") return 1 console.print(str(status.config_path)) return 0 -# --------------------------------------------------------------------------- -# Helpers -# --------------------------------------------------------------------------- - - def _bitwarden_env_names(console: Console) -> Optional[List[str]]: """Secret names from Bitwarden for ``setup --from-bitwarden``; prints the error and returns ``None`` on any failure so the wizard aborts loudly instead of falling back to the host env. @@ -741,70 +538,40 @@ def _bitwarden_env_names(console: Console) -> Optional[List[str]]: cfg = load_config() bw_cfg = (cfg.get("secrets") or {}).get("bitwarden") or {} if not bw_cfg.get("enabled"): - console.print( - " [red]✗ --from-bitwarden requested but " - "secrets.bitwarden.enabled is false.[/red]" - ) - console.print( - " Run `hermes secrets bitwarden setup` first, or omit " - "--from-bitwarden." - ) + console.print(" [red]✗ --from-bitwarden requested but secrets.bitwarden.enabled is false.[/red]") + console.print(" Run `hermes secrets bitwarden setup` first, or omit --from-bitwarden.") return None try: from agent.secret_sources import bitwarden as bw - access_token = os.environ.get( - bw_cfg.get("access_token_env", "BWS_ACCESS_TOKEN"), "" - ).strip() + token_env = bw_cfg.get("access_token_env", "BWS_ACCESS_TOKEN") + access_token = os.environ.get(token_env, "").strip() if not access_token: - console.print( - f" [red]✗ --from-bitwarden requested but " - f"{bw_cfg.get('access_token_env', 'BWS_ACCESS_TOKEN')} " - "is not set in the environment.[/red]" - ) + console.print(f" [red]✗ --from-bitwarden requested but {token_env} is not set in the environment.[/red]") return None secrets, _ = bw.fetch_bitwarden_secrets( - access_token=access_token, - project_id=bw_cfg.get("project_id", ""), - cache_ttl_seconds=0, - use_cache=False, + access_token=access_token, project_id=bw_cfg.get("project_id", ""), cache_ttl_seconds=0, use_cache=False ) names = list(secrets.keys()) if not names: console.print( " [red]✗ Bitwarden returned an empty secrets list.[/red]\n" - " Check the project_id in secrets.bitwarden and the " - "BWS access-token's project scope." + " Check the project_id in secrets.bitwarden and the BWS access-token's project scope." ) return None console.print(f" Pulled {len(names)} env names from Bitwarden.") return names except Exception as exc: # noqa: BLE001 — explicit user-facing error - console.print( - f" [red]✗ Could not enumerate Bitwarden secrets: {exc}[/red]" - ) + console.print(f" [red]✗ Could not enumerate Bitwarden secrets: {exc}[/red]") console.print( " Either fix the Bitwarden config and retry, or rerun setup " - "without --from-bitwarden (the proxy will read secrets from " - "the host process env at start time)." + "without --from-bitwarden (the proxy will read secrets from the host process env at start time)." ) return None def _load_env_file_into_environ() -> int: - """Backfill provider keys from ``~/.hermes/.env`` into ``os.environ``. - - ``hermes egress setup`` discovers providers by reading ``os.environ``, but many operators keep - their keys ONLY in ``~/.hermes/.env`` (which the agent loads at runtime but which is NOT - exported into an interactive shell). - - Only fills names that aren't already set in the process env (an exported value always wins), and - only for known bearer-provider names so we don't slurp unrelated secrets into the process. - Returns the count of names added. - """ - try: - from hermes_cli.config import load_env - except ImportError: - return 0 + """Backfill known provider keys from ``~/.hermes/.env`` into ``os.environ``; returns the count. + Never overrides an exported value; only known provider names, so unrelated secrets stay out.""" try: file_env = load_env() except Exception: # noqa: BLE001 — best-effort convenience, never fatal @@ -821,6 +588,29 @@ def _load_env_file_into_environ() -> int: return added +def _mappings_table(mappings, env_header: str, hosts_header: str, *, show_tokens: bool) -> Table: + table = Table(show_header=True, header_style="bold") + table.add_column(env_header, style="cyan") + table.add_column(hosts_header, style="dim") + table.add_column("Proxy token", style="green") + for m in mappings: + tok = m.proxy_token if show_tokens else _redact_token(m.proxy_token) + table.add_row(m.real_env_name, ", ".join(m.upstream_hosts), tok) + return table + + +def _step(console: Console, n: int, title: str) -> None: + console.print() + console.print(f"[bold]Step {n}[/bold] {title}") + + +def _refuse(console: Console, reason: str, hint: str) -> int: + """Print a ``✗ Refusing to start`` headline plus an indented remedy; returns exit code 1.""" + console.print(f"[red]✗ Refusing to start: {reason}[/red]") + console.print(f" {hint}") + return 1 + + def _yn(value: bool) -> str: return "[green]yes[/green]" if value else "[dim]no[/dim]" @@ -834,10 +624,8 @@ def _prompt(text: str) -> str: def _status_rows(proxy_cfg: dict, status, *, yn, dim) -> list[tuple[str, str]]: - """``(label, value)`` pairs shared by the rich ``status`` table and the plain-text variant. - - ``yn`` renders booleans; ``dim`` wraps placeholder text for missing values. - """ + """``(label, value)`` rows shared by the rich ``status`` table and the plain-text variant; + ``yn`` renders booleans, ``dim`` wraps placeholder text for missing values.""" return [ ("Enabled", yn(bool(proxy_cfg.get("enabled")))), ("Binary", str(status.binary_path or dim("(missing)"))), diff --git a/hermes_cli/psutil_android.py b/hermes_cli/psutil_android.py index f320da4b34..274d4693de 100644 --- a/hermes_cli/psutil_android.py +++ b/hermes_cli/psutil_android.py @@ -6,12 +6,10 @@ import shutil import tarfile from pathlib import Path, PurePosixPath -# Pin a version we know patches cleanly. Update when a newer psutil -# changes the marker line shape and we need to follow upstream. +# Pinned to a version whose marker line patches cleanly; bump when upstream changes its shape. PSUTIL_URL = ( "https://files.pythonhosted.org/packages/aa/c6/" - "d1ddf4abb55e93cebc4f2ed8b5d6dbad109ecb8d63748dd2b20ab5e57ebe/" - "psutil-7.2.2.tar.gz" + "d1ddf4abb55e93cebc4f2ed8b5d6dbad109ecb8d63748dd2b20ab5e57ebe/psutil-7.2.2.tar.gz" ) MARKER = 'LINUX = sys.platform.startswith("linux")' @@ -26,9 +24,7 @@ def _normalize_member_parts(member_name: str) -> tuple[str, ...]: path = PurePosixPath(member_name) parts = tuple(part for part in path.parts if part not in ("", ".")) if path.is_absolute() or ".." in parts or not parts: - raise PsutilAndroidInstallError( - f"Unsafe archive member path: {member_name!r}" - ) + raise PsutilAndroidInstallError(f"Unsafe archive member path: {member_name!r}") return parts @@ -38,26 +34,17 @@ def _safe_extract_tar_gz(archive: Path, destination: Path) -> None: for member in tf.getmembers(): parts = _normalize_member_parts(member.name) target = destination.joinpath(*parts) - if member.isdir(): target.mkdir(parents=True, exist_ok=True) continue - if not member.isfile(): - raise PsutilAndroidInstallError( - f"Unsupported archive member type: {member.name}" - ) - + raise PsutilAndroidInstallError(f"Unsupported archive member type: {member.name}") target.parent.mkdir(parents=True, exist_ok=True) extracted = tf.extractfile(member) if extracted is None: - raise PsutilAndroidInstallError( - f"Cannot read archive member: {member.name}" - ) - + raise PsutilAndroidInstallError(f"Cannot read archive member: {member.name}") with extracted, open(target, "wb") as dst: shutil.copyfileobj(extracted, dst) - try: target.chmod(member.mode & 0o777) except OSError: @@ -67,39 +54,22 @@ def _safe_extract_tar_gz(archive: Path, destination: Path) -> None: def prepare_patched_psutil_sdist(archive: Path, destination: Path) -> Path: """Safely extract the pinned psutil sdist and patch it for Android.""" _safe_extract_tar_gz(archive, destination) - - src_roots = [ - path for path in destination.iterdir() - if path.is_dir() and path.name.startswith("psutil-") - ] + src_roots = [path for path in destination.iterdir() if path.is_dir() and path.name.startswith("psutil-")] if not src_roots: - raise PsutilAndroidInstallError( - "psutil sdist did not contain a psutil-* directory" - ) - + raise PsutilAndroidInstallError("psutil sdist did not contain a psutil-* directory") src_root = min(src_roots, key=lambda path: path.name) common_py = src_root / "psutil" / "_common.py" + rel = common_py.relative_to(src_root) if not common_py.is_file(): - raise PsutilAndroidInstallError( - f"psutil sdist did not contain {common_py.relative_to(src_root)!s}" - ) + raise PsutilAndroidInstallError(f"psutil sdist did not contain {rel!s}") try: content = common_py.read_text(encoding="utf-8") except OSError as exc: - raise PsutilAndroidInstallError( - f"Failed to read {common_py.relative_to(src_root)!s}" - ) from exc + raise PsutilAndroidInstallError(f"Failed to read {rel!s}") from exc if MARKER not in content: - raise PsutilAndroidInstallError( - "psutil Android compatibility patch marker not found" - ) + raise PsutilAndroidInstallError("psutil Android compatibility patch marker not found") try: - common_py.write_text( - content.replace(MARKER, REPLACEMENT), - encoding="utf-8", - ) + common_py.write_text(content.replace(MARKER, REPLACEMENT), encoding="utf-8") except OSError as exc: - raise PsutilAndroidInstallError( - f"Failed to write {common_py.relative_to(src_root)!s}" - ) from exc + raise PsutilAndroidInstallError(f"Failed to write {rel!s}") from exc return src_root diff --git a/hermes_cli/pt_input_extras.py b/hermes_cli/pt_input_extras.py index b33de89b86..120e28fa1b 100644 --- a/hermes_cli/pt_input_extras.py +++ b/hermes_cli/pt_input_extras.py @@ -2,23 +2,21 @@ from __future__ import annotations -# kitty CSI-u ORs lock-key state into the modifier parameter of every key -# event while a lock is on: CapsLock=64, NumLock=128, both=192 (#88221, -# #89651). Every fixed-modifier CSI-u (and legacy CSI-tilde / CSI-letter) -# registration therefore needs lock-offset twins, or those events leak into -# the prompt as literal text. The xterm modifyOtherKeys ``ESC[27;N;CP~`` -# encoding never carries lock bits, so it never gets the twins. +# kitty CSI-u ORs lock-key state into the modifier parameter of every key event while a lock is +# on: CapsLock=64, NumLock=128, both=192. Every fixed-modifier CSI-u (and legacy CSI-tilde / +# CSI-letter) registration therefore needs lock-offset twins, or those events leak into the prompt +# as literal text. The xterm modifyOtherKeys ``ESC[27;N;CP~`` encoding never carries lock bits. _LOCK_BIT_OFFSETS = (0, 64, 128, 192) def _lock_variants(modifier: int) -> tuple[int, ...]: - """Return ``modifier`` plus its CapsLock/NumLock/both twins.""" + """``modifier`` plus its CapsLock/NumLock/both twins.""" return tuple(modifier + off for off in _LOCK_BIT_OFFSETS) def _lock_twins(modifier: int) -> tuple[int, ...]: - """Return only the lock twins of ``modifier`` (never the base value).""" - return tuple(modifier + off for off in _LOCK_BIT_OFFSETS[1:]) + """Only the lock twins of ``modifier`` (never the base value).""" + return _lock_variants(modifier)[1:] def _clear_vt100_prefix_cache() -> None: @@ -28,35 +26,28 @@ def _clear_vt100_prefix_cache() -> None: would keep stale ``False`` answers and misparse newly registered sequences. """ try: - from prompt_toolkit.input.vt100_parser import ( - _IS_PREFIX_OF_LONGER_MATCH_CACHE, - ) + from prompt_toolkit.input.vt100_parser import _IS_PREFIX_OF_LONGER_MATCH_CACHE _IS_PREFIX_OF_LONGER_MATCH_CACHE.clear() except Exception: pass -def _pt_tables(): - """Return ``(ANSI_SEQUENCES, Keys)`` or ``None`` when prompt_toolkit is unavailable.""" +def _install(build, *, overwrite: bool) -> int: + """Install ``build(ANSI_SEQUENCES, Keys) -> {seq: key}`` into prompt_toolkit's table; return + the number of entries changed (0 when prompt_toolkit is unavailable). + + ``overwrite=True`` replaces differing entries; ``overwrite=False`` behaves like ``setdefault`` + so existing/user registrations win. Clears the VT100 prefix cache when anything changed. + """ try: from prompt_toolkit.input.ansi_escape_sequences import ANSI_SEQUENCES from prompt_toolkit.keys import Keys except Exception: - return None - return ANSI_SEQUENCES, Keys - - -def _register(table: dict, aliases: dict, *, overwrite: bool) -> int: - """Install ``aliases`` into ``table``; return the number of entries changed. - - ``overwrite=True`` replaces differing entries; ``overwrite=False`` behaves like ``setdefault`` - so existing/user registrations win. Clears the VT100 prefix cache when anything changed, since - new longer sequences can flip "is this a prefix of a longer match?" answers the parser cached. - """ + return 0 changed = 0 - for seq, key in aliases.items(): - if (table.get(seq) != key) if overwrite else (seq not in table): - table[seq] = key + for seq, key in build(ANSI_SEQUENCES, Keys).items(): + if (ANSI_SEQUENCES.get(seq) != key) if overwrite else (seq not in ANSI_SEQUENCES): + ANSI_SEQUENCES[seq] = key changed += 1 if changed: _clear_vt100_prefix_cache() @@ -65,8 +56,7 @@ def _register(table: dict, aliases: dict, *, overwrite: bool) -> int: def install_keypress_data_normalization() -> int: """Normalize KeyPress data for extended-key aliases that map to a single plain character - (Shift+Space → ``' '``, Shift+letter → the uppercase letter, keypad digits → ``'0'``..``'9'``, - keypad operators). + (Shift+Space → ``' '``, Shift+letter → uppercase, keypad digits/operators). """ try: import prompt_toolkit.input.vt100_parser as _vt100_mod @@ -74,24 +64,15 @@ def install_keypress_data_normalization() -> int: except Exception: return 0 - if getattr( - _vt100_mod.Vt100Parser._call_handler, "_hermes_char_data_normalized", False - ): + _orig_call_handler = _vt100_mod.Vt100Parser._call_handler + if getattr(_orig_call_handler, "_hermes_char_data_normalized", False): return 0 - _orig_call_handler = _vt100_mod.Vt100Parser._call_handler - def _patched_call_handler(self, key, insert_text): - # A single plain character (not a Keys member, not a tuple) mapped - # from an extended sequence must carry the mapped character as its - # data — self-insert inserts event.data and the raw CSI would leak. - if ( - isinstance(key, str) - and len(key) == 1 - and not isinstance(key, _PtKeys) - and isinstance(insert_text, str) - and insert_text.startswith("\x1b") - ): + # A single plain character mapped from an extended sequence must carry the mapped + # character as its data — self-insert inserts event.data and the raw CSI would leak. + if (isinstance(key, str) and len(key) == 1 and not isinstance(key, _PtKeys) + and isinstance(insert_text, str) and insert_text.startswith("\x1b")): insert_text = key return _orig_call_handler(self, key, insert_text) @@ -101,60 +82,68 @@ def install_keypress_data_normalization() -> int: def _install_enter_alias(modifier: int) -> int: - """Map +Enter (Kitty CSI-u ``ESC[13;u`` plus lock-bit twins, xterm - ``ESC[27;;13~`` / ``;13u``) to (Escape, ControlM) so the Alt+Enter newline handler fires. + """Map +Enter (Kitty CSI-u ``ESC[13;u`` plus lock twins, xterm ``ESC[27;;13~`` + / ``;13u``) to (Escape, ControlM) so the Alt+Enter newline handler fires. Stock prompt_toolkit maps the tilde form to plain ControlM (i.e. Shift+Enter == Enter, the very - bug this fixes), so those keys are overwritten unconditionally; other modifier variants are - untouched. + bug this fixes), so these keys are overwritten unconditionally. """ - tables = _pt_tables() - if tables is None: - return 0 - seqs, keys = tables - alt_enter = (keys.Escape, keys.ControlM) - aliases = {f"\x1b[13;{m}u": alt_enter for m in _lock_variants(modifier)} - aliases[f"\x1b[27;{modifier};13~"] = alt_enter - aliases[f"\x1b[27;{modifier};13u"] = alt_enter - return _register(seqs, aliases, overwrite=True) + def build(_seqs, keys): + alt_enter = (keys.Escape, keys.ControlM) + seqs = [f"\x1b[13;{m}u" for m in _lock_variants(modifier)] + [f"\x1b[27;{modifier};13~", f"\x1b[27;{modifier};13u"] + return dict.fromkeys(seqs, alt_enter) + + return _install(build, overwrite=True) def install_shift_enter_alias() -> int: - """Map Shift+Enter sequences to (Escape, ControlM) so the Alt+Enter newline handler fires. - - macOS Terminal and stock Windows Terminal send the same byte for Enter and Shift+Enter, so - nothing can be done for them here. + """Map Shift+Enter to (Escape, ControlM). macOS Terminal and stock Windows Terminal send the + same byte for Enter and Shift+Enter, so nothing can be done for them here. """ return _install_enter_alias(2) def install_ctrl_enter_alias() -> int: - """Map Ctrl+Enter sequences to (Escape, ControlM) so the Alt+Enter newline handler fires. - - Without the alias, Kitty/mintty/xterm users over SSH get a raw CSI sequence inserted as text. - """ + """Map Ctrl+Enter to (Escape, ControlM); otherwise Kitty/mintty/xterm over SSH insert raw CSI.""" return _install_enter_alias(5) def install_cmd_backspace_alias() -> int: - """Map Cmd+Backspace / Cmd+ForwardDelete to prompt_toolkit's readline kill bindings. + """Map Cmd+Backspace -> ControlU and Cmd+ForwardDelete -> ControlK. - Terminals that rewrite Cmd+Backspace to Ctrl+U already work; Kitty/modifyOtherKeys report Cmd - as the super bit (8), yielding unmapped sequences that insert literally. Cmd+Backspace -> - ControlU (``ESC[127;9u``, ``;10u``, ``ESC[27;9;127~``); Cmd+ForwardDelete -> ControlK via - the CSI tilde form ``ESC[3;9~`` / ``;10~`` since forward-delete is not a CSI-u codepoint. + Kitty/modifyOtherKeys report Cmd as the super bit (8), yielding unmapped sequences that insert + literally. Forward-delete is not a CSI-u codepoint, so it uses the CSI tilde form ``ESC[3;9~``. """ - tables = _pt_tables() - if tables is None: - return 0 - seqs, keys = tables - aliases: dict[str, object] = {} - for base in (9, 10): # super / super+shift - for mod in _lock_variants(base): - aliases[f"\x1b[127;{mod}u"] = keys.ControlU - aliases[f"\x1b[3;{mod}~"] = keys.ControlK - aliases["\x1b[27;9;127~"] = keys.ControlU - return _register(seqs, aliases, overwrite=True) + def build(_seqs, keys): + mods = [mod for base in (9, 10) for mod in _lock_variants(base)] # super / super+shift + aliases = {f"\x1b[127;{mod}u": keys.ControlU for mod in mods} + aliases.update({f"\x1b[3;{mod}~": keys.ControlK for mod in mods}) + aliases["\x1b[27;9;127~"] = keys.ControlU + return aliases + + return _install(build, overwrite=True) + + +# Kitty functional keys (Private Use Area codepoints) that have prompt_toolkit equivalents. +# kitty emits these CSI-u encodings even in LEGACY mode, so unmapped they leak as literal text. +_KITTY_FUNCTIONAL_NAMED = { + 57409: ".", 57410: "/", 57411: "*", 57412: "-", 57413: "+", 57414: "ControlM", # KP ops + 57415: "=", 57416: ",", + 57417: "Left", 57418: "Right", 57419: "Up", 57420: "Down", 57421: "PageUp", # KP nav + 57422: "PageDown", 57423: "Home", 57424: "End", 57425: "Insert", 57426: "Delete", +} +# No prompt_toolkit equivalent: locks/PrintScreen/Pause/Menu, F25-F35, KP_BEGIN, media keys and +# bare modifier events — consumed as Ignore instead of leaking literal text. +_KITTY_FUNCTIONAL_IGNORED = (*range(57358, 57364), *range(57388, 57399), 57427, *range(57428, 57455)) + + +def _kitty_functional_map(Keys) -> dict[int, object]: + fm: dict[int, object] = {57399 + d: str(d) for d in range(10)} # KP_0..KP_9 + fm.update({cp: getattr(Keys, v) if v[0].isupper() else v for cp, v in _KITTY_FUNCTIONAL_NAMED.items()}) + fm.update({57376 + (n - 13): getattr(Keys, f"F{n}") for n in range(13, 25)}) # F13..F24 + for code in _KITTY_FUNCTIONAL_IGNORED: + fm.setdefault(code, Keys.Ignore) + return fm def install_modify_other_keys_aliases() -> int: @@ -162,246 +151,94 @@ def install_modify_other_keys_aliases() -> int: Once ``modifyOtherKeys=2`` is pushed (to distinguish Shift+Enter) the terminal re-encodes EVERY Ctrl combo as ``ESC[27;5;~``; stock prompt_toolkit maps only Ctrl+Enter, so - Ctrl+A/C/D/E/K/R/U/W/Z leak as text. Installs Ctrl/Alt/Shift letters, digits, symbols, - multi-modifier combos, CapsLock/NumLock lock-bit variants, CSI-u Esc, modified - Enter/Tab/Backspace/Space, and Kitty functional keys. Uses ``setdefault`` so existing - mappings (incl. the Shift/Ctrl+Enter aliases) are never overwritten. + Ctrl+A/C/D/... leak as text. Installs Ctrl/Alt/Shift letters, digits, symbols, multi-modifier + combos, lock-bit variants, CSI-u Esc, modified Enter/Tab/Backspace/Space and Kitty functional + keys. ``setdefault`` semantics: existing mappings (incl. the Shift/Ctrl+Enter aliases) win. """ - tables = _pt_tables() - if tables is None: - return 0 - ANSI_SEQUENCES, Keys = tables + return _install(_modify_other_keys_aliases, overwrite=False) - # Everything below is collected into ``aliases`` (first writer wins, matching setdefault - # order) and installed once at the end. + +def _modify_other_keys_aliases(ANSI_SEQUENCES: dict, Keys) -> dict[str, object]: + # Collected first-writer-wins (matching setdefault order), installed once at the end. aliases: dict[str, object] = {} + _put = aliases.setdefault - def _put(seq: str, key_val: object) -> None: - aliases.setdefault(seq, key_val) - - # -- Ctrl+letter / Ctrl+digit / Ctrl+symbol → Keys.Control* ---- - # codepoint -> Keys value. The raw control byte for Ctrl+ is - # chr(ord(ch) & 0x1f) (i.e. ord(ch) - 96 for lowercase). We map the - # *extended* sequence to the same Keys value that the raw byte maps to, - # so prompt_toolkit's existing key bindings fire identically. - ctrl_key_map: dict[int, object] = {} - - # a-z: Ctrl+A = \x01 = Keys.ControlA, ..., Ctrl+Z = \x1a = Keys.ControlZ - # Symbols that produce control chars: - # Ctrl+@ (64) = \x00 = Keys.ControlAt - # Ctrl+[ (91) = \x1b = Keys.Escape - # Ctrl+\ (92) = \x1c = Keys.ControlBackslash - # Ctrl+] (93) = \x1d = Keys.ControlSquareClose - # Ctrl+^ (94) = \x1e = Keys.ControlCircumflex - # Ctrl+_ (95) = \x1f = Keys.ControlUnderscore - # Ctrl+Space(32) = \x00 = Keys.ControlAt (prompt_toolkit maps \x00 → ControlAt) - letters = range(ord('a'), ord('z') + 1) - for codepoint in (*letters, 64, 91, 92, 93, 94, 95, 32): - existing = ANSI_SEQUENCES.get(chr(codepoint & 0x1F)) - if existing is not None: - ctrl_key_map[codepoint] = existing - - # 0-9: Ctrl+digit codepoints don't have a useful raw-byte mapping - # (e.g. chr(ord('0') & 0x1F) = 0x10 = ControlP, not Control0), so map - # them directly to Keys.Control0..Keys.Control9. - for d in range(10): - ctrl_key_map[ord('0') + d] = getattr(Keys, f"Control{d}") - - # Kitty CSI-u encodes CapsLock/NumLock state as extra modifier bits - # (caps=64, num=128) ORed into the parameter: with NumLock on, Ctrl+C - # arrives as ESC[99;133u (5 + 128) instead of ESC[99;5u. Terminals - # that report these bits (kitty, ghostty) break every key combo while - # a lock is on (#89651) unless the lock variants are mapped too. The - # xterm modifyOtherKeys encoding never carries the lock bits, so only - # the CSI-u form needs them. def _install_paired(modifier: int, mapping: dict) -> None: - """Install both modifyOtherKeys (ESC[27;N;CP~) and CSI-u (ESC[CP;Nu) mappings for the given - modifier and codepoint→key mapping. - """ + """Both modifyOtherKeys (ESC[27;N;CP~, never for mod 1) and CSI-u (ESC[CP;Nu + lock twins).""" for codepoint, key_val in mapping.items(): if modifier != 1: _put(f"\x1b[27;{modifier};{codepoint}~", key_val) for mod in _lock_variants(modifier): _put(f"\x1b[{codepoint};{mod}u", key_val) - # Ctrl+letter / Ctrl+digit / Ctrl+symbol (modifier 5) + # Ctrl+: the extended sequence maps to whatever Keys value the raw control byte + # chr(ord(ch) & 0x1f) already maps to, so existing bindings fire identically. Covers a-z and + # the control-producing symbols @ [ \ ] ^ _ and Space (\x00 -> ControlAt). + letters = range(ord('a'), ord('z') + 1) + ctrl_key_map: dict[int, object] = { + cp: key for cp in (*letters, 64, 91, 92, 93, 94, 95, 32) + if (key := ANSI_SEQUENCES.get(chr(cp & 0x1F))) is not None + } + # Ctrl+digit has no useful raw byte (chr(ord('0') & 0x1F) is ControlP), so map directly. + ctrl_key_map.update({ord('0') + d: getattr(Keys, f"Control{d}") for d in range(10)}) _install_paired(5, ctrl_key_map) - # -- Alt+letter → (Escape, ) ---- - # Under modifyOtherKeys, Alt+a = ESC[27;3;97~. Without mapping, this - # leaks as literal text. prompt_toolkit handles bare Alt+letter as - # (Escape, ), so we map the extended sequences to the same tuple. - # - # -- Shift+letter → uppercase letter ---- - # Under modifyOtherKeys=2, some terminals re-encode Shift+a as - # ESC[27;2;97~. Without mapping, this leaks as literal escape + - # "[27;2;97~" in the prompt buffer — the "caps locked" / "every key - # combo is broken" symptom (#87711). - # Map Shift+letter to the uppercase character so typing works normally. - # This is safe across all Latin keyboard layouts: Shift always uppercases - # letters. Shift+digit symbols are layout-specific (US: '!', AZERTY: '¹', - # etc.) so they are NOT mapped here — if the terminal sends those under - # modifyOtherKeys, they will leak, but that's better than wrong input. - # Map both the lowercase and uppercase codepoints — some terminals send - # the already-shifted codepoint (65 for 'A') with modifier=2. - # - # -- Multi-modifier letters: Shift+Alt (4), Ctrl+Shift (6), - # Ctrl+Alt (7), Ctrl+Alt+Shift (8) ---- - # The Kitty protocol always reports the UNSHIFTED codepoint; some - # modifyOtherKeys emitters send the shifted one — map both cases. - # Ctrl-bearing combos normalize onto the Ctrl key (Alt adds an Escape - # prefix), Shift+Alt onto (Escape, UPPER) — the same normalization - # dte/kakoune apply to these protocols. Without these, Ctrl+Shift+R - # etc. leak as literal text under either protocol. - alt_map: dict[int, tuple] = {} - shift_map: dict[int, str] = {} - shift_alt_map: dict[int, tuple] = {} - ctrl_shift_map: dict[int, object] = {} - ctrl_alt_map: dict[int, tuple] = {} + # Letter combos. Alt+a -> (Escape, 'a') like bare Alt. Shift+a -> 'A' (safe on every Latin + # layout; Shift+digit symbols are layout-specific and deliberately NOT mapped — leaking beats + # wrong input). Kitty reports the UNSHIFTED codepoint, some modifyOtherKeys emitters the shifted + # one — map both. Ctrl-bearing combos normalize onto the Ctrl key (Alt adds an Escape prefix), + # Shift+Alt onto (Escape, UPPER) — the same normalization dte/kakoune apply. for ch in letters: upper_char = chr(ch - 32) - alt_map[ch] = (Keys.Escape, chr(ch)) - alt_map[ch - 32] = (Keys.Escape, upper_char) ctrl_key = ctrl_key_map.get(ch) + _install_paired(3, {ch: (Keys.Escape, chr(ch)), ch - 32: (Keys.Escape, upper_char)}) for cp in (ch, ch - 32): - shift_map[cp] = upper_char - shift_alt_map[cp] = (Keys.Escape, upper_char) + _install_paired(2, {cp: upper_char}) + _install_paired(4, {cp: (Keys.Escape, upper_char)}) if ctrl_key is not None: - ctrl_shift_map[cp] = ctrl_key - ctrl_alt_map[cp] = (Keys.Escape, ctrl_key) - _install_paired(3, alt_map) - _install_paired(2, shift_map) - _install_paired(4, shift_alt_map) - _install_paired(6, ctrl_shift_map) - _install_paired(7, ctrl_alt_map) - _install_paired(8, ctrl_alt_map) # Ctrl+Alt+Shift — same normalization + _install_paired(6, {cp: ctrl_key}) + for modifier in (7, 8): # Ctrl+Alt and Ctrl+Alt+Shift — same normalization + _install_paired(modifier, {cp: (Keys.Escape, ctrl_key)}) - # -- The Esc KEY under Kitty disambiguate mode: ESC[27u (+ modifiers) -- - # Disambiguate mode reports the Esc key as CSI-u so it is - # distinguishable from the ESC byte that starts escape sequences - # (#56684 — previously leaked "[27u" as literal text into the prompt). - # Modifiers run from 1 to 16: kitty reports Cmd as the super bit - # (mod 9+) — same reason install_cmd_backspace_alias maps 9/10 — and - # the lock-bit variants of the modifier-less form (1+64/128/192) are - # how a lone Esc keypress arrives with a lock on. Lock bits (caps/num) - # get the same variant treatment as _install_paired. + # The Esc KEY under Kitty disambiguate mode: ESC[27u (+ modifiers 1-16 incl. super 9+, and + # lock twins of the modifier-less form, which is how a lone Esc arrives with a lock on). _put("\x1b[27u", Keys.Escape) + for mod in (mod for m in range(1, 17) for mod in _lock_variants(m)): + _put(f"\x1b[27;{mod}u", Keys.Escape) + + # Modified Enter/Tab/Backspace/Space (Shift/Ctrl+Enter are owned by the enter aliases, which run + # first and win). Modifier 1 = unmodified keys kitty CSI-u-encodes on their own when a lock bit + # is set (plain Backspace arrives as ESC[127;129u rather than \x7f). + alt_backspace = (Keys.Escape, Keys.ControlH) # backward-kill-word, matching Ink TUI + Desktop + _install_paired(2, {9: Keys.BackTab, 127: Keys.ControlH, 32: " "}) + _install_paired(3, {13: (Keys.Escape, Keys.ControlM), 127: alt_backspace, 32: (Keys.Escape, " ")}) + _install_paired(5, {9: Keys.ControlI, 127: alt_backspace}) # Ctrl+Tab degrades to Tab + _install_paired(1, {9: Keys.ControlI, 13: Keys.ControlM, 32: " ", 127: Keys.ControlH}) + + # Lock twins for the legacy CSI-letter / CSI-tilde forms kitty keeps using under the + # disambiguate push (Down with NumLock on = ESC[1;129B; Alt+Left = ESC[1;131D). Derived from + # whatever the table already maps for the base modifier, stock entries included. for m in range(1, 17): - for mod in _lock_variants(m): - _put(f"\x1b[27;{mod}u", Keys.Escape) - - # -- Modified Enter / Tab / Backspace / Space ---- - # Shift+Enter / Ctrl+Enter are installed by install_shift_enter_alias / - # install_ctrl_enter_alias (which run first and win via setdefault). - _install_paired(2, { - 9: Keys.BackTab, # Shift+Tab — same as the legacy ESC[Z - 127: Keys.ControlH, # Shift+Backspace — plain backspace - 32: " ", # Shift+Space — still a space (#86866) - }) - _install_paired(3, { - 13: (Keys.Escape, Keys.ControlM), # Alt+Enter — newline tuple - 127: (Keys.Escape, Keys.ControlH), # Alt+Backspace — backward-kill-word - 32: (Keys.Escape, " "), # Alt+Space - }) - _install_paired(5, { - 9: Keys.ControlI, # Ctrl+Tab — degrade to Tab - 127: (Keys.Escape, Keys.ControlH), # Ctrl+Backspace — backward-kill-word, - # matching Ink TUI + Desktop (#78285) - }) - - # -- Unmodified keys with a lock bit set (kitty modifier 1 = "none") -- - # With a lock on, kitty stamps the lock bit onto keys pressed with NO - # real modifier too, so plain Backspace arrives as ESC[127;129u - # (1 + 128) rather than \x7f. _install_paired(1, ...) registers the - # bare mod-1 spelling and its lock twins. Only keys kitty CSI-u-encodes - # on their own are listed; plain text characters are still delivered - # as UTF-8, lock bits or not. - _install_paired(1, { - 9: Keys.ControlI, # Tab - 13: Keys.ControlM, # Enter - 32: " ", # Space - 127: Keys.ControlH, # Backspace - }) - - # -- Lock-key modifier bits (NumLock=128, CapsLock=64) on the legacy - # CSI-letter / CSI-tilde forms kitty keeps using under the disambiguate - # push: kitty encodes lock state into the modifier parameter, so a - # plain Down with NumLock on arrives as ESC[1;129B (NumLock), ESC[1;65B - # (CapsLock) or ESC[1;193B (both) instead of the legacy ESC[B — and a - # modified one shifts the same way (Alt+Left → ESC[1;131D). Those fall - # through the parser and leak as literal text ("[1;129B") in the input - # line. Derive the lock twins from whatever the table already maps for - # the base modifier (stock prompt_toolkit entries included), so every - # modifier the terminal can report keeps working under a lock. - for m in range(1, 17): - # CSI-letter navigation: Up/Down/Right/Left/End/Home + F1-F4 - for trailer in "ABCDFHPQRS": - base_seq = f"\x1b[1;{m}{trailer}" if m > 1 else f"\x1b[{trailer}" + legacy = [(f"\x1b[1;{m}{t}" if m > 1 else f"\x1b[{t}", f"\x1b[1;{{mod}}{t}", f"\x1bO{t}") for t in "ABCDFHPQRS"] + legacy += [(f"\x1b[{n};{m}~" if m > 1 else f"\x1b[{n}~", f"\x1b[{n};{{mod}}~", None) for n in range(1, 9)] + for base_seq, twin_fmt, ss3_seq in legacy: # CSI-letter nav/F1-F4, then CSI-tilde nav keys key = ANSI_SEQUENCES.get(base_seq) - if key is None and m == 1: - # Plain F1-F4 live in the table as SS3 (ESC O P) forms. - key = ANSI_SEQUENCES.get(f"\x1bO{trailer}") - if key is None: - continue - for mod in _lock_twins(m): - _put(f"\x1b[1;{mod}{trailer}", key) - # CSI-tilde navigation: Insert/Delete/PageUp/PageDown/Home/End - for num in range(1, 9): - base_seq = f"\x1b[{num};{m}~" if m > 1 else f"\x1b[{num}~" - key = ANSI_SEQUENCES.get(base_seq) - if key is None: - continue - for mod in _lock_twins(m): - _put(f"\x1b[{num};{mod}~", key) + if key is None and m == 1 and ss3_seq: + key = ANSI_SEQUENCES.get(ss3_seq) # plain F1-F4 live as SS3 forms + for mod in _lock_twins(m) if key is not None else (): + _put(twin_fmt.format(mod=mod), key) - # -- Kitty functional keys (Private Use Area codepoints) ---- - # kitty emits these CSI-u encodings even in LEGACY mode for keys that - # have no legacy encoding, so unmapped they leak as literal text in any - # kitty session regardless of which modes were pushed. - functional_map: dict[int, object] = {} - for d in range(10): # KP_0..KP_9 → digits - functional_map[57399 + d] = str(d) - functional_map.update({ # KP operators / punctuation - 57409: ".", 57410: "/", 57411: "*", 57412: "-", - 57413: "+", 57414: Keys.ControlM, 57415: "=", 57416: ",", - }) - functional_map.update({ # KP navigation → non-keypad keys - 57417: Keys.Left, 57418: Keys.Right, 57419: Keys.Up, - 57420: Keys.Down, 57421: Keys.PageUp, 57422: Keys.PageDown, - 57423: Keys.Home, 57424: Keys.End, 57425: Keys.Insert, - 57426: Keys.Delete, - }) - for n in range(13, 25): # F13..F24 - functional_map[57376 + (n - 13)] = getattr(Keys, f"F{n}") - # No prompt_toolkit equivalent (lock keys, PrintScreen, Menu, F25-F35, - # KP_BEGIN, media keys, bare modifier events): consume as Ignore - # instead of leaking literal text. - for code in ( - list(range(57358, 57364)) # locks, PrintScreen, Pause, Menu - + list(range(57388, 57399)) # F25..F35 - + [57427] # KP_BEGIN - + list(range(57428, 57455)) # media keys + modifier key events - ): - functional_map.setdefault(code, Keys.Ignore) - for code, key_val in functional_map.items(): + for code, key_val in _kitty_functional_map(Keys).items(): _put(f"\x1b[{code}u", key_val) - # Lock twins: with a lock on these arrive as ESC[;129u etc. - for mod in _lock_twins(1): + for mod in _lock_twins(1): # with a lock on these arrive as ESC[;129u etc. _put(f"\x1b[{code};{mod}u", key_val) - - return _register(ANSI_SEQUENCES, aliases, overwrite=False) + return aliases def install_ignored_terminal_sequences() -> int: - """Map terminal noise sequences to ``Keys.Ignore`` so the VT100 parser consumes them. + """Map focus reports ``ESC[I`` / ``ESC[O`` (Ghostty, iTerm2, some xterms) to ``Keys.Ignore``. - Covers focus reports ``ESC[I`` / ``ESC[O``, which Ghostty, iTerm2 and some xterms emit on - tab/window switches; unmapped, prompt_toolkit inserts ``[I``/``[O`` into the buffer. Parser- - level handling beats post-hoc regex stripping because the bytes never reach the buffer. + Parser-level handling beats post-hoc regex stripping because the bytes never reach the buffer. ``setdefault`` lets user/downstream registrations win. """ - tables = _pt_tables() - if tables is None: - return 0 - seqs, keys = tables - return _register(seqs, {"\x1b[I": keys.Ignore, "\x1b[O": keys.Ignore}, overwrite=False) + return _install(lambda _seqs, keys: {"\x1b[I": keys.Ignore, "\x1b[O": keys.Ignore}, overwrite=False) diff --git a/hermes_cli/pty_bridge.py b/hermes_cli/pty_bridge.py index ac35d74328..c681d729b2 100644 --- a/hermes_cli/pty_bridge.py +++ b/hermes_cli/pty_bridge.py @@ -1,13 +1,9 @@ """PTY bridge for `hermes dashboard` chat tab. -Wraps a child process behind a pseudo-terminal so its ANSI output can be streamed to a browser-side -terminal emulator (xterm.js) and typed keystrokes can be fed back in. The only caller today is the -``/api/pty`` WebSocket endpoint in ``hermes_cli.web_server``. - -* **POSIX-only.** This module depends on ``fcntl``, ``termios``, and ``ptyprocess``, none of which -exist on native Windows Python. Native Windows ConPTY is a different API (Windows 10 build 17763+) -and would need a separate Windows implementation (``pywinpty``) — that's tracked as a future -enhancement. +Wraps a child process behind a pseudo-terminal so its ANSI output can be streamed to xterm.js and +keystrokes fed back in; the only caller is the ``/api/pty`` WebSocket endpoint in +``hermes_cli.web_server``. POSIX-only: depends on ``fcntl``, ``termios`` and ``ptyprocess`` (native +Windows would need a separate ConPTY/``pywinpty`` implementation). """ from __future__ import annotations @@ -34,21 +30,17 @@ except ImportError: # pragma: no cover - dev env without ptyprocess __all__ = ["PtyBridge", "PtyUnavailableError"] -# ``struct winsize`` packs rows/cols as unsigned short (0..65535). We clamp -# well below that ceiling: real terminals never exceed a couple thousand -# columns, and a value above this is a broken probe (WSL2 reports -# columns=131072) rather than a genuine ultrawide. Lower bound is 1 — a -# zero/negative dimension is the classic "no size yet" signal. +# ``struct winsize`` packs rows/cols as unsigned short; we clamp well below that ceiling because a +# value above it is a broken probe (WSL2 reports columns=131072), not a genuine ultrawide. Lower +# bound is 1 — a zero/negative dimension is the classic "no size yet" signal. _MIN_DIMENSION = 1 _MAX_COLS = 2000 _MAX_ROWS = 1000 def _clamp_dimension(value: int, maximum: int) -> int: - """Clamp a reported terminal dimension into ``[_MIN_DIMENSION, maximum]``. - - Non-integer / non-finite values fall back to ``_MIN_DIMENSION`` so a bad probe can never reach - ``struct.pack`` and raise ``struct.error``. + """Clamp into ``[_MIN_DIMENSION, maximum]``; non-integer / non-finite values fall back to + ``_MIN_DIMENSION`` so a bad probe can never reach ``struct.pack``. """ try: n = int(value) @@ -58,18 +50,14 @@ def _clamp_dimension(value: int, maximum: int) -> int: class PtyUnavailableError(RuntimeError): - """Raised when a PTY cannot be created on this platform. - - Today this means native Windows (no ConPTY bindings) or a dev environment missing - ``ptyprocess``. The dashboard surfaces the message as a chat-tab banner. + """PTY cannot be created here (native Windows, or ``ptyprocess`` missing); the dashboard + surfaces the message as a chat-tab banner. """ class PtyBridge: - """Thin wrapper around ``ptyprocess.PtyProcess`` for byte streaming. - - Not thread-safe. A single bridge is owned by the WebSocket handler that spawned it; the reader - runs in an executor thread while writes happen on the event-loop thread. + """Thin wrapper around ``ptyprocess.PtyProcess`` for byte streaming. Not thread-safe: owned by + the WebSocket handler that spawned it; reads run in an executor thread, writes on the loop. """ def __init__(self, proc: "ptyprocess.PtyProcess"): # type: ignore[name-defined] @@ -77,8 +65,6 @@ class PtyBridge: self._fd: int = proc.fd self._closed = False - # -- lifecycle -------------------------------------------------------- - @classmethod def is_available(cls) -> bool: """True if a PTY can be spawned on this platform.""" @@ -86,45 +72,23 @@ class PtyBridge: @classmethod def spawn( - cls, - argv: Sequence[str], - *, - cwd: Optional[str] = None, - env: Optional[dict] = None, - cols: int = 80, - rows: int = 24, + cls, argv: Sequence[str], *, cwd: Optional[str] = None, env: Optional[dict] = None, cols: int = 80, rows: int = 24 ) -> "PtyBridge": """Spawn ``argv`` behind a new PTY and return a bridge.""" if not _PTY_AVAILABLE: if sys.platform.startswith("win"): - raise PtyUnavailableError( - "Pseudo-terminals are unavailable on this platform. " - "Hermes Agent supports Windows only via WSL." - ) - if ptyprocess is None: - raise PtyUnavailableError( - "The `ptyprocess` package is missing. " - "Install with: pip install ptyprocess (or pip install -e '.[pty]')." - ) - raise PtyUnavailableError("Pseudo-terminals are unavailable.") - # PTY-hosted programs expect TERM to describe the terminal type. - # CI often runs without TERM in the parent process, which makes - # simple terminal probes like `tput cols` fail before winsize reads. - # Preserve explicit caller overrides, but backfill a sensible default - # when TERM is missing or blank. - # env=None fallback: callers own env policy (process_registry already - # sanitizes). Build via the factory with exact preservation so the - # site stays findable without changing inherited content. + raise PtyUnavailableError("Pseudo-terminals are unavailable on this platform. " + "Hermes Agent supports Windows only via WSL.") + raise PtyUnavailableError("The `ptyprocess` package is missing. " # only other way _PTY_AVAILABLE is False + "Install with: pip install ptyprocess (or pip install -e '.[pty]').") + # env=None: callers own env policy (process_registry already sanitizes), so inherit via the + # factory with exact preservation. Backfill TERM when missing/blank — CI often lacks it and + # probes like `tput cols` then fail before winsize reads; explicit overrides are kept. from tools.environments.local import build_subprocess_env - spawn_env = ( - build_subprocess_env(scrub_secrets=False, inherit_profile_home=False) - if env is None else env.copy() - ) + spawn_env = build_subprocess_env(scrub_secrets=False, inherit_profile_home=False) if env is None else env.copy() if not spawn_env.get("TERM"): spawn_env["TERM"] = "xterm-256color" - proc = ptyprocess.PtyProcess.spawn( # type: ignore[union-attr] - list(argv), cwd=cwd, env=spawn_env, dimensions=(rows, cols) - ) + proc = ptyprocess.PtyProcess.spawn(list(argv), cwd=cwd, env=spawn_env, dimensions=(rows, cols)) # type: ignore[union-attr] return cls(proc) @property @@ -132,20 +96,15 @@ class PtyBridge: return int(self._proc.pid) def is_alive(self) -> bool: - if self._closed: - return False try: - return bool(self._proc.isalive()) + return not self._closed and bool(self._proc.isalive()) except Exception: return False - # -- I/O -------------------------------------------------------------- - def read(self, timeout: float = 0.2) -> Optional[bytes]: - """Read up to 64 KiB of raw bytes from the PTY master. + """Read up to 64 KiB from the PTY master, blocking at most ``timeout`` seconds. - Never blocks longer than ``timeout`` seconds. Safe to call after :meth:`close`; returns - ``None`` in that case. + ``b""`` = nothing yet; ``None`` = EOF / closed (also after :meth:`close`). """ if self._closed: return None @@ -184,29 +143,22 @@ class PtyBridge: def resize(self, cols: int, rows: int) -> None: """Forward a terminal resize to the child via ``TIOCSWINSZ``. - Dimensions are clamped first: some hosts (WSL2 via xterm.js) report garbage like - ``columns=131072, rows=1``, and ``struct winsize`` packs unsigned shorts, so an unclamped - value raises ``struct.error`` (not ``OSError``), breaks resizing and leaves the TUI laid out - for a one-row screen — the blank/disappearing-text symptom. + Clamped first: WSL2 via xterm.js reports garbage like ``columns=131072, rows=1`` and an + unclamped unsigned-short pack raises ``struct.error`` (not ``OSError``), leaving the TUI + laid out for a one-row screen — the blank/disappearing-text symptom. """ if self._closed: return # struct winsize: rows, cols, xpixel, ypixel (all unsigned short) - winsize = struct.pack( - "HHHH", _clamp_dimension(rows, _MAX_ROWS), _clamp_dimension(cols, _MAX_COLS), 0, 0 - ) + winsize = struct.pack("HHHH", _clamp_dimension(rows, _MAX_ROWS), _clamp_dimension(cols, _MAX_COLS), 0, 0) try: fcntl.ioctl(self._fd, termios.TIOCSWINSZ, winsize) except OSError: pass - # -- teardown --------------------------------------------------------- - def close(self) -> None: - """Terminate the child (SIGTERM → 0.5s grace → SIGKILL) and close fds. - - Idempotent. Reaping the child is important so we don't leak zombies across the lifetime of - the dashboard process. + """Terminate the child (SIGHUP → SIGTERM → SIGKILL, 0.5s grace each), reap it so the + dashboard process never leaks zombies, and close fds. Idempotent. """ if self._closed: return @@ -217,10 +169,8 @@ class PtyBridge: except Exception: pgid = None - # SIGHUP is the conventional "your terminal went away" signal. - # Send it to the whole foreground process group, not just the PTY - # leader: the dashboard TUI starts helper children such as the Python - # slash worker, and killing only the leader can strand those helpers. + # Signal the whole process group, not just the PTY leader: the dashboard TUI starts helper + # children (e.g. the Python slash worker) and killing only the leader strands them. for sig in (signal.SIGHUP, signal.SIGTERM, signal.SIGKILL): # windows-footgun: ok — POSIX-only module (imports fcntl/termios/ptyprocess at top) if not self._proc.isalive(): break @@ -240,7 +190,6 @@ class PtyBridge: except Exception: pass - # Context-manager sugar — handy in tests and ad-hoc scripts. def __enter__(self) -> "PtyBridge": return self diff --git a/hermes_cli/pty_session.py b/hermes_cli/pty_session.py index 8382a4a283..4c2a606d07 100644 --- a/hermes_cli/pty_session.py +++ b/hermes_cli/pty_session.py @@ -21,28 +21,23 @@ class RingBuffer: def __init__(self, capacity: int) -> None: self._cap = capacity self._buf = bytearray() - self._truncated = False + self.truncated = False def append(self, data: bytes) -> None: self._buf.extend(data) overflow = len(self._buf) - self._cap if overflow > 0: del self._buf[:overflow] - self._truncated = True + self.truncated = True def snapshot(self) -> bytes: return bytes(self._buf) - @property - def truncated(self) -> bool: - return self._truncated - async def _close_ws(ws, code: int) -> None: - if ws is None: - return try: - await ws.close(code=code) + if ws is not None: + await ws.close(code=code) except Exception: pass @@ -74,37 +69,32 @@ class PtySession: await asyncio.sleep(0) continue self.buffer.append(chunk) - ws = self._ws - if ws is not None: - try: - await ws.send_bytes(chunk) - except Exception: - pass # detached mid-send; keep buffering + try: + if self._ws is not None: + await self._ws.send_bytes(chunk) + except Exception: + pass # detached mid-send; keep buffering async def attach(self, ws, *, force_redraw: bool = False) -> None: """Attach a browser terminal and replay buffered PTY output. - The TUI uses an alternate screen and differential rendering, so a bounded ANSI tail is not - guaranteed to be a self-contained frame. Reattaching a fresh xterm therefore asks the live - TUI to emit one complete redraw after the replay. + The TUI renders differentially on an alternate screen, so a bounded ANSI tail is not a + self-contained frame; ``force_redraw`` asks the live TUI for one full redraw after replay. """ if self._ws is not ws: await _close_ws(self._ws, WS_CLOSE_SUPERSEDED) self._ws = ws self.attached = True self.last_detached_at = None - snap = self.buffer.snapshot() - if snap: + if snap := self.buffer.snapshot(): await ws.send_bytes(snap) if force_redraw: self.bridge.write(TUI_FORCE_REDRAW) def detach(self, ws) -> None: - # Only the currently-attached socket may mark the session detached. - # A superseded socket's handler also calls detach on its way out - # (its ``finally`` runs after the new tab attached); flipping - # ``attached`` then would make a session with a live viewer look - # idle and reapable. + # Only the currently-attached socket may mark the session detached: a superseded socket's + # handler also calls detach on its way out (after the new tab attached), and flipping + # ``attached`` then would make a session with a live viewer look idle and reapable. if self._ws is not ws: return self._ws = None @@ -119,8 +109,7 @@ class PtySession: except (asyncio.CancelledError, Exception): pass try: - # bridge.close() joins the child — blocking; keep it off the - # event loop (#53227). + # bridge.close() joins the child — blocking; keep it off the event loop. await asyncio.to_thread(self.bridge.close) except Exception: pass @@ -141,16 +130,14 @@ async def run_reaper(registry: "PtySessionRegistry", *, interval: float = 60.0) class PtySessionRegistry: - def __init__(self, *, ttl: float, max_sessions: int, - buffer_cap: int, read_timeout: float) -> None: + def __init__(self, *, ttl: float, max_sessions: int, buffer_cap: int, read_timeout: float) -> None: self._ttl = ttl self._max = max_sessions self._buffer_cap = buffer_cap self._read_timeout = read_timeout self._sessions: Dict[str, PtySession] = {} - async def attach_or_spawn(self, key: str, *, spawn: Callable[[], object] - ) -> Tuple[PtySession, bool]: + async def attach_or_spawn(self, key: str, *, spawn: Callable[[], object]) -> Tuple[PtySession, bool]: await self.reap_idle() existing = self._sessions.get(key) if existing is not None and existing.alive: @@ -160,11 +147,9 @@ class PtySessionRegistry: self._sessions.pop(key, None) if len(self._sessions) >= self._max: self._reap_one_idle_or_raise() - # PTY spawn does blocking fork/exec work — keep it off the event - # loop (#53227). + # PTY spawn does blocking fork/exec work — keep it off the event loop. bridge = await asyncio.to_thread(spawn) - session = PtySession(key, bridge, buffer_cap=self._buffer_cap, - read_timeout=self._read_timeout) + session = PtySession(key, bridge, buffer_cap=self._buffer_cap, read_timeout=self._read_timeout) await session.start() self._sessions[key] = session return session, True @@ -178,16 +163,13 @@ class PtySessionRegistry: now = time.monotonic() if now is None else now doomed = [ key for key, s in self._sessions.items() - if (not s.alive) - or (not s.attached and s.last_detached_at is not None - and (now - s.last_detached_at) > self._ttl) + if not s.alive or (not s.attached and s.last_detached_at is not None and (now - s.last_detached_at) > self._ttl) ] for key in doomed: await self._sessions.pop(key).close() def _reap_one_idle_or_raise(self) -> None: - idle = [s for s in self._sessions.values() - if not s.attached and s.last_detached_at is not None] + idle = [s for s in self._sessions.values() if not s.attached and s.last_detached_at is not None] if not idle: raise RegistryFull() oldest = min(idle, key=lambda s: s.last_detached_at or 0.0) diff --git a/hermes_cli/relaunch.py b/hermes_cli/relaunch.py index 31963f4fac..0ae7ec57e9 100644 --- a/hermes_cli/relaunch.py +++ b/hermes_cli/relaunch.py @@ -1,9 +1,5 @@ -"""Unified self-relaunch for Hermes CLI. - -Preserves critical flags (--tui, --dev, --profile, --model, etc.) across process replacement so that -``hermes sessions browse`` or post-setup relaunch doesn't silently drop the user's UI mode or other -preferences. -""" +"""Unified self-relaunch for Hermes CLI: preserves inherited flags (--tui, --dev, --profile, --model…) +across process replacement so ``hermes sessions browse`` / post-setup relaunch keep the user's mode.""" import os import shutil @@ -11,19 +7,14 @@ import sys from typing import Optional, Sequence from hermes_cli._parser import ( - PRE_ARGPARSE_INHERITED_FLAGS, - build_top_level_parser, + PRE_ARGPARSE_INHERITED_FLAGS, build_top_level_parser ) def _build_inherited_flag_table() -> list[tuple[str, bool]]: - """Build the ``(option_string, takes_value)`` table of flags that must survive a self-relaunch. - - Introspects the real ``hermes`` parser: a flag participates iff its argparse Action carries - ``inherit_on_relaunch = True`` (set by ``_parser._inherited_flag``). - """ + """``(option_string, takes_value)`` for every parser Action carrying ``inherit_on_relaunch`` + (set by ``_parser._inherited_flag``), plus the pre-argparse flags.""" parser, _subparsers, chat_parser = build_top_level_parser() - table: list[tuple[str, bool]] = [] seen: set[tuple[str, bool]] = set() for p in (parser, chat_parser): @@ -38,7 +29,6 @@ def _build_inherited_flag_table() -> list[tuple[str, bool]]: if key not in seen: seen.add(key) table.append(key) - table.extend(PRE_ARGPARSE_INHERITED_FLAGS) return table @@ -53,14 +43,10 @@ def _extract_inherited_flags(argv: Sequence[str]) -> list[str]: while i < len(argv): arg = argv[i] if "=" in arg: - key = arg.split("=", 1)[0] - for flag, _ in _INHERITED_FLAGS_TABLE: - if key == flag: - flags.append(arg) - break + if any(arg.split("=", 1)[0] == flag for flag, _ in _INHERITED_FLAGS_TABLE): + flags.append(arg) i += 1 continue - for flag, takes_value in _INHERITED_FLAGS_TABLE: if arg == flag: flags.append(arg) @@ -73,80 +59,50 @@ def _extract_inherited_flags(argv: Sequence[str]) -> list[str]: def resolve_hermes_bin() -> Optional[str]: - """Find the hermes entry point. - - Priority: 1. ``sys.argv[0]`` if it resolves to a real executable. 2. ``shutil.which("hermes")`` - on PATH. 3. ``None`` → caller should fall back to ``python -m hermes_cli.main``. - """ + """Hermes entry point: ``sys.argv[0]`` if a real executable, else ``which hermes``, else ``None`` + (caller falls back to ``python -m hermes_cli.main``).""" argv0 = sys.argv[0] _is_windows = sys.platform == "win32" def _is_python_script(p: str) -> bool: return p.lower().endswith((".py", ".pyc")) - # Absolute path to an executable (covers nix store, venv wrappers, etc.) - if os.path.isabs(argv0) and os.path.isfile(argv0) and os.access(argv0, os.X_OK): - if not (_is_windows and _is_python_script(argv0)): - return argv0 - - # Relative path — resolve against CWD + # Absolute executable (nix store, venv wrappers, …), then relative-to-CWD, then PATH. + if ( + os.path.isabs(argv0) and os.path.isfile(argv0) and os.access(argv0, os.X_OK) + and not (_is_windows and _is_python_script(argv0)) + ): + return argv0 if not argv0.startswith("-") and os.path.isfile(argv0): abs_path = os.path.abspath(argv0) - if os.access(abs_path, os.X_OK): - if not (_is_windows and _is_python_script(abs_path)): - return abs_path - - # PATH lookup - path_bin = shutil.which("hermes") - if path_bin: - return path_bin - - return None + if os.access(abs_path, os.X_OK) and not (_is_windows and _is_python_script(abs_path)): + return abs_path + return shutil.which("hermes") or None def build_relaunch_argv( - extra_args: Sequence[str], - *, - preserve_inherited: bool = True, - original_argv: Optional[Sequence[str]] = None, + extra_args: Sequence[str], *, preserve_inherited: bool = True, original_argv: Optional[Sequence[str]] = None ) -> list[str]: """Construct an argv list for replacing the current process with hermes.""" bin_path = resolve_hermes_bin() - - if bin_path: - argv = [bin_path] - else: - argv = [sys.executable, "-m", "hermes_cli.main"] - + argv = [bin_path] if bin_path else [sys.executable, "-m", "hermes_cli.main"] src = list(original_argv) if original_argv is not None else list(sys.argv[1:]) - if preserve_inherited: argv.extend(_extract_inherited_flags(src)) - argv.extend(extra_args) return argv def relaunch( - extra_args: Sequence[str], - *, - preserve_inherited: bool = True, - original_argv: Optional[Sequence[str]] = None, + extra_args: Sequence[str], *, preserve_inherited: bool = True, original_argv: Optional[Sequence[str]] = None ) -> None: """Replace the current process with a fresh hermes invocation. - On POSIX we use ``os.execvp`` which replaces the running process with the new one in place — - same PID, no double-fork. That's what the relaunch contract wants: "run hermes again as if the - user had typed the new argv". - - Windows has no native exec semantics — ``os.execvp`` on Windows *emulates* exec by spawning the - child and exiting the parent, but only works when the target is a real Win32 executable. + POSIX: ``os.execvp`` in place (same PID, no double-fork). Windows has no real exec — its + ``execvp`` emulation only works for a real Win32 executable, so spawn + exit instead. """ - new_argv = build_relaunch_argv( - extra_args, preserve_inherited=preserve_inherited, original_argv=original_argv - ) + new_argv = build_relaunch_argv(extra_args, preserve_inherited=preserve_inherited, original_argv=original_argv) if sys.platform == "win32": - # Windows: subprocess + exit, because execvp can't swap to .cmd/.exe shims. import subprocess try: result = subprocess.run(new_argv) @@ -154,10 +110,8 @@ def relaunch( except KeyboardInterrupt: sys.exit(130) except OSError as exc: - # Surface a helpful error rather than the raw OSError — the - # caller used to see ``[Errno 8] Exec format error`` which is - # cryptic. Common causes: ``hermes`` not on PATH yet (install - # hasn't propagated User PATH into this shell) or a stale shim. + # Raw ``[Errno 8] Exec format error`` is cryptic; usual causes are ``hermes`` not on + # PATH yet (install hasn't propagated User PATH into this shell) or a stale shim. print( f"\nHermes relaunch failed: {exc}\n" f"Command: {' '.join(new_argv)}\n" diff --git a/hermes_cli/relay_plugin_cutover.py b/hermes_cli/relay_plugin_cutover.py index 880fa867ff..f7297e914d 100644 --- a/hermes_cli/relay_plugin_cutover.py +++ b/hermes_cli/relay_plugin_cutover.py @@ -8,56 +8,35 @@ from typing import Any RELAY_PLUGINS_CONFIG_ENV = "HERMES_NEMO_RELAY_PLUGINS_TOML" -LEGACY_RELAY_PLUGIN_KEYS = frozenset( - { - "nemo_relay", - "observability/nemo_relay", - } -) +LEGACY_RELAY_PLUGIN_KEYS = frozenset({"nemo_relay", "observability/nemo_relay"}) -LEGACY_RELAY_EXPORT_ENV_VARS = frozenset( - { - "HERMES_NEMO_RELAY_ATOF_ENABLED", - "HERMES_NEMO_RELAY_ATOF_OUTPUT_DIRECTORY", - "HERMES_NEMO_RELAY_ATOF_FILENAME", - "HERMES_NEMO_RELAY_ATOF_MODE", - "HERMES_NEMO_RELAY_ATIF_ENABLED", - "HERMES_NEMO_RELAY_ATIF_OUTPUT_DIRECTORY", - "HERMES_NEMO_RELAY_ATIF_FILENAME_TEMPLATE", - "HERMES_NEMO_RELAY_ATIF_AGENT_NAME", - "HERMES_NEMO_RELAY_ATIF_AGENT_VERSION", - "HERMES_NEMO_RELAY_ATIF_EXPORT_TIMEOUT_S", - "HERMES_NEMO_RELAY_ATIF_MODEL_NAME", - "HERMES_NEMO_RELAY_ATIF_SUBAGENT_EXPORT_MODE", - } -) +LEGACY_RELAY_EXPORT_ENV_VARS = frozenset({ + "HERMES_NEMO_RELAY_ATOF_ENABLED", + "HERMES_NEMO_RELAY_ATOF_OUTPUT_DIRECTORY", + "HERMES_NEMO_RELAY_ATOF_FILENAME", + "HERMES_NEMO_RELAY_ATOF_MODE", + "HERMES_NEMO_RELAY_ATIF_ENABLED", + "HERMES_NEMO_RELAY_ATIF_OUTPUT_DIRECTORY", + "HERMES_NEMO_RELAY_ATIF_FILENAME_TEMPLATE", + "HERMES_NEMO_RELAY_ATIF_AGENT_NAME", + "HERMES_NEMO_RELAY_ATIF_AGENT_VERSION", + "HERMES_NEMO_RELAY_ATIF_EXPORT_TIMEOUT_S", + "HERMES_NEMO_RELAY_ATIF_MODEL_NAME", + "HERMES_NEMO_RELAY_ATIF_SUBAGENT_EXPORT_MODE", +}) def legacy_relay_plugin_keys(values: Any) -> tuple[str, ...]: """Return removed Relay plugin identities present in a config value.""" if not isinstance(values, (list, tuple, set, frozenset)): return () - return tuple( - sorted( - { - value - for value in values - if isinstance(value, str) and value in LEGACY_RELAY_PLUGIN_KEYS - } - ) - ) + return tuple(sorted({v for v in values if isinstance(v, str) and v in LEGACY_RELAY_PLUGIN_KEYS})) -def configured_legacy_relay_env_vars( - env: Mapping[str, Any] | None, -) -> tuple[str, ...]: +def configured_legacy_relay_env_vars(env: Mapping[str, Any] | None) -> tuple[str, ...]: """Return non-empty legacy Relay exporter variables in *env*.""" if env is None: return () - return tuple( - sorted( - name - for name in LEGACY_RELAY_EXPORT_ENV_VARS - if env.get(name) is not None and str(env[name]).strip() - ) - ) + return tuple(sorted( + name for name in LEGACY_RELAY_EXPORT_ENV_VARS if env.get(name) is not None and str(env[name]).strip() + )) diff --git a/hermes_cli/resource_limits.py b/hermes_cli/resource_limits.py index 6e4ceca44e..276d24f18a 100644 --- a/hermes_cli/resource_limits.py +++ b/hermes_cli/resource_limits.py @@ -19,36 +19,28 @@ DEFAULT_NOFILE_SOFT_LIMIT = int(DEFAULT_CONFIG["runtime"]["nofile_soft_limit"]) _MISSING = object() -def configured_nofile_soft_limit( - config: Mapping[str, Any] | None = None, -) -> int | None: - """Resolve ``runtime.nofile_soft_limit`` from a loaded config. +def configured_nofile_soft_limit(config: Mapping[str, Any] | None = None) -> int | None: + """``runtime.nofile_soft_limit`` from a loaded config, or ``None`` when disabled/unresolvable. - A missing key uses the default. Explicit ``0``, ``false``, and ``null`` disable the - adjustment; other non-integer or negative values are ignored (caller fails open). - Used by service-definition generators (e.g. the launchd plist) so persisted service limits - and the in-process floor share one knob. ``None`` when disabled or unresolvable. + Missing key → default. Explicit ``0``/``false``/``null`` disable; other non-int or negative + values are ignored (caller fails open). Shared by the in-process floor and service-definition + generators (launchd plist) so both use one knob. """ if config is None: try: - # Use Hermes's real, profile-aware loader rather than reading YAML - # here. This also applies managed-scope overlays and defaults. + # Profile-aware loader (applies managed-scope overlays and defaults). from hermes_cli.config import load_config_readonly - config = load_config_readonly() except Exception: logger.debug("Could not load config for RLIMIT_NOFILE", exc_info=True) return None - if not isinstance(config, Mapping): return None - runtime = config.get("runtime", _MISSING) if runtime is _MISSING: return DEFAULT_NOFILE_SOFT_LIMIT if not isinstance(runtime, Mapping): return None - raw_value = runtime.get("nofile_soft_limit", _MISSING) if raw_value is _MISSING: return DEFAULT_NOFILE_SOFT_LIMIT @@ -57,50 +49,34 @@ def configured_nofile_soft_limit( return raw_value -def apply_nofile_soft_limit( - config: Mapping[str, Any] | None = None, -) -> bool: - """Raise this process's ``RLIMIT_NOFILE`` soft limit when possible. +def apply_nofile_soft_limit(config: Mapping[str, Any] | None = None) -> bool: + """Best-effort raise of this process's ``RLIMIT_NOFILE`` soft limit; ``True`` iff changed. - The target defaults to :data:`DEFAULT_NOFILE_SOFT_LIMIT` and can be set with - ``runtime.nofile_soft_limit``. The target is clamped to a finite hard limit, never lowers an - existing higher soft limit, and returns ``False`` for an explicit opt-out or when the - platform/sandbox refuses the operation. - - This is intentionally best-effort. Unsupported platforms, malformed settings, and denied - ``setrlimit`` calls must never prevent a server from starting. + Target = ``runtime.nofile_soft_limit`` (default :data:`DEFAULT_NOFILE_SOFT_LIMIT`), clamped + to a finite hard limit; never lowers a higher soft limit. Unsupported platforms, malformed + settings, and denied ``setrlimit`` must never prevent a server from starting. """ if _resource is None: return False - target = configured_nofile_soft_limit(config) if target is None: return False - try: nofile = _resource.RLIMIT_NOFILE current_soft, current_hard = _resource.getrlimit(nofile) - # On platforms where RLIM_INFINITY is represented as -1, ordinary - # integer ordering would make an unlimited soft limit look lower than - # every positive target. Never replace infinity with a finite limit. + # RLIM_INFINITY may be -1, which ordinary ordering would treat as "lower than any + # target"; never replace infinity with a finite limit. infinity = getattr(_resource, "RLIM_INFINITY", object()) if current_soft == infinity or current_soft >= target: return False new_soft = target if current_hard == infinity else min(target, current_hard) if new_soft <= current_soft: return False - _resource.setrlimit(nofile, (new_soft, current_hard)) return True except Exception: - # This helper runs before server startup and must fail open for - # unsupported/sandboxed environments and denied resource changes. logger.debug("Could not raise RLIMIT_NOFILE soft limit", exc_info=True) return False -__all__ = [ - "DEFAULT_NOFILE_SOFT_LIMIT", - "apply_nofile_soft_limit", - "configured_nofile_soft_limit", -] +__all__ = ["DEFAULT_NOFILE_SOFT_LIMIT", "apply_nofile_soft_limit", "configured_nofile_soft_limit"] diff --git a/hermes_cli/security_advisories.py b/hermes_cli/security_advisories.py index 171de7b83d..1d807e99a4 100644 --- a/hermes_cli/security_advisories.py +++ b/hermes_cli/security_advisories.py @@ -1,8 +1,7 @@ """Security advisory checker for Hermes Agent. -- **Cheap.** A single ``importlib.metadata.version()`` call per advisory package. Safe to run on -every CLI startup. - **Loud when it matters, silent otherwise.** If no compromised package is -installed, the user sees nothing. +Cheap (one ``importlib.metadata.version()`` call per advisory package, safe on every CLI startup) +and silent unless a compromised package is actually installed. """ from __future__ import annotations @@ -17,33 +16,17 @@ from typing import Iterable, Optional logger = logging.getLogger(__name__) -# ============================================================================= -# Advisory catalog -# -# Each advisory is a community-facing security warning about one or more -# specific package versions that are known to be compromised. To add a new -# advisory: -# -# 1. Append a new ``Advisory`` to ``ADVISORIES`` below -# 2. Set ``compromised`` to a tuple of ``(pkg_name, frozenset_of_versions)`` -# — version strings must match what ``importlib.metadata.version()`` -# returns. Use an empty frozenset to flag *any installed version* -# (rare; only when the maintainer namespace itself is compromised). -# 3. Write 2-4 short ``remediation`` lines a non-expert can copy/paste. -# -# Do NOT remove old advisories. Once an advisory ships, leave it in place so -# users running an older release with the compromised package still get -# warned. Mark superseded ones via ``superseded_by`` if needed. -# ============================================================================= +# Advisory catalog. To add one: append an ``Advisory`` to ``ADVISORIES`` with ``compromised`` as +# ``(pkg_name, frozenset_of_versions)`` pairs (versions exactly as ``importlib.metadata.version()`` +# returns them; an empty frozenset flags ANY installed version — rare, namespace compromise only) +# and 2-4 copy/pasteable ``remediation`` lines. Never remove old advisories: users on an older +# release with the compromised package must still get warned. @dataclass(frozen=True) class Advisory: - """One security advisory entry. - - ``id`` is lowercase-hyphen, stable and never reused (it is what acks key on). An empty version - frozenset in ``compromised`` means any version is suspect — use sparingly. ``remediation`` is - ordered: uninstall command first, then credential audit/rotation guidance. + """``id`` is lowercase-hyphen, stable and never reused (it is what acks key on). ``remediation`` + is ordered: uninstall command first, then credential audit/rotation guidance. """ id: str @@ -73,9 +56,7 @@ ADVISORIES: tuple[Advisory, ...] = ( "the compromised 2.4.6 is still installed." ), url="https://socket.dev/blog/mini-shai-hulud-worm-pypi", - compromised=( - ("mistralai", frozenset({"2.4.6"})), - ), + compromised=(("mistralai", frozenset({"2.4.6"})),), remediation=( "Run: pip uninstall -y mistralai (or: uv pip uninstall mistralai)", "Rotate API keys in ~/.hermes/.env (OpenRouter, Anthropic, OpenAI, " @@ -93,11 +74,6 @@ ADVISORIES: tuple[Advisory, ...] = ( ) -# ============================================================================= -# Detection -# ============================================================================= - - @dataclass(frozen=True) class AdvisoryHit: """One package-version match against an advisory.""" @@ -108,10 +84,8 @@ class AdvisoryHit: def _installed_version(pkg_name: str) -> Optional[str]: - """Return the installed version of ``pkg_name``, or None if not installed. - - Uses ``importlib.metadata`` so we don't depend on pip being importable inside the active venv - (uv-created venvs may lack pip). + """Installed version of ``pkg_name`` via ``importlib.metadata`` (uv venvs may lack pip), or + None if not installed or metadata is corrupt — never crash the CLI startup path. """ from importlib.metadata import PackageNotFoundError, version try: @@ -119,20 +93,12 @@ def _installed_version(pkg_name: str) -> Optional[str]: except PackageNotFoundError: return None except Exception: - # Some metadata corruption modes raise ValueError or OSError. Don't - # let advisory checking crash the CLI startup path. logger.debug("importlib.metadata.version(%s) raised", pkg_name, exc_info=True) return None -def detect_compromised( - advisories: Iterable[Advisory] = ADVISORIES, -) -> list[AdvisoryHit]: - """Scan installed packages and return all advisory hits. - - A hit means the package is installed AND its version is in the compromised set (or the set is - empty, meaning any version is suspect). - """ +def detect_compromised(advisories: Iterable[Advisory] = ADVISORIES) -> list[AdvisoryHit]: + """All hits: package installed AND version in the compromised set (or the set is empty).""" return [ AdvisoryHit(advisory, pkg_name, installed) for advisory in advisories @@ -141,30 +107,20 @@ def detect_compromised( ] -# ============================================================================= -# Acknowledgement persistence -# -# Acks live under ``security.acked_advisories`` in config.yaml as a list of -# advisory IDs. The list is the only state — no per-host data, no -# timestamps, no fingerprints. Users sharing a config.yaml across machines -# (rare but possible) get the same dismissal everywhere, which is the -# correct behavior for a global advisory. -# ============================================================================= - +# ─── Acknowledgement persistence ────────────────────────────────────────────── +# Acks live under ``security.acked_advisories`` in config.yaml as a list of advisory IDs — the only +# state (no per-host data or timestamps), so a shared config.yaml dismisses everywhere. def get_acked_ids() -> set[str]: - """Return the set of advisory IDs the user has dismissed. - - Returns an empty set if config can't be loaded (don't block startup just because config is - broken — the advisory will keep firing until config is repaired, which is fine). + """Advisory IDs the user has dismissed; empty when config can't be loaded (never block startup — + the advisory keeps firing until config is repaired). """ try: from hermes_cli.config import load_config - cfg = load_config() + raw = (load_config().get("security") or {}).get("acked_advisories") or [] except Exception: logger.debug("Could not load config for advisory acks", exc_info=True) return set() - raw = (cfg.get("security") or {}).get("acked_advisories") or [] return {str(x).strip() for x in raw if str(x).strip()} if isinstance(raw, list) else set() @@ -182,7 +138,8 @@ def ack_advisory(advisory_id: str) -> bool: cfg = load_config() sec = cfg.setdefault("security", {}) existing = sec.get("acked_advisories") or [] - existing = existing if isinstance(existing, list) else [] + if not isinstance(existing, list): + existing = [] if advisory_id not in existing: sec["acked_advisories"] = existing + [advisory_id] save_config(cfg) @@ -193,26 +150,17 @@ def ack_advisory(advisory_id: str) -> bool: def filter_unacked(hits: list[AdvisoryHit]) -> list[AdvisoryHit]: - """Return only hits whose advisories the user has not dismissed.""" + """Only hits whose advisories the user has not dismissed.""" acked = get_acked_ids() if hits else set() return [h for h in hits if h.advisory.id not in acked] -# ============================================================================= -# Rendering helpers -# ============================================================================= - - def _term_supports_color() -> bool: return not os.environ.get("NO_COLOR") and sys.stdout.isatty() def short_banner_lines(hits: list[AdvisoryHit]) -> list[str]: - """Return 1-3 short lines suitable for a startup banner. - - Caller is responsible for color/styling. Always names the worst hit explicitly so the user knows - what's wrong without running doctor. - """ + """1-3 short unstyled lines for a startup banner; always names the worst hit explicitly.""" if not hits: return [] primary = hits[0] @@ -222,13 +170,12 @@ def short_banner_lines(hits: list[AdvisoryHit]) -> list[str]: " Run 'hermes doctor' for remediation steps.", ] if len(hits) > 1: - lines.insert(1, f" ({len(hits) - 1} additional advisor" - f"{'ies' if len(hits) > 2 else 'y'} also active.)") + lines.insert(1, f" ({len(hits) - 1} additional advisor{'ies' if len(hits) > 2 else 'y'} also active.)") return lines def full_remediation_text(hit: AdvisoryHit) -> list[str]: - """Return a multi-line block describing the advisory + remediation.""" + """Multi-line block describing the advisory + remediation.""" a = hit.advisory return [ f"=== {a.title} ===", @@ -243,18 +190,10 @@ def full_remediation_text(hit: AdvisoryHit) -> list[str]: ] -# ============================================================================= -# Startup-banner gating -# -# We do NOT want to hammer the user with the banner on every command. Once -# they've seen it inside a 24h window we cache that fact in -# ``~/.hermes/cache/advisory_banner_seen`` (a single line per advisory ID: -# `` ``). -# -# Acked advisories never re-banner. Cached-but-not-acked advisories -# re-banner after 24h so the user doesn't fully forget. -# ============================================================================= - +# ─── Startup-banner gating ──────────────────────────────────────────────────── +# Once the banner is seen we cache that in ``~/.hermes/cache/advisory_banner_seen`` (one +# `` `` line per advisory). Acked advisories never re-banner; cached-but-not-acked +# ones re-banner after 24h so the user doesn't fully forget. _BANNER_CACHE_FILE = "advisory_banner_seen" _BANNER_REPEAT_HOURS = 24 @@ -272,41 +211,31 @@ def _banner_cache_path() -> Optional[Path]: def _read_banner_cache() -> dict[str, float]: p = _banner_cache_path() - if p is None or not p.exists(): - return {} - out: dict[str, float] = {} try: - for line in p.read_text(encoding="utf-8").splitlines(): - parts = line.split(None, 1) - try: - if len(parts) == 2: - out[parts[0]] = float(parts[1]) - except ValueError: - continue + lines = p.read_text(encoding="utf-8").splitlines() if p is not None and p.exists() else [] except Exception: return {} + out: dict[str, float] = {} + for parts in (line.split(None, 1) for line in lines): + try: + if len(parts) == 2: + out[parts[0]] = float(parts[1]) + except ValueError: + continue return out def _write_banner_cache(seen: dict[str, float]) -> None: - p = _banner_cache_path() - if p is None: - return try: - p.write_text("\n".join(f"{aid} {ts}" for aid, ts in seen.items()) + "\n", encoding="utf-8") + if (p := _banner_cache_path()) is not None: + p.write_text("\n".join(f"{aid} {ts}" for aid, ts in seen.items()) + "\n", encoding="utf-8") except Exception: logger.debug("Could not write advisory banner cache", exc_info=True) -def hits_due_for_banner( - hits: list[AdvisoryHit], - *, - repeat_hours: int = _BANNER_REPEAT_HOURS, -) -> list[AdvisoryHit]: - """Return only hits whose banner is due (not acked, not recently shown). - - Side effect: stamps the banner cache for any hit that's about to be shown. Callers should - subsequently render the result. +def hits_due_for_banner(hits: list[AdvisoryHit], *, repeat_hours: int = _BANNER_REPEAT_HOURS) -> list[AdvisoryHit]: + """Hits whose banner is due (not acked, not recently shown). Side effect: stamps the banner + cache for every returned hit, so callers must render the result. """ import time @@ -316,7 +245,6 @@ def hits_due_for_banner( now = time.time() cache = _read_banner_cache() cutoff = now - (repeat_hours * 3600) - due = [hit for hit in fresh if cache.get(hit.advisory.id, 0.0) < cutoff] for hit in due: cache[hit.advisory.id] = now @@ -325,17 +253,8 @@ def hits_due_for_banner( return due -# ============================================================================= -# Public entry points used by doctor / CLI / gateway -# ============================================================================= - - def startup_banner(hits: list[AdvisoryHit]) -> Optional[str]: - """Return a printable startup banner, or None if nothing is due. - - Updates the banner cache as a side effect (so the next call within 24h returns None for the same - hit). - """ + """Printable startup banner, or None if nothing is due (updates the banner cache).""" due = hits_due_for_banner(hits) if not due: return None @@ -344,17 +263,13 @@ def startup_banner(hits: list[AdvisoryHit]) -> Optional[str]: def gateway_log_message(hits: list[AdvisoryHit]) -> Optional[str]: - """Return a one-line log message for gateway operators, or None.""" + """One-line log message for gateway operators, or None.""" fresh = filter_unacked(hits) if not fresh: return None if len(fresh) == 1: h = fresh[0] - return ( - f"Security advisory [{h.advisory.id}] active: {h.package}=={h.installed_version} " - f"matches {h.advisory.title}. See {h.advisory.url}" - ) - return ( - f"{len(fresh)} security advisories active (IDs: {', '.join(h.advisory.id for h in fresh)}). " - "Run `hermes doctor` on the gateway host for details." - ) + return (f"Security advisory [{h.advisory.id}] active: {h.package}=={h.installed_version} " + f"matches {h.advisory.title}. See {h.advisory.url}") + return (f"{len(fresh)} security advisories active (IDs: {', '.join(h.advisory.id for h in fresh)}). " + "Run `hermes doctor` on the gateway host for details.") diff --git a/hermes_cli/security_audit.py b/hermes_cli/security_audit.py index ebc5ac769f..6358e5fc8c 100644 --- a/hermes_cli/security_audit.py +++ b/hermes_cli/security_audit.py @@ -1,8 +1,7 @@ """On-demand supply-chain audit for Hermes Agent installs. Vulnerabilities are looked up against OSV.dev (``api.osv.dev/v1/querybatch`` + ``/v1/vulns/{id}``). -Single-shot, on-demand, never daily — see the design notes in ``references/security-disclosure- -triage.md``. +Single-shot, on-demand, never daily — see ``references/security-disclosure-triage.md``. """ from __future__ import annotations @@ -26,20 +25,8 @@ OSV_BATCH_MAX = 1000 # OSV documented hard cap per request HTTP_TIMEOUT = 20 DETAIL_PARALLELISM = 8 -# Severity ordering for --fail-on gating. UNKNOWN sits below LOW so it -# never blocks unless --fail-on is passed something even lower (we don't -# expose that). -SEVERITY_ORDER = { - "UNKNOWN": 0, - "LOW": 1, - "MODERATE": 2, - "MEDIUM": 2, - "HIGH": 3, - "CRITICAL": 4, -} - - -# ─── Data shapes ────────────────────────────────────────────────────────────── +# Severity ordering for --fail-on gating. UNKNOWN sits below LOW so it never blocks. +SEVERITY_ORDER = {"UNKNOWN": 0, "LOW": 1, "MODERATE": 2, "MEDIUM": 2, "HIGH": 3, "CRITICAL": 4} @dataclass(frozen=True) @@ -66,39 +53,24 @@ class Finding: vuln: Vulnerability -# ─── Component discovery ────────────────────────────────────────────────────── - - def _discover_venv() -> list[Component]: """Every dist installed in the running Python's import path.""" from importlib.metadata import distributions - out: list[Component] = [] - seen: set[tuple[str, str]] = set() + out: dict[tuple[str, str], Component] = {} for dist in distributions(): try: name = (dist.metadata["Name"] or "").strip() except Exception: continue version = (dist.version or "").strip() - key = (name.lower(), version) - if name and version and key not in seen: - seen.add(key) - out.append(Component(name=name, version=version, ecosystem="PyPI", source="venv")) - return out + if name and version: + out.setdefault((name.lower(), version), Component(name=name, version=version, ecosystem="PyPI", source="venv")) + return list(out.values()) -# requirements.txt line: drop comments, environment markers, options, extras -_REQ_LINE = re.compile( - r"""^\s* - (?P[A-Za-z0-9][A-Za-z0-9._-]*) - (?:\[[^\]]+\])? # extras - \s*==\s* - (?P[A-Za-z0-9._+!-]+) - \s*(?:;.*)?$ - """, - re.VERBOSE, -) +# ``name[extras]==version ; marker`` — an exact pin, optionally with extras and an environment marker. +_REQ_LINE = re.compile(r"^\s*(?P[A-Za-z0-9][A-Za-z0-9._-]*)(?:\[[^\]]+\])?\s*==\s*(?P[A-Za-z0-9._+!-]+)\s*(?:;.*)?$") def _match_pins(specs: Iterable[str]) -> list[tuple[str, str]]: @@ -107,10 +79,8 @@ def _match_pins(specs: Iterable[str]) -> list[tuple[str, str]]: def _parse_requirements(text: str) -> list[tuple[str, str]]: - """Extract ``name==version`` pins. Everything else (>=, ~=, no pin) is skipped. - - A loose pin can't be mapped to a single OSV query, and getting it wrong is worse than missing a - finding for an audit tool — false positives train users to ignore output. + """Extract ``name==version`` pins. Loose specs (>=, ~=, no pin) are skipped: they can't map to + a single OSV query, and false positives train users to ignore an audit tool's output. """ lines = (raw.strip() for raw in text.splitlines()) return _match_pins(line for line in lines if line and not line.startswith(("#", "-"))) @@ -120,35 +90,25 @@ def _parse_pyproject_pins(text: str) -> list[tuple[str, str]]: """Pull ``name==version`` pins from a ``pyproject.toml`` ``dependencies`` list.""" try: import tomllib - except ImportError: # pragma: no cover - 3.10 only - return [] - try: - data = tomllib.loads(text) + project = tomllib.loads(text).get("project") or {} except Exception: return [] - project = data.get("project") or {} optional = project.get("optional-dependencies") or {} groups = [project.get("dependencies")] + (list(optional.values()) if isinstance(optional, dict) else []) return _match_pins(str(x) for group in groups if isinstance(group, list) for x in group) -_PLUGIN_PIN_FILES = ( - ("requirements.txt", _parse_requirements), - ("requirements-dev.txt", _parse_requirements), - ("pyproject.toml", _parse_pyproject_pins), -) +_PLUGIN_PIN_FILES = (("requirements.txt", _parse_requirements), ("requirements-dev.txt", _parse_requirements), + ("pyproject.toml", _parse_pyproject_pins)) def _discover_plugins(hermes_home: Path) -> list[Component]: - """Python deps declared by plugins under ``~/.hermes/plugins``. - - Plugins typically don't install into the venv (they're directory-based with relative imports), - so their stated requirements are useful audit surface even when the venv scan misses them. + """Python deps declared by plugins under ``~/.hermes/plugins``. Plugins typically don't install + into the venv, so their stated requirements are audit surface the venv scan misses. """ plugins_dir = hermes_home / "plugins" if not plugins_dir.is_dir(): return [] - out: list[Component] = [] for plugin_dir in sorted(plugins_dir.iterdir()): if not plugin_dir.is_dir() or plugin_dir.name.startswith("."): @@ -163,26 +123,17 @@ def _discover_plugins(hermes_home: Path) -> list[Component]: return out -# npx forms we recognise: -# npx -y @scope/pkg@1.2.3 -# npx --yes pkg@1.2.3 -# npx pkg@1.2.3 [...args] -# We deliberately don't try to resolve unversioned names — that maps to -# "latest" at runtime and isn't a stable audit subject. +# Recognised pinned refs: ``npx [-y|--yes] [@scope/]pkg@1.2.3`` and ``uvx [--with] pkg==1.2.3``. +# Unversioned names map to "latest" at runtime and aren't a stable audit subject. _NPX_PKG = re.compile(r"^(@[A-Za-z0-9._-]+/[A-Za-z0-9._-]+|[A-Za-z0-9._-]+)@([A-Za-z0-9._+-]+)$") -# uvx forms: -# uvx pkg==1.2.3 -# uvx --with pkg==1.2.3 entrypoint _UVX_PKG = re.compile(r"^([A-Za-z0-9][A-Za-z0-9._-]*)==([A-Za-z0-9._+!-]+)$") # launcher basename -> (package-ref regex, OSV ecosystem) _MCP_LAUNCHERS = {"npx": (_NPX_PKG, "npm"), "uvx": (_UVX_PKG, "PyPI")} def _extract_mcp_component(server_name: str, command: str, args: list[str]) -> Optional[Component]: - """Best-effort: parse `command/args` into a (name, version, ecosystem). - - Returns None when the entry doesn't pin an auditable version (local paths, Docker images, - unversioned npx, ...); the audit stays silent rather than guess. + """Parse `command/args` into a Component, or None when the entry doesn't pin an auditable + version (local paths, Docker images, unversioned npx, ...) — stay silent rather than guess. """ cmd = (command or "").strip().lower() launcher = next((k for k in _MCP_LAUNCHERS if cmd.endswith(k)), None) # any prefix path @@ -201,21 +152,15 @@ def _discover_mcp() -> list[Component]: from hermes_cli.mcp_config import _get_mcp_servers except Exception: return [] - servers = _get_mcp_servers() if not isinstance(servers, dict): return [] - out: list[Component] = [] - for name, cfg in servers.items(): - if not isinstance(cfg, dict) or not isinstance(cfg.get("args") or [], list): - continue - comp = _extract_mcp_component(name, cfg.get("command", "") or "", [str(a) for a in cfg.get("args") or []]) - if comp: - out.append(comp) - return out - - -# ─── OSV client ─────────────────────────────────────────────────────────────── + comps = ( + _extract_mcp_component(name, cfg.get("command", "") or "", [str(a) for a in cfg.get("args") or []]) + for name, cfg in servers.items() + if isinstance(cfg, dict) and isinstance(cfg.get("args") or [], list) + ) + return [c for c in comps if c] _HTTP_ERRORS = (urllib.error.URLError, TimeoutError, ConnectionError) @@ -223,12 +168,8 @@ _HTTP_ERRORS = (urllib.error.URLError, TimeoutError, ConnectionError) def _http_json(url: str, payload: Optional[dict] = None) -> dict: """GET ``url`` (or POST ``payload`` as JSON when given) and decode the JSON body.""" - if payload is None: - req = urllib.request.Request(url, method="GET") - else: - req = urllib.request.Request( - url, data=json.dumps(payload).encode("utf-8"), headers={"Content-Type": "application/json"}, method="POST" - ) + req = urllib.request.Request(url, method="GET") if payload is None else urllib.request.Request( + url, data=json.dumps(payload).encode("utf-8"), method="POST", headers={"Content-Type": "application/json"}) with urllib.request.urlopen(req, timeout=HTTP_TIMEOUT) as resp: return json.loads(resp.read().decode("utf-8")) @@ -238,130 +179,77 @@ def _osv_query_batch(components: list[Component]) -> dict[Component, list[str]]: findings: dict[Component, list[str]] = {} for chunk_start in range(0, len(components), OSV_BATCH_MAX): chunk = components[chunk_start:chunk_start + OSV_BATCH_MAX] - payload = { - "queries": [{"package": {"name": c.name, "ecosystem": c.ecosystem}, "version": c.version} for c in chunk] - } + payload = {"queries": [{"package": {"name": c.name, "ecosystem": c.ecosystem}, "version": c.version} for c in chunk]} try: resp = _http_json(OSV_BATCH_URL, payload) except _HTTP_ERRORS as exc: raise RuntimeError(f"OSV batch query failed: {exc}") from exc for comp, result in zip(chunk, resp.get("results") or []): - ids = [v.get("id") for v in (result or {}).get("vulns") or [] if v.get("id")] - if ids: + if ids := [v.get("id") for v in (result or {}).get("vulns") or [] if v.get("id")]: findings[comp] = ids return findings def _osv_severity_from_record(record: dict) -> str: - """Extract CVSS-derived severity tier from an OSV vuln record.""" - # OSV puts CVSS in `severity` (top-level or per-affected) and a - # human-readable bucket in `database_specific.severity` for GHSAs. - # Top-level `severity` holds CVSS vector strings we can't tier without a lib; fall back to - # the per-affected ecosystem_specific bucket after the GHSA database_specific one. + """CVSS-derived severity tier from an OSV vuln record. + + Top-level ``severity`` holds CVSS vector strings we can't tier without a lib, so use the GHSA + ``database_specific`` bucket first, then the per-affected ``ecosystem_specific`` one. + """ candidates = [(record.get("database_specific") or {}).get("severity")] + [ - (entry.get("ecosystem_specific") or {}).get("severity") for entry in record.get("affected") or [] - ] - for sev in candidates: - if isinstance(sev, str) and sev.strip().upper() in SEVERITY_ORDER: - return sev.strip().upper() - return "UNKNOWN" + (entry.get("ecosystem_specific") or {}).get("severity") for entry in record.get("affected") or []] + tiers = (sev.strip().upper() for sev in candidates if isinstance(sev, str)) + return next((tier for tier in tiers if tier in SEVERITY_ORDER), "UNKNOWN") def _osv_fixed_versions(record: dict) -> list[str]: - fixes = [ - str(event["fixed"]) - for entry in record.get("affected") or [] - for rng in entry.get("ranges") or [] - for event in rng.get("events") or [] - if "fixed" in event - ] + fixes = [str(event["fixed"]) for entry in record.get("affected") or [] for rng in entry.get("ranges") or [] + for event in rng.get("events") or [] if "fixed" in event] return list(dict.fromkeys(fixes)) # dedupe, preserve order def _osv_fetch_details(vuln_ids: Iterable[str]) -> dict[str, Vulnerability]: """Fetch summary/severity for each unique vuln id, in parallel.""" - unique = sorted({vid for vid in vuln_ids if vid}) - if not unique: - return {} - def _fetch_one(vid: str) -> Vulnerability: try: rec = _http_json(OSV_VULN_URL.format(vid=vid)) except _HTTP_ERRORS: return Vulnerability(osv_id=vid) - return Vulnerability( - osv_id=vid, - severity=_osv_severity_from_record(rec), - summary=(rec.get("summary") or "").strip(), - fixed_versions=_osv_fixed_versions(rec), - ) + return Vulnerability(vid, _osv_severity_from_record(rec), (rec.get("summary") or "").strip(), _osv_fixed_versions(rec)) + unique = sorted({vid for vid in vuln_ids if vid}) + if not unique: + return {} with concurrent.futures.ThreadPoolExecutor(max_workers=DETAIL_PARALLELISM) as pool: return {vuln.osv_id: vuln for vuln in pool.map(_fetch_one, unique)} -# ─── Orchestration ──────────────────────────────────────────────────────────── - - def _discover_components( - *, - skip_venv: bool = False, - skip_plugins: bool = False, - skip_mcp: bool = False, - hermes_home: Optional[Path] = None, + *, skip_venv: bool = False, skip_plugins: bool = False, skip_mcp: bool = False, hermes_home: Optional[Path] = None ) -> list[Component]: """Discover all scannable components across the enabled sources.""" home = hermes_home or Path(get_hermes_home()) - components: list[Component] = [] - if not skip_venv: - components.extend(_discover_venv()) - if not skip_plugins: - components.extend(_discover_plugins(home)) - if not skip_mcp: - components.extend(_discover_mcp()) - return components + sources = ((skip_venv, _discover_venv), (skip_plugins, lambda: _discover_plugins(home)), (skip_mcp, _discover_mcp)) + return [c for skip, discover in sources if not skip for c in discover()] -def run_audit( - *, - skip_venv: bool = False, - skip_plugins: bool = False, - skip_mcp: bool = False, - hermes_home: Optional[Path] = None, - components: Optional[list[Component]] = None, -) -> list[Finding]: - """Query OSV for the given (or freshly discovered) components. - - ``components`` lets callers that already ran discovery (e.g. for a count) reuse it instead - of scanning the venv/plugins/MCP config a second time. +def run_audit(*, components: Optional[list[Component]] = None, **discover_kwargs) -> list[Finding]: + """Query OSV for ``components`` (or discover them with ``discover_kwargs`` when None; passing + an already-discovered list avoids scanning the venv/plugins/MCP config a second time). """ if components is None: - components = _discover_components( - skip_venv=skip_venv, skip_plugins=skip_plugins, skip_mcp=skip_mcp, hermes_home=hermes_home - ) + components = _discover_components(**discover_kwargs) raw = _osv_query_batch(components) if components else {} if not raw: return [] details = _osv_fetch_details(vid for ids in raw.values() for vid in ids) - findings = [ - Finding(component=comp, vuln=details.get(vid) or Vulnerability(osv_id=vid)) - for comp, ids in raw.items() - for vid in ids - ] - findings.sort( - key=lambda f: ( - -SEVERITY_ORDER.get(f.vuln.severity, 0), - f.component.source, - f.component.name.lower(), - f.vuln.osv_id, - ) - ) + findings = [Finding(comp, details.get(vid) or Vulnerability(osv_id=vid)) for comp, ids in raw.items() for vid in ids] + findings.sort(key=lambda f: ( + -SEVERITY_ORDER.get(f.vuln.severity, 0), f.component.source, f.component.name.lower(), f.vuln.osv_id + )) return findings -# ─── Rendering ──────────────────────────────────────────────────────────────── - - def _render_human(findings: list[Finding], total_components: int) -> str: if not findings: return f"No known vulnerabilities found across {total_components} component(s)." @@ -369,14 +257,15 @@ def _render_human(findings: list[Finding], total_components: int) -> str: lines = [f"Found {len(findings)} known vulnerability finding(s) across {total_components} component(s):", ""] last_source = None for f in findings: - if f.component.source != last_source: - lines.append(f"[{f.component.source}]") - last_source = f.component.source - lines.append(f" {f.vuln.severity.ljust(8)} {f.component.name}=={f.component.version} {f.vuln.osv_id}") - if summary := f.vuln.summary: + c, v = f.component, f.vuln + if c.source != last_source: + lines.append(f"[{c.source}]") + last_source = c.source + lines.append(f" {v.severity.ljust(8)} {c.name}=={c.version} {v.osv_id}") + if summary := v.summary: lines.append(f" {summary if len(summary) <= 100 else summary[:97] + '...'}") - if f.vuln.fixed_versions: - lines.append(f" fixed in: {', '.join(f.vuln.fixed_versions[:3])}") + if v.fixed_versions: + lines.append(f" fixed in: {', '.join(v.fixed_versions[:3])}") return "\n".join(lines) @@ -384,52 +273,31 @@ def _render_json(findings: list[Finding], total_components: int) -> str: payload = { "total_components_scanned": total_components, "finding_count": len(findings), - "findings": [ - { - "package": f.component.name, - "version": f.component.version, - "ecosystem": f.component.ecosystem, - "source": f.component.source, - "vuln_id": f.vuln.osv_id, - "severity": f.vuln.severity, - "summary": f.vuln.summary, - "fixed_versions": f.vuln.fixed_versions, - } - for f in findings - ], + "findings": [{ + "package": f.component.name, "version": f.component.version, + "ecosystem": f.component.ecosystem, "source": f.component.source, + "vuln_id": f.vuln.osv_id, "severity": f.vuln.severity, + "summary": f.vuln.summary, "fixed_versions": f.vuln.fixed_versions, + } for f in findings], } return json.dumps(payload, indent=2) -# ─── CLI entrypoint ─────────────────────────────────────────────────────────── - - def cmd_security_audit(args: argparse.Namespace) -> int: """Implementation of `hermes security audit`.""" home = Path(get_hermes_home()) - skip_venv = bool(getattr(args, "skip_venv", False)) - skip_plugins = bool(getattr(args, "skip_plugins", False)) - skip_mcp = bool(getattr(args, "skip_mcp", False)) output_json = bool(getattr(args, "json", False)) fail_on = (getattr(args, "fail_on", None) or "critical").upper() if fail_on not in SEVERITY_ORDER: - print( - f"unknown --fail-on value: {fail_on.lower()} " - f"(choose from: low, moderate, high, critical)", - file=sys.stderr, - ) + print(f"unknown --fail-on value: {fail_on.lower()} (choose from: low, moderate, high, critical)", file=sys.stderr) return 2 - components = _discover_components( - skip_venv=skip_venv, skip_plugins=skip_plugins, skip_mcp=skip_mcp, hermes_home=home - ) + skips = {k: bool(getattr(args, k, False)) for k in ("skip_venv", "skip_plugins", "skip_mcp")} + components = _discover_components(hermes_home=home, **skips) total = len(components) if total == 0: - print( - json.dumps({"total_components_scanned": 0, "finding_count": 0, "findings": []}) - if output_json - else "No components discovered (everything skipped, or empty environment)." - ) + print(json.dumps({"total_components_scanned": 0, "finding_count": 0, "findings": []}) if output_json + else "No components discovered (everything skipped, or empty environment).") return 0 try: diff --git a/hermes_cli/security_audit_startup.py b/hermes_cli/security_audit_startup.py index 059e6f8936..5f91aef8e6 100644 --- a/hermes_cli/security_audit_startup.py +++ b/hermes_cli/security_audit_startup.py @@ -1,11 +1,8 @@ """Startup security posture audit (warn-on-load, never blocks). -Surfaces dangerous host / deployment posture at process start so operators get an at-a-glance -"you're exposed" signal. These checks are advisory: they emit ``logger.warning`` records and return -human-readable strings; they never raise or block startup. - -Checks (each is independent and fail-safe — any internal error is swallowed and simply yields no -finding): +Surfaces dangerous host / deployment posture at process start. Checks are advisory and +independently fail-safe: they return human-readable strings, any internal error yields no finding, +and nothing here ever raises or blocks startup. """ from __future__ import annotations @@ -17,8 +14,7 @@ from typing import Optional logger = logging.getLogger("hermes.security_audit") -# Sentinel so the audit only runs once per process even if both the CLI and -# gateway startup paths call it. +# The audit runs once per process even if both the CLI and gateway startup paths call it. _AUDIT_RAN = False @@ -32,24 +28,18 @@ def _is_root() -> bool: def _running_as_root() -> Optional[str]: - if not _is_root(): - return None - return ( - "Running as ROOT. The agent's terminal/file tools execute with full " - "root privileges — a single prompt-injection or exposed endpoint is a " - "full host compromise. Run Hermes as an unprivileged user (or in a " - "sandboxed terminal backend / container with a non-root user)." - ) + return None if not _is_root() else ( + "Running as ROOT. The agent's terminal/file tools execute with full root privileges — a single " + "prompt-injection or exposed endpoint is a full host compromise. Run Hermes as an unprivileged user " + "(or in a sandboxed terminal backend / container with a non-root user).") def _iter_sshd_config_lines() -> list[str]: - """Yield non-comment lines from sshd_config + its drop-in directory.""" + """Non-comment lines from sshd_config + its drop-in directory.""" lines: list[str] = [] paths: list[Path] = [Path("/etc/ssh/sshd_config")] try: - d = Path("/etc/ssh/sshd_config.d") - if d.is_dir(): - paths.extend(sorted(d.glob("*.conf"))) + paths.extend(sorted(Path("/etc/ssh/sshd_config.d").glob("*.conf"))) except Exception: pass for p in paths: @@ -62,29 +52,19 @@ def _iter_sshd_config_lines() -> list[str]: def _ssh_password_auth_enabled() -> Optional[str]: - """Warn when an SSH daemon has password authentication enabled. - - Password auth on a public SSH daemon is the classic brute-force surface and pairs badly with a - root-capable agent box. POSIX-only; returns None when there's no sshd config to read (e.g. - Windows, or SSH not installed). + """Warn when sshd has password auth enabled — the classic brute-force surface, which pairs + badly with a root-capable agent box. None when there is no sshd config to read. """ lines = _iter_sshd_config_lines() if not lines: return None # Last directive wins in sshd_config. Default (no directive) is "yes". - directives = [ - m.group(1).lower() - for m in (re.match(r"(?i)^PasswordAuthentication\s+(\w+)", line) for line in lines) - if m - ] + directives = [m.group(1).lower() for m in map(re.compile(r"(?i)^PasswordAuthentication\s+(\w+)").match, lines) if m] if directives and directives[-1] == "no": return None qualifier = "" if directives else " (default — no explicit directive)" - return ( - f"SSH password authentication is ENABLED{qualifier}. Password auth is " - "brute-forceable and dangerous on an internet-facing box. Set " - "'PasswordAuthentication no' in sshd_config and use key-based auth." - ) + return (f"SSH password authentication is ENABLED{qualifier}. Password auth is brute-forceable and dangerous " + "on an internet-facing box. Set 'PasswordAuthentication no' in sshd_config and use key-based auth.") def _in_container() -> bool: @@ -101,11 +81,8 @@ def _in_container() -> bool: def _path_is_mounted(path: Path) -> bool: - """True if *path* sits on (or under) a real mount point per /proc/mounts. - - Container overlay/root filesystems are ephemeral; a bind/volume mount over the data dir - shows up as a distinct mount entry. The path counts as persisted when a mountpoint at or - above it is NOT the container root overlay. + """True if *path* sits on a persistent mount per /proc/mounts, i.e. the most specific + mountpoint at or above it is NOT the ephemeral container root overlay/tmpfs. """ try: target = path.resolve() @@ -115,8 +92,7 @@ def _path_is_mounted(path: Path) -> bool: mounts = Path("/proc/mounts").read_text(encoding="utf-8", errors="replace").splitlines() except Exception: return True # can't tell — fail safe (no warning) - # (mountpoint, fstype) entries at or above target; the longest (most specific) wins, - # first one on ties. + # (mountpoint, fstype) entries at or above target; the longest wins, first one on ties. covering = [ (Path(parts[1]), parts[2]) for parts in (line.split() for line in mounts) @@ -125,7 +101,6 @@ def _path_is_mounted(path: Path) -> bool: if not covering: return True best_fstype = max(covering, key=lambda entry: len(str(entry[0])))[1] - # overlay / tmpfs over the data dir = ephemeral container storage. return best_fstype not in ("overlay", "tmpfs", "aufs") @@ -138,23 +113,16 @@ def _container_no_volume_mount(hermes_home: Optional[Path]) -> Optional[str]: hermes_home = get_hermes_home() if _path_is_mounted(hermes_home): # any error propagates to run_security_audit (= no finding) return None - return ( - f"Running in a container but the data dir ({hermes_home}) is NOT on a " - "persistent volume mount — sessions, memory, skills, and API keys are " - "ephemeral and lost on container restart. Mount a host volume over the " - "HERMES_HOME data directory." - ) + return (f"Running in a container but the data dir ({hermes_home}) is NOT on a persistent volume mount — " + "sessions, memory, skills, and API keys are ephemeral and lost on container restart. Mount a host " + "volume over the HERMES_HOME data directory.") def _network_listener_without_auth(config: Optional[dict]) -> list[str]: - """Warn about network-accessible gateway listeners with no auth. - - Covers the API server (no API_SERVER_KEY) and the dashboard (non-loopback bind with no auth - provider). Read-only against config + env; overlaps the hard fail-closed guards but surfaces the - posture proactively at startup. + """Warn about a network-accessible API server with no API_SERVER_KEY. Read-only against + config + env; overlaps the hard fail-closed guards but surfaces the posture at startup. """ - # Any error (incl. an unimportable gateway package) propagates to run_security_audit, - # which treats it as "no finding". + # Any error (incl. an unimportable gateway package) propagates to run_security_audit. from gateway.platforms.base import is_network_accessible plats = (config or {}).get("platforms") or {} @@ -166,29 +134,18 @@ def _network_listener_without_auth(config: Optional[dict]) -> list[str]: key = extra.get("key") or os.environ.get("API_SERVER_KEY", "") if not is_network_accessible(str(host)) or str(key).strip(): return [] - return [ - f"OpenAI-compatible API server is network-accessible ({host}) " - "with NO API_SERVER_KEY. It dispatches terminal-capable agent " - "work — an unauthenticated network endpoint is remote code " - "execution. Set a strong API_SERVER_KEY." - ] + return [f"OpenAI-compatible API server is network-accessible ({host}) with NO API_SERVER_KEY. It dispatches " + "terminal-capable agent work — an unauthenticated network endpoint is remote code execution. " + "Set a strong API_SERVER_KEY."] -def run_security_audit( - *, hermes_home: Optional[Path] = None, config: Optional[dict] = None -) -> list[str]: - """Run all checks and return a list of human-readable warning strings. - - Pure: no logging, no side effects. Each check is independently fail-safe. Used directly by - tests; the logging wrapper is :func:`log_startup_security_warnings`. +def run_security_audit(*, hermes_home: Optional[Path] = None, config: Optional[dict] = None) -> list[str]: + """Run all checks and return human-readable warning strings. Pure (no logging); a check that + raises simply contributes no finding. """ findings: list[str] = [] - for check in ( - _running_as_root, - _ssh_password_auth_enabled, - lambda: _container_no_volume_mount(hermes_home), - lambda: _network_listener_without_auth(config), - ): + for check in (_running_as_root, _ssh_password_auth_enabled, lambda: _container_no_volume_mount(hermes_home), + lambda: _network_listener_without_auth(config)): try: r = check() except Exception: @@ -201,25 +158,16 @@ def run_security_audit( def log_startup_security_warnings( - *, - hermes_home: Optional[Path] = None, - config: Optional[dict] = None, - force: bool = False, + *, hermes_home: Optional[Path] = None, config: Optional[dict] = None, force: bool = False ) -> list[str]: - """Run the audit once per process and emit each finding via logger.warning. - - Returns the findings (also for tests). Never raises. Idempotent unless ``force=True`` (used by - tests). - """ + """Run the audit once per process (``force=True`` re-runs) and log each finding as a warning.""" global _AUDIT_RAN if _AUDIT_RAN and not force: return [] _AUDIT_RAN = True findings = run_security_audit(hermes_home=hermes_home, config=config) if findings: - logger.warning( - "Security posture audit found %d issue(s) — review your deployment:", len(findings) - ) + logger.warning("Security posture audit found %d issue(s) — review your deployment:", len(findings)) for i, f in enumerate(findings, 1): logger.warning(" [security %d/%d] %s", i, len(findings), f) return findings diff --git a/hermes_cli/service_manager.py b/hermes_cli/service_manager.py index ab0c8ba5c1..c338a6bfc7 100644 --- a/hermes_cli/service_manager.py +++ b/hermes_cli/service_manager.py @@ -1,4 +1,4 @@ -"""Abstract service manager interface.""" +"""Abstract service manager interface + systemd/launchd/Windows/s6 backends.""" from __future__ import annotations import json @@ -13,86 +13,58 @@ from typing import Literal, Protocol, runtime_checkable ServiceManagerKind = Literal["systemd", "launchd", "windows", "s6", "none"] -# Profile name → service directory mapping. Profile names must be safe -# as filesystem directory names because the s6 backend creates a service -# directory at ``/gateway-/``. We reject anything that -# could traverse paths, span filesystems, or break s6's own naming rules. +# Profile names become s6 service directory names (``/gateway-/``), so they +# must not traverse paths, span filesystems, or break s6's own naming rules. _VALID_PROFILE_RE = re.compile(r"^[a-z0-9][a-z0-9_-]*$") _MAX_PROFILE_LEN = 251 # s6-svscan default name_max def validate_profile_name(name: str) -> None: - """Raise ValueError if ``name`` is not usable as a profile name. - - Profile names are used as s6 service directory names, so they must match a conservative subset - of filesystem-safe characters. Reject empty strings, uppercase, paths-traversal sequences, and - anything longer than s6's default ``name_max``. - """ + """Raise ValueError unless ``name`` is a filesystem/s6-safe profile name.""" if not name: raise ValueError("profile name must not be empty") if len(name) > _MAX_PROFILE_LEN: - raise ValueError( - f"profile name too long ({len(name)} > {_MAX_PROFILE_LEN})" - ) + raise ValueError(f"profile name too long ({len(name)} > {_MAX_PROFILE_LEN})") if not _VALID_PROFILE_RE.match(name): - raise ValueError( - f"profile name must match [a-z0-9][a-z0-9_-]*, got {name!r}" - ) + raise ValueError(f"profile name must match [a-z0-9][a-z0-9_-]*, got {name!r}") @runtime_checkable class ServiceManager(Protocol): - """Abstract interface for init-system-specific service operations. + """Init-system-specific service operations. - Lifecycle methods (start/stop/restart/is_running) exist on every backend. Runtime registration - (register/unregister/list_profile_gateways) is s6-only — callers MUST check - ``supports_runtime_registration()`` before invoking it. + Lifecycle methods exist on every backend. Runtime registration (register/unregister/ + list_profile_gateways) is s6-only — check ``supports_runtime_registration()`` first. """ kind: ServiceManagerKind - # Lifecycle of a pre-declared service. def start(self, name: str) -> None: ... def stop(self, name: str) -> None: ... def restart(self, name: str) -> None: ... def is_running(self, name: str) -> bool: ... - # Runtime registration (s6 only). def supports_runtime_registration(self) -> bool: ... def register_profile_gateway( - self, - profile: str, - *, - extra_env: dict[str, str] | None = None, - start_now: bool = True, + self, profile: str, *, extra_env: dict[str, str] | None = None, start_now: bool = True ) -> None: ... def unregister_profile_gateway(self, profile: str) -> None: ... def list_profile_gateways(self) -> list[str]: ... def detect_service_manager() -> ServiceManagerKind: - """Detect which service manager is available in this environment. + """Return "s6" (s6-svscan is PID 1), "windows", "launchd", "systemd" (working bus) or "none". - Returns "s6" (s6-svscan is PID 1), "windows", "launchd", "systemd" (working bus) or "none" - (Termux, sandboxes). Does NOT replace ``supports_systemd_services()`` for host call sites; it - exists for backend-agnostic code (profile hooks, the s6 dispatch in ``hermes gateway``). + Does NOT replace ``supports_systemd_services()`` for host call sites; it exists for + backend-agnostic code (profile hooks, the s6 dispatch in ``hermes gateway``). """ - # Imports deferred so importing this module doesn't drag in the - # whole gateway dependency graph for callers that only need the - # Protocol type or validate_profile_name(). - from hermes_cli.gateway import ( - is_macos, - is_windows, - supports_systemd_services, - ) - - # Gate on _s6_running() alone (PID 1 comm == s6-svscan AND /run/s6/basedir), - # NOT is_container(): the latter only detects Docker/Podman/lxc, so it is - # False on Fly's Firecracker microVMs even though s6-overlay is PID 1 there. - # That false negative made the whole s6 dispatch path inert on Fly, so - # `hermes gateway start/stop/restart` fell through to host code that spawns - # a foreground gateway competing with the supervised one. _s6_running() is - # already an s6-overlay-specific signal, so the container gate was redundant. + # Deferred so importing this module (Protocol type, validate_profile_name) doesn't drag in + # the whole gateway dependency graph. + from hermes_cli.gateway import is_macos, is_windows, supports_systemd_services + # Gate on _s6_running() alone, NOT is_container(): the latter only detects Docker/Podman/lxc + # and is False on Fly's Firecracker microVMs even though s6-overlay is PID 1 there — that + # made the s6 dispatch inert on Fly, so `hermes gateway start` spawned a foreground gateway + # competing with the supervised one. if _s6_running(): return "s6" if is_windows(): @@ -105,39 +77,31 @@ def detect_service_manager() -> ServiceManagerKind: def _s6_running() -> bool: - """True when s6-svscan is running as PID 1 in this container. + """True when s6-svscan is PID 1 in this container. - Must work for root AND the unprivileged hermes user: ``/proc/1/exe`` is unreadable for other - UIDs and ``resolve()`` silently yields the literal ``exe``, which made runtime registration - inert in production. Probe the world-readable ``/proc/1/comm`` and ``/run/s6/basedir`` instead; - both are required since either alone can false-positive. + Must work for the unprivileged hermes user too: ``/proc/1/exe`` is unreadable for other UIDs + (``resolve()`` silently yields the literal ``exe``), which made runtime registration inert in + production. Probe the world-readable ``/proc/1/comm`` AND ``/run/s6/basedir`` — either alone + can false-positive. """ try: comm = Path("/proc/1/comm").read_text(encoding="utf-8").strip() except OSError: return False - if comm != "s6-svscan": - return False - return Path("/run/s6/basedir").is_dir() + return comm == "s6-svscan" and Path("/run/s6/basedir").is_dir() # --------------------------------------------------------------------------- -# Backend wrappers -# -# These adapters are thin facades over the existing module-level functions -# in ``hermes_cli.gateway`` (systemd/launchd) and ``hermes_cli.gateway_windows`` -# (Windows Scheduled Tasks). The protocol's ``name`` parameter is currently -# unused for host backends — they operate on whichever profile is currently -# active (set via the ``hermes -p `` flag before the call). This -# matches existing host-side semantics; the parameter shape is designed -# for s6 where each profile maps to a distinct service directory. +# Host backends: thin facades over ``hermes_cli.gateway`` (systemd/launchd) and +# ``hermes_cli.gateway_windows``. The protocol's ``name`` parameter is unused here — host backends +# operate on the currently active profile (``hermes -p ``); the shape exists for s6 where +# each profile maps to a distinct service directory. # --------------------------------------------------------------------------- class _HostServiceManager: - """Table-driven host backend: ``start``/``stop``/``restart`` resolve to - ``<_fn_prefix>`` on the ``hermes_cli.<_backend>`` submodule at call time - (lazy import; tests monkeypatch the submodule or its functions). Runtime + """``start``/``stop``/``restart`` resolve to ``<_fn_prefix>`` on ``hermes_cli.<_backend>`` + at call time (lazy import; tests monkeypatch the submodule or its functions). Runtime registration is unsupported on every host backend. """ @@ -147,9 +111,7 @@ class _HostServiceManager: def _backend_module(self): import importlib - import hermes_cli - importlib.import_module(f"hermes_cli.{self._backend}") return getattr(hermes_cli, self._backend) @@ -168,34 +130,26 @@ class _HostServiceManager: def supports_runtime_registration(self) -> bool: return False - def register_profile_gateway( - self, - profile: str, - *, - extra_env: dict[str, str] | None = None, - start_now: bool = True, - ) -> None: - raise NotImplementedError( - f"{type(self).__name__} does not support runtime profile " - "gateway registration (container-only feature)" + def _unsupported(self, verb: str) -> NotImplementedError: + return NotImplementedError( + f"{type(self).__name__} does not support runtime profile gateway {verb} (container-only feature)" ) + def register_profile_gateway( + self, profile: str, *, extra_env: dict[str, str] | None = None, start_now: bool = True + ) -> None: + raise self._unsupported("registration") + def unregister_profile_gateway(self, profile: str) -> None: - raise NotImplementedError( - f"{type(self).__name__} does not support runtime profile " - "gateway unregistration (container-only feature)" - ) + raise self._unsupported("unregistration") def list_profile_gateways(self) -> list[str]: return [] class SystemdServiceManager(_HostServiceManager): - """Thin wrapper around the ``systemd_*`` functions in hermes_cli.gateway. - - Existing host call sites keep using those functions directly; this wrapper exists for - backend-agnostic code such as the profile create/delete hooks. - """ + """Wraps the ``systemd_*`` functions in hermes_cli.gateway (host call sites still use those + directly; this exists for backend-agnostic code such as the profile create/delete hooks).""" kind: ServiceManagerKind = "systemd" _backend = "gateway" @@ -207,7 +161,7 @@ class SystemdServiceManager(_HostServiceManager): class LaunchdServiceManager(_HostServiceManager): - """Thin wrapper around the ``launchd_*`` functions in hermes_cli.gateway.""" + """Wraps the ``launchd_*`` functions in hermes_cli.gateway.""" kind: ServiceManagerKind = "launchd" _backend = "gateway" @@ -218,12 +172,10 @@ class LaunchdServiceManager(_HostServiceManager): class WindowsServiceManager(_HostServiceManager): - """Thin wrapper around ``hermes_cli.gateway_windows`` (Scheduled Task / Startup-folder - fallback). + """Wraps ``hermes_cli.gateway_windows`` (Scheduled Task / Startup-folder fallback). - A Scheduled Task is not a true init service, but the lifecycle protocol is the same. ``install`` - takes Windows-specific kwargs (start_now, start_on_login, elevated_handoff) passed straight - through — non-Windows callers must never call ``install`` here. + Not a true init service but the lifecycle protocol is the same. ``install`` takes + Windows-specific kwargs passed straight through — non-Windows callers must never call it. """ kind: ServiceManagerKind = "windows" @@ -238,10 +190,7 @@ class WindowsServiceManager(_HostServiceManager): elevated_handoff: bool = False, ) -> None: self._backend_module().install( - force=force, - start_now=start_now, - start_on_login=start_on_login, - elevated_handoff=elevated_handoff, + force=force, start_now=start_now, start_on_login=start_on_login, elevated_handoff=elevated_handoff ) def is_running(self, name: str) -> bool: @@ -260,18 +209,13 @@ def get_service_manager() -> ServiceManager: # --------------------------------------------------------------------------- -# S6ServiceManager (container-only) -# -# Per-profile gateways are registered dynamically when `hermes profile create` -# runs inside the container (Phase 4). Static services (main-hermes, dashboard) -# live in /etc/s6-overlay/s6-rc.d/ and are NOT managed by this class — they're -# part of the image, not runtime-created. +# S6ServiceManager (container-only). Per-profile gateways are registered dynamically by +# `hermes profile create` inside the container. Static services (main-hermes, dashboard) live in +# /etc/s6-overlay/s6-rc.d/ as part of the image and are NOT managed here. # --------------------------------------------------------------------------- - -# s6-overlay's dynamic scandir for runtime-registered services. Lives on -# tmpfs and is the directory s6-svscan watches. Writes here trigger -# automatic supervision on the next rescan. +# s6-overlay's dynamic scandir (tmpfs) that s6-svscan watches; writes here trigger supervision +# on the next rescan. S6_DYNAMIC_SCANDIR = Path("/run/service") S6_SERVICE_PREFIX = "gateway-" @@ -284,10 +228,9 @@ def _profile_from_service(name: str) -> str: def _profile_dir_for_gateway_service(name: str) -> Path: """Resolve ``gateway-`` to its persistent profile directory. - s6 lifecycle commands may be invoked from any active profile, including ``gateway stop --all``. - Do not write the caller's HERMES_HOME blindly; derive the shared profile root from the current - HERMES_HOME and map the service suffix to either the root default profile or - ``/profiles/``. + s6 lifecycle commands may run from any active profile (``gateway stop --all``), so never write + the caller's HERMES_HOME blindly: derive the shared profile root and map the suffix to the root + default profile or ``/profiles/``. """ profile = _profile_from_service(name) validate_profile_name(profile) @@ -297,12 +240,9 @@ def _profile_dir_for_gateway_service(name: str) -> Path: def _write_gateway_desired_state(name: str, desired_state: str) -> None: - """Persist durable s6 gateway intent next to runtime status. - - ``gateway_state`` stays the volatile field written by the gateway; ``desired_state`` records the - operator's start/stop intent so boot reconciliation can restore s6 want-up/down after pod - recreation even if the last runtime state was transient. Best-effort: a failed write must not - block immediate s6 lifecycle control. + """Persist the operator's start/stop intent (``desired_state``) next to the gateway's volatile + ``gateway_state`` so boot reconciliation can restore s6 want-up/down after pod recreation. + Best-effort: a failed write must not block immediate s6 lifecycle control. """ profile_dir = _profile_dir_for_gateway_service(name) state_file = profile_dir / "gateway_state.json" @@ -324,18 +264,10 @@ def _write_gateway_desired_state(name: str, desired_state: str) -> None: return -# s6-overlay installs its binaries under /command/ and only adds that -# directory to PATH for processes started under the supervision tree -# (services started by s6-svscan, cont-init.d scripts, etc.). Code -# that runs via `docker exec` or any other out-of-tree entry point — -# notably our Phase 4 profile create/delete hooks — inherits the -# container's base PATH which does NOT include /command/. -# -# Rather than asking every caller to fix up its environment, the -# S6ServiceManager calls s6-* binaries by absolute path via this -# constant. We don't use `/usr/bin/s6-…` symlinks because the -# s6-overlay-symlinks-noarch tarball only links a subset, and we -# want every s6 invocation to be guaranteed-findable. +# s6-overlay installs its binaries under /command/ and only adds it to PATH inside the supervision +# tree. Out-of-tree entry points (``docker exec``, the profile create/delete hooks) inherit the base +# PATH, so every s6 invocation uses this absolute prefix. Not ``/usr/bin/s6-*``: the +# s6-overlay-symlinks-noarch tarball only links a subset. _S6_BIN_DIR = "/command" @@ -348,10 +280,8 @@ def _s6_run(cmd: str, *args: str, timeout: float = 5, check: bool = False): ) -# UID/GID of the in-image ``hermes`` user. Hardcoded to match what -# ``stage2-hook.sh`` enforces (the runtime invariant — see also -# tests/docker/test_uid_remap.py). The container starts s6-supervise -# under root and immediately drops to this UID via ``s6-setuidgid``. +# UID/GID of the in-image ``hermes`` user; hardcoded to match what ``stage2-hook.sh`` enforces +# (tests/docker/test_uid_remap.py). s6-supervise starts as root and drops via ``s6-setuidgid``. _HERMES_UID = 10000 _HERMES_GID = 10000 @@ -360,23 +290,19 @@ def _chown_hermes(path: Path) -> None: try: os.chown(path, _HERMES_UID, _HERMES_GID) except PermissionError: - # Running as the hermes user already — directory is hermes- - # owned by default. The chown is a no-op in that case, so - # swallowing this keeps both root and unprivileged callers - # on one code path. + # Already running as hermes → the dir is hermes-owned by default; swallowing keeps root + # and unprivileged callers on one code path. pass def _seed_supervise_skeleton(svc_dir: Path) -> None: - """Pre-create the ``supervise/`` and top-level ``event/`` skeleton inside a service directory, - owned by the hermes user. + """Pre-create hermes-owned ``supervise/`` and top-level ``event/`` inside a service directory. - s6-supervise runs as root and creates ``event/``/``supervise/`` mode 0700 plus the control FIFO - 0600, so the unprivileged hermes user gets EACCES on every ``s6-svc``/``s6-svstat``. s6 treats - EEXIST as success and skips its chown/chmod fix-up, so laying the skeleton down hermes-owned - before ``s6-svscanctl -a`` makes s6-supervise inherit it. The same skeleton is seeded under - ``log/`` when present, else unregister teardown EACCESes on the logger. Idempotent: existing - entries (possibly live FIFOs) are left untouched. + s6-supervise (root) creates ``event/``/``supervise/`` 0700 and the control FIFO 0600, so the + hermes user gets EACCES on every ``s6-svc``/``s6-svstat``. s6 treats EEXIST as success and skips + its chown/chmod fix-up, so seeding before ``s6-svscanctl -a`` makes s6-supervise inherit our + ownership. ``log/`` gets the same skeleton (its own supervise instance) or unregister teardown + EACCESes on the logger. Idempotent: existing entries (possibly live FIFOs) are left untouched. """ def _mkdir_owned(path: Path, mode: int) -> None: @@ -387,21 +313,14 @@ def _seed_supervise_skeleton(svc_dir: Path) -> None: _chown_hermes(path) def _seed(root: Path) -> None: - # Top-level event/ dir (this is the s6-svlisten1 event-subscription - # dir at the service root, distinct from supervise/event/). + # Service-root event/ is the s6-svlisten1 subscription dir, distinct from supervise/event/. _mkdir_owned(root / "event", 0o3730) - # supervise/ dir + its inner event/ dir. supervise = root / "supervise" _mkdir_owned(supervise, 0o755) _mkdir_owned(supervise / "event", 0o3730) - # supervise/control FIFO. Same EEXIST-safe pattern: if it's already - # there (s6-supervise has already started against this slot), leave - # it alone. The explicit chmod after mkfifo is required because - # mkfifo honors the process umask, which can strip group-write - # (e.g. the default 0022 on most dev hosts → 0o660 becomes 0o640). - # The container runs with umask 0 inside s6-overlay's stage2, but - # being defensive here keeps the helper consistent under any - # invocation context. + # EEXIST-safe FIFO: if s6-supervise already started against this slot, leave it. The + # explicit chmod is required because mkfifo honors the umask (0022 on dev hosts strips + # group-write → 0o640); stage2 runs umask 0 but be defensive for any invocation context. control = supervise / "control" if not control.exists(): os.mkfifo(control, 0o660) @@ -409,22 +328,14 @@ def _seed_supervise_skeleton(svc_dir: Path) -> None: _chown_hermes(control) _seed(svc_dir) - # If a log/ subdir is present (the canonical s6 logger pattern — - # see servicedir(7)), it gets its own s6-supervise instance and - # needs the same skeleton. Without this, unregister teardown - # would EACCES on the logger's root-owned supervise/ dir even - # when the parent slot's supervise/ is hermes-owned. log_dir = svc_dir / "log" if log_dir.is_dir(): _seed(log_dir) class S6Error(RuntimeError): - """Base error for S6ServiceManager lifecycle failures. - - Subclasses carry the slot name (and subprocess output where useful) so the CLI can render an - actionable message instead of a raw ``CalledProcessError`` traceback. - """ + """Base for S6ServiceManager lifecycle failures; carries the slot name so the CLI can render an + actionable message instead of a raw ``CalledProcessError``.""" def __init__(self, message: str, *, service: str | None = None) -> None: super().__init__(message) @@ -432,11 +343,8 @@ class S6Error(RuntimeError): class GatewayNotRegisteredError(S6Error): - """Raised when a lifecycle method targets a slot that doesn't exist. - - Carries the unprefixed profile name (not ``gateway-``) so callers can phrase "no such - gateway 'typo'". - """ + """A lifecycle method targeted a missing slot. ``profile`` is unprefixed so callers can phrase + "no such gateway 'typo'".""" def __init__(self, profile: str) -> None: self.profile = profile @@ -449,57 +357,42 @@ class GatewayNotRegisteredError(S6Error): class S6CommandError(S6Error): - """Raised when an s6 command fails for a reason other than a missing slot — e.g. permission denied - on the supervise control FIFO, or s6-svc returning a non-zero exit for an unexpected reason. - Carries the stderr from the failing command so callers can surface it. - """ + """An s6 command failed for a reason other than a missing slot (EACCES on the control FIFO, + unexpected non-zero exit); carries the command's stderr.""" - def __init__( - self, *, service: str, action: str, returncode: int, stderr: str, - ) -> None: + def __init__(self, *, service: str, action: str, returncode: int, stderr: str) -> None: self.action = action self.returncode = returncode self.stderr = stderr - message = ( - f"s6-svc {action} on {service!r} failed (rc={returncode})" - ) + message = f"s6-svc {action} on {service!r} failed (rc={returncode})" if stderr.strip(): message += f": {stderr.strip()}" super().__init__(message, service=service) class S6ServiceManager: - """Per-profile gateway supervision via s6-overlay. - - Only handles runtime-registered services under ``S6_DYNAMIC_SCANDIR``. Static services (main- - hermes, dashboard) are managed by s6-rc at image-build time and are out of scope. - """ + """Per-profile gateway supervision via s6-overlay, for runtime-registered services under + ``S6_DYNAMIC_SCANDIR`` only (static services are managed by s6-rc at image-build time).""" kind: ServiceManagerKind = "s6" def __init__(self, scandir: Path = S6_DYNAMIC_SCANDIR) -> None: self.scandir = scandir - # -- internal helpers -------------------------------------------------- - def _service_dir(self, profile: str) -> Path: validate_profile_name(profile) return self.scandir / f"{S6_SERVICE_PREFIX}{profile}" @staticmethod - def _render_run_script( - profile: str, - extra_env: dict[str, str], - ) -> str: - """Generate the run script for a profile-gateway s6 service. + def _render_run_script(profile: str, extra_env: dict[str, str]) -> str: + """Run script for a profile-gateway s6 service. - The script sources HERMES_HOME via with-contenv (honored at run time, not baked in at - registration), resets ``HOME`` before the privilege drop so root's HOME does not leak, - activates the venv, and drops to hermes. ``profile == "default"`` emits NO ``-p`` flag: it - is the sentinel for the root HERMES_HOME profile, and ``-p default`` would look up - ``profiles/default/`` instead. Port comes from the profile's own env (``API_SERVER_PORT``, - default 8642); two profiles that both leave it unset will collide, so give each a distinct - port. + Sources HERMES_HOME via with-contenv (run time, not baked in), resets ``HOME`` before the + privilege drop so root's HOME does not leak, activates the venv, drops to hermes. + ``profile == "default"`` emits NO ``-p`` flag: it is the sentinel for the root HERMES_HOME + profile and ``-p default`` would look up ``profiles/default/``. Port comes from the + profile's own env (``API_SERVER_PORT``, default 8642); two profiles that both leave it + unset collide. """ lines = [ "#!/command/with-contenv sh", @@ -511,53 +404,36 @@ class S6ServiceManager: ] for k, v in sorted(extra_env.items()): lines.append(f"export {k}={shlex.quote(v)}") - # Sentinel for the supervised-child path. Prevents recursive - # redirect when the supervised gateway re-enters - # `_gateway_command_inner` with subcmd == "run" — without it the - # supervisor would dispatch `gateway start` which would re-exec - # `gateway run --replace` which would re-dispatch `gateway - # start`, etc. See `_gateway_command_inner` for the matching - # guard. + # Supervised-child sentinel: without it the supervised gateway re-entering + # `_gateway_command_inner` with subcmd == "run" would dispatch `gateway start` → re-exec + # `gateway run --replace` → `gateway start` … (see the matching guard there). lines.append("export HERMES_S6_SUPERVISED_CHILD=1") - # Generalized supervisor marker (#74872) — same meaning for the - # profile-redirect guard in hermes_cli.main._apply_profile_override, - # kept alongside the s6-specific sentinel for back-compat. + # Generalized supervisor marker — same meaning for the profile-redirect guard in + # hermes_cli.main._apply_profile_override; kept alongside the s6 one for back-compat. lines.append("export HERMES_SUPERVISED_CHILD=1") - # ``--replace`` makes the supervised gateway authoritative for its - # profile's HERMES_HOME. Without it, a gateway started OUTSIDE s6 - # (a stray ``hermes gateway run`` from a shell, an agent action, or - # the Open WebUI helper) grabs the per-HERMES_HOME PID lock first; - # the supervised slot then execs a bare ``gateway run``, hits the - # "Another gateway instance is already running" guard, exits - # non-zero, and s6 restarts it — a restart loop that floods the - # log and never binds (NS-505). ``--replace`` - # instead reaps the stale holder (hardened takeover path: marker + - # SIGTERM→SIGKILL-with-confirmation + scoped-lock cleanup, see - # gateway/run.py) so s6 always wins. The HERMES_S6_SUPERVISED_CHILD - # sentinel above prevents the run→start→run redirect recursion. - # Each profile is scoped to its own HERMES_HOME and s6 guarantees a - # single supervised instance per slot, so there is no legitimate - # supervised sibling for ``--replace`` to clobber. + # ``--replace`` makes the supervised gateway authoritative for its HERMES_HOME. Without it + # a gateway started OUTSIDE s6 (stray ``hermes gateway run``, an agent action, the Open + # WebUI helper) grabs the PID lock first; the slot then hits "Another gateway instance is + # already running", exits non-zero, and s6 restarts it forever — a log-flooding loop that + # never binds. ``--replace`` reaps the stale holder (marker + SIGTERM→SIGKILL-with- + # confirmation + scoped-lock cleanup, see gateway/run.py) so s6 always wins; the sentinel + # above prevents the run→start→run recursion. s6 guarantees one supervised instance per + # slot, so there is no legitimate sibling for ``--replace`` to clobber. if profile == "default": gateway_cmd = "hermes gateway run --replace" else: gateway_cmd = f"hermes -p {shlex.quote(profile)} gateway run --replace" - # Skip the drop when already non-root (setgroups() lacks CAP_SETGID → - # s6 boot-loop). + # Skip the drop when already non-root (setgroups() lacks CAP_SETGID → s6 boot-loop). lines.append(f'[ "$(id -u)" = 0 ] || exec {gateway_cmd}') lines.append(f"exec s6-setuidgid hermes {gateway_cmd}") return "\n".join(lines) + "\n" @staticmethod def _render_finish_script() -> str: - """Generate the finish script for a profile-gateway s6 service. - - Exit 78 (EX_CONFIG, fatal config error) makes the script exit 125 so s6 stops restarting. A - clean exit 0 is an intentional stop, not a crash — restarting after it turns any normal exit - into a reconnect storm. Only non-zero, non-78 exits let s6 restart normally. - """ + """Finish script: exit 78 (EX_CONFIG, fatal config) and clean exit 0 (intentional stop — + restarting would turn every normal exit into a reconnect storm) both map to 125 so s6 stops + restarting; only other non-zero exits let s6 restart normally.""" from gateway.restart import GATEWAY_FATAL_CONFIG_EXIT_CODE - code = GATEWAY_FATAL_CONFIG_EXIT_CODE return ( "#!/command/with-contenv sh\n" @@ -576,26 +452,19 @@ class S6ServiceManager: @staticmethod def _render_log_run(profile: str) -> str: - """Generate the log/run script for a profile-gateway service. - - Output routing — the script is two action directives, applied per line, in order: - - ``T`` is non-sticky: it only prefixes lines for the next action directive. We deliberately - put ``T`` between ``1`` and the log dir (not before ``1``) so: - """ + """log/run script. s6-log directives apply per line in order; ``T`` (timestamp) is + non-sticky and only prefixes lines for the next action directive, so it sits between + ``1`` and the log dir rather than before ``1``.""" prof = shlex.quote(profile) return ( f"#!/command/with-contenv sh\n" f"# shellcheck shell=sh\n" f': "${{HERMES_HOME:=/opt/data}}"\n' f'log_dir="$HERMES_HOME/logs/gateways/{prof}"\n' - # Create the leaf and clear a stale s6-log lock as hermes when - # this script starts as root. Never chown or unlink hermes-writable - # volume paths from this restartable root-context script: - # log/supervise/control is hermes-owned, so an unprivileged user - # can race a pathname op through a symlink swap (CWE-59 / - # CWE-367). Parent logs/gateways is seeded hermes-owned at stage2 - # boot (#45258; tests/docker/test_log_dir_seed.py). + # Create the leaf and clear a stale s6-log lock AS HERMES when starting as root. Never + # chown/unlink hermes-writable volume paths from this restartable root-context script: + # an unprivileged user can race a pathname op through a symlink swap (CWE-59/CWE-367). + # Parent logs/gateways is seeded hermes-owned at stage2 boot (test_log_dir_seed.py). f'if [ "$(id -u)" = 0 ]; then\n' f' s6-setuidgid hermes mkdir -p "$log_dir"\n' f' s6-setuidgid hermes rm -f "$log_dir/lock"\n' @@ -611,38 +480,24 @@ class S6ServiceManager: # -- lifecycle --------------------------------------------------------- def _run_svc(self, action_flag: str, action_label: str, name: str) -> None: - """Shared lifecycle dispatch for start / stop / restart. - - Pre-empts a missing service dir with ``GatewayNotRegisteredError`` (clear "no such gateway" - instead of s6-svc's opaque failure) and wraps anything else (EACCES on the control FIFO, - timeout) in ``S6CommandError`` carrying return code and stderr. ``action_flag`` is the - ``s6-svc`` flag, ``action_label`` the human verb used in messages. - """ + """``s6-svc ``; a missing service dir raises ``GatewayNotRegisteredError`` + (instead of s6-svc's opaque failure) and any other failure ``S6CommandError``.""" service_dir = self.scandir / name if not service_dir.is_dir(): raise GatewayNotRegisteredError(_profile_from_service(name)) - try: _s6_run("s6-svc", action_flag, str(service_dir), check=True) except subprocess.CalledProcessError as exc: raise S6CommandError( - service=name, - action=action_label, - returncode=exc.returncode, - stderr=exc.stderr or "", + service=name, action=action_label, returncode=exc.returncode, stderr=exc.stderr or "" ) from exc def start(self, name: str) -> None: - """Bring up a registered service (``s6-svc -u``).""" self._run_svc("-u", "start", name) _write_gateway_desired_state(name, "running") def _supervised_pid(self, name: str) -> int | None: - """Return the PID of the supervised gateway process, or None. - - Parses ``s6-svstat`` output. Used to write the planned-stop marker before an operator stop - so the gateway classifies the SIGTERM as intentional. Best-effort: any failure returns None. - """ + """PID of the supervised gateway per ``s6-svstat``, or None on any failure.""" try: result = _s6_run("s6-svstat", str(self.scandir / name)) except (OSError, subprocess.SubprocessError): @@ -653,17 +508,13 @@ class S6ServiceManager: return int(m.group(1)) if m else None def stop(self, name: str) -> None: - """Bring down a registered service (``s6-svc -d``). - - Writes a planned-stop marker naming the supervised gateway PID BEFORE sending the down - command, so the gateway's shutdown handler recognises this SIGTERM as an operator-initiated - stop and persists ``gateway_state=stopped`` (respecting the explicit intent). - """ + """``s6-svc -d``, after writing a planned-stop marker for the supervised PID so the gateway's + shutdown handler classifies this SIGTERM as operator-initiated and persists + ``gateway_state=stopped``.""" pid = self._supervised_pid(name) if pid is not None: try: from gateway.status import write_planned_stop_marker - write_planned_stop_marker(pid) except Exception: pass @@ -671,12 +522,11 @@ class S6ServiceManager: _write_gateway_desired_state(name, "stopped") def restart(self, name: str) -> None: - """Restart a registered service (``s6-svc -t`` = SIGTERM).""" + """``s6-svc -t`` (SIGTERM).""" self._run_svc("-t", "restart", name) _write_gateway_desired_state(name, "running") def is_running(self, name: str) -> bool: - """True iff ``s6-svstat`` reports the service as up.""" result = _s6_run("s6-svstat", str(self.scandir / name)) return result.returncode == 0 and "up " in result.stdout @@ -686,42 +536,23 @@ class S6ServiceManager: return True def register_profile_gateway( - self, - profile: str, - *, - extra_env: dict[str, str] | None = None, - start_now: bool = True, + self, profile: str, *, extra_env: dict[str, str] | None = None, start_now: bool = True ) -> None: - """Create the s6 service directory for a profile gateway. + """Create the s6 service directory and ``s6-svscanctl -a`` so it is picked up immediately. - Triggers ``s6-svscanctl -a`` so s6-svscan picks the directory up immediately. With - ``start_now=False`` a ``down`` marker is written so the service stays stopped until an - explicit ``gateway start``. Raises ValueError on an invalid name or existing directory, - RuntimeError if ``s6-svscanctl`` fails. + ``start_now=False`` writes a ``down`` marker so the service stays stopped until an explicit + ``gateway start``. Raises ValueError on an invalid name or existing directory, RuntimeError + if ``s6-svscanctl`` fails. """ svc_dir = self._service_dir(profile) if svc_dir.exists(): - raise ValueError( - f"profile gateway {profile!r} already registered at {svc_dir}" - ) - - # Build the service directory atomically: write to a sibling - # temp dir, then rename. The staging name is DOT-PREFIXED - # (``.gateway-.tmp``) so s6-svscan ignores it while it - # is half-built: s6-svscan skips any scandir entry whose name - # begins with ``.``. Without the dot prefix, a concurrent - # ``s6-svscanctl -a`` rescan (fired by the cont-init reconciler - # registering ``gateway-default``, or by a sibling register) - # would supervise the still-being-seeded ``.tmp`` slot: it has a - # valid ``type``/``run`` by that point, so s6-supervise spawns - # AS ROOT and mkdir's ``supervise/`` root-owned 0700 — then this - # process's ``_seed_supervise_skeleton`` early-returns on the now- - # existing ``supervise/`` and the next ``mkdir supervise/event`` - # hits EACCES. That is the arm64-only CI flake on - # test_s6_unregister_removes_service_dir_in_live_container - # (the wider scheduling jitter on the native arm64 runner lets the - # rescan land inside the ~ms seed window). The atomic rename to - # the dotless live name below is unaffected. + raise ValueError(f"profile gateway {profile!r} already registered at {svc_dir}") + # Build atomically in a DOT-PREFIXED sibling (``.gateway-.tmp``) then rename: + # s6-svscan skips dot entries, so a concurrent rescan (cont-init reconciler, sibling + # register) cannot supervise the half-built slot. Otherwise s6-supervise would spawn AS + # ROOT on the ``.tmp`` (it already has ``type``/``run``), mkdir ``supervise/`` root-owned + # 0700, and our ``_seed_supervise_skeleton`` would EACCES on ``supervise/event`` — the + # arm64-only CI flake on test_s6_unregister_removes_service_dir_in_live_container. tmp_dir = svc_dir.with_name("." + svc_dir.name + ".tmp") if tmp_dir.exists(): shutil.rmtree(tmp_dir, ignore_errors=True) @@ -735,86 +566,46 @@ class S6ServiceManager: (tmp_dir / "type").write_text("longrun\n", encoding="utf-8") _write_script(tmp_dir / "run", self._render_run_script(profile, extra_env or {})) _write_script(tmp_dir / "finish", self._render_finish_script()) - # Persistent log rotation (OQ8-C). (tmp_dir / "log").mkdir() _write_script(tmp_dir / "log" / "run", self._render_log_run(profile)) - - # Pre-create the supervise/ skeleton with hermes ownership - # BEFORE we publish the slot. s6-supervise will EEXIST our - # dirs/FIFOs and inherit the ownership, so the runtime - # s6-svc / s6-svstat / s6-svwait calls (all dispatched as - # the hermes user) won't hit EACCES on root-owned 0700 - # dirs. See ``_seed_supervise_skeleton`` for the full - # rationale. + # Seed hermes-owned supervise/ BEFORE publishing so the hermes-user s6-svc/s6-svstat/ + # s6-svwait calls never hit root-owned 0700 dirs (see _seed_supervise_skeleton). _seed_supervise_skeleton(tmp_dir) - - # When start_now is False, write a `down` marker so - # s6-supervise does not auto-start the service on rescan. - # Mirrors the same pattern in container_boot.py - # _register_gateway_slot when start=False. + # Mirrors container_boot._register_gateway_slot when start=False. if not start_now: (tmp_dir / "down").touch() - tmp_dir.rename(svc_dir) except Exception: shutil.rmtree(tmp_dir, ignore_errors=True) raise - - # Trigger rescan so s6-svscan picks up the new service. result = _s6_run("s6-svscanctl", "-a", str(self.scandir)) if result.returncode != 0: - # Clean up: rescan failed, leave the directory in place would - # be confusing (no supervisor watching it). + # No supervisor is watching it — leaving the directory would be confusing. shutil.rmtree(svc_dir, ignore_errors=True) - raise RuntimeError( - f"s6-svscanctl failed: {result.stderr or result.stdout}" - ) + raise RuntimeError(f"s6-svscanctl failed: {result.stderr or result.stdout}") def unregister_profile_gateway(self, profile: str) -> None: - """Stop the profile gateway service and remove its directory. + """Stop the profile gateway (best effort, wait for down) and remove its directory. + Idempotent: absent services are a no-op. - Idempotent: absent services are a no-op. Best-effort stop + wait-for-down before removal so - the running gateway process gets a chance to shut down cleanly before its service dir - disappears. - - Teardown ordering matters: ``s6-svscanctl -an`` is fired **before** ``rmtree`` so s6-svscan - reaps the supervise child process (releasing its handle on ``supervise/lock`` and the - regular files inside the supervise dir), giving us a clean directory to remove. + ``s6-svscanctl -an`` fires BEFORE ``rmtree`` so s6-svscan reaps the supervise child and + releases its handles on ``supervise/lock``/``status``/``death_tally``; those files are + root-owned but the parent ``supervise/`` is hermes-owned (see ``_seed_supervise_skeleton``) + and POSIX only needs write+execute on the parent to remove them. """ svc_dir = self._service_dir(profile) if not svc_dir.exists(): return - - # Stop the service (best effort — service may already be down). _s6_run("s6-svc", "-d", str(svc_dir)) - # Wait for it to actually go down (up to 10s). _s6_run("s6-svwait", "-D", "-t", "10000", str(svc_dir), timeout=15) - - # Reap the supervise child FIRST: -n tells s6-svscan to drop - # any supervise processes whose service dir is gone (which - # includes any service dir we're about to remove). This - # releases the file handles s6-supervise holds against the - # supervise/lock + supervise/status + supervise/death_tally - # files inside the slot, so the upcoming rmtree doesn't race. _s6_run("s6-svscanctl", "-an", str(self.scandir)) - # Give s6-svscan a moment to reap. There's no synchronous - # "scan completed" handshake — the -a/-n trigger just sets a - # flag s6-svscan reads on its next loop iteration. 200ms is - # comfortably above the loop's resolution but well under any - # user-perceived latency. + # No synchronous "scan completed" handshake — -a/-n just set a flag s6-svscan reads on its + # next loop; 200ms is comfortably above that resolution. time.sleep(0.2) - - # Now the supervise dir's files are no longer held open by a - # live s6-supervise, so rmtree can remove them. Files inside - # supervise/ are root-owned (death_tally, lock, status, written - # by s6-supervise itself) — but the parent supervise/ directory - # is hermes-owned (see ``_seed_supervise_skeleton``), and on - # POSIX you only need write+execute on the parent to remove - # contained files regardless of file ownership. shutil.rmtree(svc_dir, ignore_errors=True) def list_profile_gateways(self) -> list[str]: - """Return the profile names of all currently-registered gateway services.""" + """Profile names of all currently-registered gateway services.""" if not self.scandir.exists(): return [] return [ diff --git a/hermes_cli/session_export.py b/hermes_cli/session_export.py index 51548ce6f0..93a867129a 100644 --- a/hermes_cli/session_export.py +++ b/hermes_cli/session_export.py @@ -1,8 +1,8 @@ """Shared renderers for session export commands. -The CLI, dashboard, and slash-command surfaces all deal with the same session-shaped data: a session -dict with a ``messages`` list. Keep filtering and human-readable rendering here so each surface only -has to load sessions and write bytes. +CLI, dashboard, and slash-command surfaces all deal with the same session-shaped data (a session +dict with a ``messages`` list); filtering and human-readable rendering live here so each surface +only loads sessions and writes bytes. """ from __future__ import annotations @@ -16,10 +16,13 @@ from typing import Any, Dict, Iterable, Iterator, List, Literal, Optional, Tuple ExportFormat = Literal["jsonl", "markdown"] ExportOnly = Literal["user-prompts"] +_EXPORT_FORMATS = {"jsonl": "jsonl", "markdown": "markdown", "md": "markdown"} +_ONLY_ALIASES = {"user", "prompts", "user-prompts", "user_prompts"} + def normalize_export_format(fmt: str) -> ExportFormat: """Return the canonical export format name.""" - value = {"jsonl": "jsonl", "markdown": "markdown", "md": "markdown"}.get((fmt or "jsonl").strip().lower()) + value = _EXPORT_FORMATS.get((fmt or "jsonl").strip().lower()) if value is None: raise ValueError(f"Unsupported session export format: {fmt}") return value # type: ignore[return-value] @@ -29,40 +32,42 @@ def normalize_export_only(only: Optional[str]) -> Optional[ExportOnly]: """Return the canonical export filter name.""" if only is None: return None - if only.strip().lower() in {"user", "prompts", "user-prompts", "user_prompts"}: + if only.strip().lower() in _ONLY_ALIASES: return "user-prompts" raise ValueError(f"Unsupported session export filter: {only}") -def render_sessions_export( - sessions: Iterable[Dict[str, Any]], - *, - fmt: str = "jsonl", - only: Optional[str] = None, -) -> str: +def render_sessions_export(sessions: Iterable[Dict[str, Any]], *, fmt: str = "jsonl", only: Optional[str] = None) -> str: """Render exported sessions in a stable, reusable format. - ``fmt=jsonl`` with no filter intentionally preserves the legacy shape: one full session object - per line. ``only=user-prompts`` switches the unit of export to one prompt record per line so the - output is easy to pipe into review, memory-ingestion, or prompt-library tooling. + ``fmt=jsonl`` with no filter keeps the legacy shape (one full session object per line); + ``only=user-prompts`` switches the unit to one prompt record per line for piping into + review / memory-ingestion / prompt-library tooling. """ session_list = list(sessions) export_format = normalize_export_format(fmt) - export_only = normalize_export_only(only) - - prompts_only = export_only == "user-prompts" + prompts_only = normalize_export_only(only) == "user-prompts" if export_format == "jsonl": rows = iter_user_prompt_records(session_list) if prompts_only else session_list lines = [json.dumps(row, ensure_ascii=False) for row in rows] return ("\n".join(lines) + "\n") if lines else "" if prompts_only: - return _render_user_prompts_markdown(session_list) - return _render_full_markdown(session_list) + lines = _render_sessions_markdown( + session_list, "User prompts export", + lambda session: f"User prompts for session {_heading_text(_session_id(session))}", + lambda session: f"Session {_heading_text(_session_id(session))}", + _append_prompt_records, + ) + if not session_list: + lines += ["_No user prompts found._", ""] + return _finish_markdown(lines) + heading = lambda session: f"Session: {_heading_text(_session_title_or_id(session))}" # noqa: E731 + return _finish_markdown(_render_sessions_markdown( + session_list, "Hermes sessions export", heading, heading, _append_session_messages, + )) -def export_record_count( - sessions: Iterable[Dict[str, Any]], *, only: Optional[str] = None -) -> Tuple[int, str]: +def export_record_count(sessions: Iterable[Dict[str, Any]], *, only: Optional[str] = None) -> Tuple[int, str]: """Return ``(count, noun)`` for status messages after an export.""" session_list = list(sessions) if normalize_export_only(only) == "user-prompts": @@ -70,9 +75,7 @@ def export_record_count( return len(session_list), "session" -def iter_user_prompt_records( - sessions: Iterable[Dict[str, Any]] -) -> Iterator[Dict[str, Any]]: +def iter_user_prompt_records(sessions: Iterable[Dict[str, Any]]) -> Iterator[Dict[str, Any]]: """Yield one normalized record for each user-authored prompt.""" for session in sessions: session_id = str(session.get("id") or session.get("session_id") or "") @@ -88,27 +91,13 @@ def iter_user_prompt_records( "role": "user", "text": _message_text(message.get("content")), } - message_id = message.get("id") - if message_id is not None: + if (message_id := message.get("id")) is not None: record["message_id"] = message_id - event_id = message.get("platform_message_id") or message.get("event_id") - if event_id: + if event_id := message.get("platform_message_id") or message.get("event_id"): record["event_id"] = event_id yield record -def _render_user_prompts_markdown(sessions: List[Dict[str, Any]]) -> str: - lines = _render_sessions_markdown( - sessions, "User prompts export", - lambda session: f"User prompts for session {_heading_text(_session_id(session))}", - lambda session: f"Session {_heading_text(_session_id(session))}", - _append_prompt_records, - ) - if not sessions: - lines += ["_No user prompts found._", ""] - return _finish_markdown(lines) - - def _render_sessions_markdown(sessions, multi_title, single_heading, multi_heading, append_body) -> List[str]: """One session → its own H1 with body at H2; several → a shared H1, each session H2/H3.""" lines: List[str] = [] @@ -124,42 +113,25 @@ def _render_sessions_markdown(sessions, multi_title, single_heading, multi_headi return lines -def _append_prompt_records( - lines: List[str], session: Dict[str, Any], *, heading_level: int -) -> None: +def _append_prompt_records(lines: List[str], session: Dict[str, Any], *, heading_level: int) -> None: prompts = list(iter_user_prompt_records([session])) if not prompts: lines += ["_No user prompts found._", ""] return marker = "#" * heading_level for prompt in prompts: - timestamp = prompt.get("created_at") or "timestamp unavailable" - lines.append(f"{marker} {prompt['index']}. {timestamp}") + lines.append(f"{marker} {prompt['index']}. {prompt.get('created_at') or 'timestamp unavailable'}") if (message_id := prompt.get("message_id")) is not None: lines += [f"Message ID: `{message_id}`", ""] lines += [str(prompt.get("text") or ""), ""] -def _render_full_markdown(sessions: List[Dict[str, Any]]) -> str: - return _finish_markdown(_render_sessions_markdown( - sessions, "Hermes sessions export", - lambda session: f"Session: {_heading_text(_session_title_or_id(session))}", - lambda session: f"Session: {_heading_text(_session_title_or_id(session))}", - _append_session_messages, - )) - - -def _append_session_messages( - lines: List[str], session: Dict[str, Any], *, heading_level: int -) -> None: +def _append_session_messages(lines: List[str], session: Dict[str, Any], *, heading_level: int) -> None: marker = "#" * heading_level - visible_messages = [ - message for message in _messages(session) if message.get("role") != "system" - ] + visible_messages = [message for message in _messages(session) if message.get("role") != "system"] if not visible_messages: lines += ["_No messages found._", ""] return - for message in visible_messages: role = str(message.get("role") or "unknown") timestamp = _format_timestamp(message.get("timestamp")) @@ -172,34 +144,28 @@ def _append_session_messages( f"
{html_escape(tool_name)}", "", _fenced_text(text), "", "
", "", ] - continue - label = {"user": "User", "assistant": "Assistant"}.get(role, role.title()) - lines += [f"{marker} {label}{suffix}", "", text, ""] + else: + label = {"user": "User", "assistant": "Assistant"}.get(role, role.title()) + lines += [f"{marker} {label}{suffix}", "", text, ""] def _messages(session: Dict[str, Any]) -> List[Dict[str, Any]]: - messages = session.get("messages") or [] - return [message for message in messages if isinstance(message, dict)] + return [message for message in session.get("messages") or [] if isinstance(message, dict)] def _message_text(content: Any) -> str: - if content is None: - return "" if isinstance(content, list): return "\n".join(part for part in map(_content_part_text, content) if part) - return _content_part_text(content) + return "" if content is None else _content_part_text(content) def _content_part_text(part: Any) -> str: - if isinstance(part, str): - return part - if isinstance(part, dict): - for key in ("text", "content"): - value = part.get(key) - if isinstance(value, str): - return value - return json.dumps(part, ensure_ascii=False, sort_keys=True) - return str(part) + if not isinstance(part, dict): + return part if isinstance(part, str) else str(part) + for key in ("text", "content"): + if isinstance(value := part.get(key), str): + return value + return json.dumps(part, ensure_ascii=False, sort_keys=True) def _format_timestamp(value: Any) -> Optional[str]: @@ -220,7 +186,7 @@ def _session_metadata_lines(session: Dict[str, Any]) -> List[str]: if session.get(key): lines.append(f"- {label}: `{session[key]}`") if title := session.get("title"): - lines.append(f"- Title: {_inline_text(str(title))}") + lines.append(f"- Title: {' '.join(str(title).splitlines()).strip()}") if started := _format_timestamp(session.get("started_at")): lines.append(f"- Started: {started}") if (message_count := session.get("message_count")) is not None: @@ -240,10 +206,6 @@ def _heading_text(value: str) -> str: return " ".join(str(value).splitlines()).strip() or "unknown" -def _inline_text(value: str) -> str: - return " ".join(value.splitlines()).strip() - - def _fenced_text(text: str, *, language: str = "text") -> str: fence = "```" while fence in text: @@ -257,9 +219,7 @@ def _finish_markdown(lines: List[str]) -> str: return "\n".join(lines) + "\n" -# --------------------------------------------------------------------------- -# Current-session save helper (shared by CLI /save and gateway /save) -# --------------------------------------------------------------------------- +# --- Current-session save helper (shared by CLI /save and gateway /save) --- SAVE_FORMATS = ("json", "md", "html") @@ -282,34 +242,39 @@ Examples: /save md notes.md /save html session.html redact""" +_SAVE_FORMAT_ALIASES = {"json": "json", "snapshot": "json", "md": "md", "markdown": "md", "html": "html"} + def normalize_save_format(fmt: Optional[str]) -> str: """Map a user-typed /save format token to a canonical format.""" token = (fmt or "json").strip().lower() - canonical = {"json": "json", "snapshot": "json", "md": "md", "markdown": "md", "html": "html"} - if token not in canonical: - raise ValueError( - f"Unknown format {token!r} — expected one of: json, md, html" - ) - return canonical[token] + if token not in _SAVE_FORMAT_ALIASES: + raise ValueError(f"Unknown format {token!r} — expected one of: json, md, html") + return _SAVE_FORMAT_ALIASES[token] + + +def _render_html_for_save(session: Dict[str, Any]) -> str: + from hermes_cli.session_export_html import generate_html_export + + return generate_html_export(session) + + +_SAVE_RENDERERS = { + "json": lambda session: json.dumps(session, indent=2, ensure_ascii=False, default=str), + "md": lambda session: render_sessions_export([session], fmt="markdown"), + "html": _render_html_for_save, +} def render_session_for_save(session: Dict[str, Any], fmt: str) -> str: """Render one exported session dict for /save.""" - if fmt == "json": - return json.dumps(session, indent=2, ensure_ascii=False, default=str) - if fmt == "md": - return render_sessions_export([session], fmt="markdown") - if fmt == "html": - from hermes_cli.session_export_html import generate_html_export - - return generate_html_export(session) - raise ValueError(f"Unknown save format: {fmt!r}") + renderer = _SAVE_RENDERERS.get(fmt) + if renderer is None: + raise ValueError(f"Unknown save format: {fmt!r}") + return renderer(session) def default_save_filename(session_id: str, fmt: str) -> str: """Default filename for a /save export of the given session.""" - safe_id = "".join( - ch for ch in str(session_id) if ch.isalnum() or ch in ("-", "_") - ) or "session" + safe_id = "".join(ch for ch in str(session_id) if ch.isalnum() or ch in ("-", "_")) or "session" return f"hermes_session_{safe_id}.{fmt}" diff --git a/hermes_cli/session_export_html.py b/hermes_cli/session_export_html.py index 6e05856267..bfd6dee1c4 100644 --- a/hermes_cli/session_export_html.py +++ b/hermes_cli/session_export_html.py @@ -1,7 +1,4 @@ -"""HTML Export generator for Hermes sessions. Generates a standalone, beautiful HTML file with all -messages embedded. Supports single and multi-session exports with a professional sidebar. No remote -dependencies. Enhanced with UI-UX-PRO-MAX design intelligence. -""" +"""Standalone single-file HTML export (single or multi-session with sidebar); no remote deps.""" import datetime import secrets @@ -645,14 +642,16 @@ HTML_TEMPLATE = """ """ -def _escape_html(text: str) -> str: - if not isinstance(text, str): - text = str(text) - return text.replace("&", "&").replace("<", "<").replace(">", ">").replace('"', """).replace("'", "'") +def _escape_html(text: Any) -> str: + return ( + str(text).replace("&", "&").replace("<", "<").replace(">", ">") + .replace('"', """).replace("'", "'") + ) + def _format_timestamp(ts: float) -> str: - if not ts: return "N/A" - return datetime.datetime.fromtimestamp(ts).strftime("%Y-%m-%d %H:%M:%S") + return datetime.datetime.fromtimestamp(ts).strftime("%Y-%m-%d %H:%M:%S") if ts else "N/A" + _ROLE_ICONS = {"user": ICON_USER, "assistant": ICON_BOT, "system": ICON_SHIELD} _CHEVRON_HTML = ICON_CHEVRON_RIGHT.replace('class="', 'class="chevron ') @@ -675,7 +674,7 @@ def _content_text(content: Any) -> str: def _collapsible(kind: str, icon: str, title: str, inner: str, indent: str, outer_class: str = "") -> str: """A chevron-headed collapsible block (tool-call / reasoning / system-prompt).""" - return f''' + return f""" {indent}
{indent}
{indent} {_CHEVRON_HTML} @@ -685,98 +684,66 @@ def _collapsible(kind: str, icon: str, title: str, inner: str, indent: str, oute {indent} {inner} {indent}
{indent}
-{indent}''' +{indent}""" def _generate_messages_html(messages: List[Dict[str, Any]]) -> str: html_list = [] for i, msg in enumerate(messages): role = msg.get("role", "unknown") - - # Skip internal metadata messages - if role == "session_meta": + if role == "session_meta": # internal metadata, never rendered continue - content = _content_text(msg.get("content") or "") - timestamp = _format_timestamp(msg.get("timestamp", 0)) - role_icon = _ROLE_ICONS.get(role, ICON_TERMINAL) - - # Build message HTML. The role feeds two sinks and for tool/MCP messages - # is externally influenced, so treat each sink on its own terms: - # - display text: HTML-escape (prevents markup/JS injection). - # - class attribute: reduce to a single safe CSS token (alnum/-/_), - # so a crafted role can neither break out of the attribute nor split - # into several unintended classes. Real roles (user/assistant/system/ - # tool) are unchanged, so the `.message-` rules still match. - safe_role = _escape_html(role) + # The role feeds two sinks and is externally influenced for tool/MCP messages: HTML-escape + # the display text; reduce the class token to alnum/-/_ so a crafted role can neither break + # out of the attribute nor split into extra classes (real roles keep matching `.message-`). role_class = "".join(c if c.isalnum() or c in "-_" else "-" for c in str(role).lower()) - msg_class = f"message message-{role_class} active" - # Delay animation for initial items - delay_style = f' style="animation-delay: {min(i * 0.05, 1.0)}s"' if i < 10 else "" - - html = f'
' - html += f'
' - html += f'
{_CHEVRON_HTML} {role_icon} {safe_role}
' - html += f'
{timestamp}
' - html += '
' - html += '
' - + delay_style = f' style="animation-delay: {min(i * 0.05, 1.0)}s"' if i < 10 else "" # stagger first items + html = ( + f'
' + f'
' + f'
{_CHEVRON_HTML} {_ROLE_ICONS.get(role, ICON_TERMINAL)} {_escape_html(role)}
' + f'
{_format_timestamp(msg.get("timestamp", 0))}
' + '
' + '
' + ) for tc in msg.get("tool_calls") or (): - fn_name = tc.get("function", {}).get("name", "unknown") - args = tc.get("function", {}).get("arguments", "{}") + fn = tc.get("function", {}) html += _collapsible( - "tool-call", ICON_WRENCH, f"Tool Call: {_escape_html(fn_name)}", - f"
{_escape_html(args)}
", " " * 16, + "tool-call", ICON_WRENCH, f"Tool Call: {_escape_html(fn.get('name', 'unknown'))}", + f"
{_escape_html(fn.get('arguments', '{}'))}
", " " * 16, ) - if content: - if role == "tool": - html += f'
{_escape_html(content)}
' - else: - html += f'
{_escape_html(content)}
' - - reasoning = msg.get("reasoning") or msg.get("reasoning_content") - if reasoning: + escaped = _escape_html(content) + body = f"
{escaped}
" if role == "tool" else escaped + html += f'
{body}
' + if reasoning := msg.get("reasoning") or msg.get("reasoning_content"): html += _collapsible( "reasoning", ICON_SPARKLES, "Reasoning", f'
{_escape_html(reasoning)}
', " " * 12, ) - - html += '
' - html += '
' - html_list.append(html) + html_list.append(html + "
") return "\n".join(html_list) -def generate_multi_session_html_export(sessions: List[Dict[str, Any]]) -> str: - if not sessions: - return "

No sessions to export.

" - is_multi = len(sessions) > 1 - generated_at = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S") - - # Sidebar - sidebar_html = "" - if is_multi: - sidebar_items = [] - for s in sessions: - sid = str(s.get("id", "N/A")) - escaped_sid = _escape_html(sid) - title = s.get("title") or s.get("preview") or "Untitled Session" - if len(title) > 50: title = title[:47] + "..." - date = _format_timestamp(s.get("started_at", 0)).split(" ")[0] - - item = f''' - +def _sidebar_html(sessions: List[Dict[str, Any]]) -> str: + items = [] + for s in sessions: + sid = str(s.get("id", "N/A")) + title = s.get("title") or s.get("preview") or "Untitled Session" + if len(title) > 50: + title = title[:47] + "..." + date = _format_timestamp(s.get("started_at", 0)).split(" ")[0] + items.append(f""" +
{_escape_html(title)}
{_escape_html(sid[:8])} {date}
- ''' - sidebar_items.append(item) - - sidebar_html = f''' + """) + return f""" - ''' + """ - # Main Content - sessions_html_list = [] - for s in sessions: - sid = str(s.get("id", "N/A")) - escaped_sid = _escape_html(sid) - title = s.get("title") or "Hermes Session" - model = s.get("model") or "Unknown" - started_at = _format_timestamp(s.get("started_at", 0)) - messages_html = _generate_messages_html(s.get("messages", [])) - view_class = "session-view" if is_multi else "session-view active" - - system_prompt = s.get("system_prompt") - system_html = "" - if system_prompt: - system_html = _collapsible( - "system-prompt", ICON_SHIELD, "System Prompt (Persona)", - f'
{_escape_html(system_prompt)}
', " " * 12, - outer_class="system-prompt-section active", - ) - - session_html = f''' -
+ +def _session_view_html(s: Dict[str, Any], is_multi: bool) -> str: + escaped_sid = _escape_html(str(s.get("id", "N/A"))) + system_html = "" + if system_prompt := s.get("system_prompt"): + system_html = _collapsible( + "system-prompt", ICON_SHIELD, "System Prompt (Persona)", + f'
{_escape_html(system_prompt)}
', " " * 12, + outer_class="system-prompt-section active", + ) + return f""" +
-

{_escape_html(title)}

+

{_escape_html(s.get("title") or "Hermes Session")}

ID: {escaped_sid}
-
Model: {_escape_html(model)}
-
Started: {started_at}
+
Model: {_escape_html(s.get("model") or "Unknown")}
+
Started: {_format_timestamp(s.get("started_at", 0))}
{system_html}
- {messages_html} + {_generate_messages_html(s.get("messages", []))}
- ''' - sessions_html_list.append(session_html) + """ + +def generate_multi_session_html_export(sessions: List[Dict[str, Any]]) -> str: + if not sessions: + return "

No sessions to export.

" + is_multi = len(sessions) > 1 return HTML_TEMPLATE.format( page_title="Hermes Session Export" if is_multi else _escape_html(sessions[0].get("title") or "Hermes Session"), - sidebar_html=sidebar_html, - sessions_html="\n".join(sessions_html_list), + sidebar_html=_sidebar_html(sessions) if is_multi else "", + sessions_html="\n".join(_session_view_html(s, is_multi) for s in sessions), main_margin="var(--sidebar-width)" if is_multi else "0", layout_class="layout-multi" if is_multi else "layout-single", - generated_at=generated_at, + generated_at=datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S"), script_nonce=secrets.token_urlsafe(16), ) + def generate_html_export(session_data: Dict[str, Any]) -> str: - """Legacy wrapper for single session export.""" + """Single-session export (legacy entry point).""" return generate_multi_session_html_export([session_data]) diff --git a/hermes_cli/session_export_md.py b/hermes_cli/session_export_md.py index a06ccabd13..1e6544f480 100644 --- a/hermes_cli/session_export_md.py +++ b/hermes_cli/session_export_md.py @@ -1,8 +1,7 @@ """Markdown/QMD export helpers for Hermes sessions. -This module is intentionally filesystem-only: it formats already-exported SessionDB dictionaries and -writes them to user-selected export directories. It must not mutate state.db or call -delete/prune/archive APIs. +Filesystem-only: formats already-exported SessionDB dicts and writes them to user-selected export +directories. Must not mutate state.db or call delete/prune/archive APIs. """ from __future__ import annotations @@ -17,6 +16,8 @@ from typing import Any EXPORTER_VERSION = "hermes sessions export (md/qmd) v1" _SHA_LINE_RE = re.compile(r"- SHA256 of exported body: `([0-9a-f]{64})`") +_SHA_PLACEHOLDER = "__SHA256_PLACEHOLDER__" +_VERIFICATION_HEADING = "## Export verification" def _iso_timestamp(value: Any) -> str: @@ -29,55 +30,33 @@ def _iso_timestamp(value: Any) -> str: return datetime.fromtimestamp(ts, tz=timezone.utc).isoformat().replace("+00:00", "Z") -def _frontmatter_value(value: Any) -> str: - if value is None: - return "null" - if isinstance(value, bool): - return "true" if value else "false" - if isinstance(value, (int, float)) and not isinstance(value, bool): - return json.dumps(value, ensure_ascii=False) - if isinstance(value, list): - return json.dumps(value, ensure_ascii=False) - return json.dumps(str(value), ensure_ascii=False) - - def _frontmatter_line(key: str, value: Any) -> str: - return f"{key}: {_frontmatter_value(value)}" + if value is None or isinstance(value, bool): + shown = {None: "null", True: "true", False: "false"}[value] + else: + shown = json.dumps(value if isinstance(value, (int, float, list)) else str(value), ensure_ascii=False) + return f"{key}: {shown}" + + +def _json_block(value: Any) -> str: + return "```json\n" + json.dumps(value, ensure_ascii=False, indent=2) + "\n```" def _message_heading(message: dict[str, Any]) -> str: role = str(message.get("role") or "message") - label = role.capitalize() name = message.get("name") or message.get("tool_name") - if role == "tool" and name: - label = f"Tool — {name}" + label = f"Tool — {name}" if role == "tool" and name else role.capitalize() timestamp = _iso_timestamp(message.get("created_at") or message.get("timestamp")) return f"### {label}{' — ' + timestamp if timestamp else ''}" -def _render_content(content: Any) -> str: - if content is None: - return "" - if isinstance(content, str): - return content.rstrip() - return "```json\n" + json.dumps(content, ensure_ascii=False, indent=2) + "\n```" - - -def _render_tool_calls(tool_calls: Any) -> str: - if not tool_calls: - return "" - return "\n\n## Tool calls\n\n```json\n" + json.dumps(tool_calls, ensure_ascii=False, indent=2) + "\n```" - - def _session_id(session: dict[str, Any]) -> str: return str(session.get("id") or session.get("session_id") or "unknown-session") def _segments(session: dict[str, Any]) -> list[dict[str, Any]]: segments = session.get("segments") - if isinstance(segments, list) and segments: - return [s for s in segments if isinstance(s, dict)] - return [session] + return [s for s in segments if isinstance(s, dict)] if isinstance(segments, list) and segments else [session] def _message_count(session: dict[str, Any]) -> int: @@ -87,8 +66,7 @@ def _message_count(session: dict[str, Any]) -> int: def _render_messages(session: dict[str, Any]) -> str: parts: list[str] = ["## Messages\n"] segments = _segments(session) - total_messages = _message_count(session) - if total_messages == 0: + if _message_count(session) == 0: parts.append("_No messages in this session._\n") return "\n".join(parts).rstrip() + "\n" @@ -98,99 +76,82 @@ def _render_messages(session: dict[str, Any]) -> str: parts.append(f"## Compression segment: {_session_id(segment)}\n") for message in list(segment.get("messages") or []): parts.append(_message_heading(message) + "\n") - rendered_content = _render_content(message.get("content")) - if rendered_content: - parts.append(rendered_content + "\n") - tool_calls = _render_tool_calls(message.get("tool_calls")) - if tool_calls: - parts.append(tool_calls + "\n") + content = message.get("content") + if content is not None: + rendered = content.rstrip() if isinstance(content, str) else _json_block(content) + if rendered: + parts.append(rendered + "\n") + if tool_calls := message.get("tool_calls"): + parts.append("\n\n## Tool calls\n\n" + _json_block(tool_calls) + "\n") parts.append("") return "\n".join(parts).rstrip() + "\n" def _export_body_without_hash(session: dict[str, Any], *, fmt: str, exported_at: float) -> str: session_id = _session_id(session) - title = session.get("title") or session_id - provider = session.get("billing_provider") or session.get("provider") - started_at = _iso_timestamp(session.get("started_at") or session.get("created_at")) - last_active = _iso_timestamp(session.get("last_active") or session.get("updated_at")) - ended_at = _iso_timestamp(session.get("ended_at")) exported_iso = _iso_timestamp(exported_at) message_count = _message_count(session) - frontmatter = [ - "---", - _frontmatter_line("session_id", session_id), - _frontmatter_line("title", session.get("title")), - _frontmatter_line("source", session.get("source")), - _frontmatter_line("created_at", started_at), - _frontmatter_line("updated_at", last_active), - _frontmatter_line("ended_at", ended_at), - _frontmatter_line("model", session.get("model")), - _frontmatter_line("provider", provider), - _frontmatter_line("cwd", session.get("cwd")), - _frontmatter_line("archived", bool(session.get("archived"))), - _frontmatter_line("message_count", message_count), - _frontmatter_line("tool_call_count", session.get("tool_call_count") or 0), + fields = [ + ("session_id", session_id), + ("title", session.get("title")), + ("source", session.get("source")), + ("created_at", _iso_timestamp(session.get("started_at") or session.get("created_at"))), + ("updated_at", _iso_timestamp(session.get("last_active") or session.get("updated_at"))), + ("ended_at", _iso_timestamp(session.get("ended_at"))), + ("model", session.get("model")), + ("provider", session.get("billing_provider") or session.get("provider")), + ("cwd", session.get("cwd")), + ("archived", bool(session.get("archived"))), + ("message_count", message_count), + ("tool_call_count", session.get("tool_call_count") or 0), ] if session.get("lineage_session_ids"): - frontmatter.append(_frontmatter_line("lineage_session_ids", session.get("lineage_session_ids"))) - frontmatter.extend([ - _frontmatter_line("format", fmt), - _frontmatter_line("exported_at", exported_iso), - _frontmatter_line("exporter", EXPORTER_VERSION), - "---", - "", - ]) + fields.append(("lineage_session_ids", session.get("lineage_session_ids"))) + fields += [("format", fmt), ("exported_at", exported_iso), ("exporter", EXPORTER_VERSION)] + frontmatter = ["---", *(_frontmatter_line(k, v) for k, v in fields), "---", ""] - parts = ["\n".join(frontmatter), f"# {title}\n"] - parts.append(f"Session ID: `{session_id}`\n") + parts = ["\n".join(frontmatter), f"# {session.get('title') or session_id}\n", f"Session ID: `{session_id}`\n"] if session.get("source"): parts.append(f"Source: `{session.get('source')}`\n") if session.get("cwd"): parts.append(f"Working directory: `{session.get('cwd')}`\n") - - parts.append(_render_messages(session)) - parts.append("## Export verification\n") - parts.append(f"- Session id: `{session_id}`") - parts.append(f"- Exported messages: `{message_count}`") - parts.append(f"- Source DB message count at export: `{session.get('message_count', message_count)}`") - parts.append(f"- Exported at: `{exported_iso}`") - parts.append("- SHA256 of exported body: `__SHA256_PLACEHOLDER__`") + parts += [ + _render_messages(session), + f"{_VERIFICATION_HEADING}\n", + f"- Session id: `{session_id}`", + f"- Exported messages: `{message_count}`", + f"- Source DB message count at export: `{session.get('message_count', message_count)}`", + f"- Exported at: `{exported_iso}`", + f"- SHA256 of exported body: `{_SHA_PLACEHOLDER}`", + ] return "\n".join(parts).rstrip() + "\n" -def _body_for_digest(text: str) -> str: - return _SHA_LINE_RE.sub("- SHA256 of exported body: `pending`", text) +def _check_fmt(fmt: str) -> None: + if fmt not in {"md", "qmd"}: + raise ValueError("fmt must be 'md' or 'qmd'") def render_session_markdown( session: dict[str, Any], *, fmt: str = "md", include_verification: bool = True ) -> str: """Render a SessionDB export dictionary as Markdown/QMD text.""" - if fmt not in {"md", "qmd"}: - raise ValueError("fmt must be 'md' or 'qmd'") - exported_at = time.time() - body = _export_body_without_hash(session, fmt=fmt, exported_at=exported_at) - digest_body = body.replace("`__SHA256_PLACEHOLDER__`", "`pending`") - digest = hashlib.sha256(digest_body.encode("utf-8")).hexdigest() - if include_verification: - return body.replace("__SHA256_PLACEHOLDER__", digest) - before_verification = body.split("\n## Export verification\n", 1)[0].rstrip() + "\n" - return before_verification + _check_fmt(fmt) + body = _export_body_without_hash(session, fmt=fmt, exported_at=time.time()) + if not include_verification: + return body.split(f"\n{_VERIFICATION_HEADING}\n", 1)[0].rstrip() + "\n" + # The digest covers the body with the SHA line set to `pending`, which is what verify recomputes. + digest_body = body.replace(f"`{_SHA_PLACEHOLDER}`", "`pending`") + return body.replace(_SHA_PLACEHOLDER, hashlib.sha256(digest_body.encode("utf-8")).hexdigest()) def safe_session_filename(session: dict[str, Any], *, fmt: str = "md") -> str: """Return a deterministic, path-safe filename for a session export.""" - if fmt not in {"md", "qmd"}: - raise ValueError("fmt must be 'md' or 'qmd'") - session_id = _session_id(session) + _check_fmt(fmt) title = str(session.get("title") or "session") - slug = re.sub(r"[^A-Za-z0-9._-]+", "-", title).strip(".-_").lower() - if not slug: - slug = "session" - slug = slug[:60] - return f"{session_id}-{slug}.{fmt}" + slug = re.sub(r"[^A-Za-z0-9._-]+", "-", title).strip(".-_").lower() or "session" + return f"{_session_id(session)}-{slug[:60]}.{fmt}" def file_sha256(path: Path | str) -> str: @@ -198,18 +159,16 @@ def file_sha256(path: Path | str) -> str: def verify_export_file(path: Path | str, session: dict[str, Any]) -> tuple[bool, str]: - p = Path(path) - if not p.exists(): + if not Path(path).exists(): return False, "file missing" - text = p.read_text(encoding="utf-8") + text = Path(path).read_text(encoding="utf-8") match = _SHA_LINE_RE.search(text) if not match: return False, "sha256 marker missing" - actual = hashlib.sha256(_body_for_digest(text).encode("utf-8")).hexdigest() - if actual != match.group(1): + digest_body = _SHA_LINE_RE.sub("- SHA256 of exported body: `pending`", text) + if hashlib.sha256(digest_body.encode("utf-8")).hexdigest() != match.group(1): return False, "sha256 mismatch" - expected_count = _message_count(session) - if f"- Exported messages: `{expected_count}`" not in text: + if f"- Exported messages: `{_message_count(session)}`" not in text: return False, "message count mismatch" if f"- Session id: `{_session_id(session)}`" not in text: return False, "session id mismatch" @@ -217,11 +176,10 @@ def verify_export_file(path: Path | str, session: dict[str, Any]) -> tuple[bool, def redact_session_data(session: dict[str, Any]) -> dict[str, Any]: - """Return a deep copy of a session export dict with secrets redacted. + """Return a copy of a session export dict with secrets redacted. - Runs every message's content and tool-call arguments through the force-mode redaction pass - (``agent.redact.redact_sensitive_text``), so API keys, tokens, and credentials that appeared in - tool output never land in plaintext export files. + Every message's content and tool-call arguments go through the force-mode redaction pass + (``agent.redact.redact_sensitive_text``) so credentials in tool output never land in exports. """ from agent.redact import redact_sensitive_text @@ -236,18 +194,22 @@ def redact_session_data(session: dict[str, Any]) -> dict[str, Any]: redacted = dict(session) for key in ("messages", "segments"): - if key in redacted and redacted[key] is not None: + if redacted.get(key) is not None: redacted[key] = _clean(redacted[key]) return redacted +def _export_dir(output_dir: Path | str) -> Path: + out_dir = Path(output_dir).expanduser() + out_dir.mkdir(parents=True, exist_ok=True) + return out_dir + + def write_session_markdown( session: dict[str, Any], output_dir: Path | str, *, fmt: str = "md", force: bool = False ) -> Path: """Write a Markdown/QMD export file and return its path.""" - out_dir = Path(output_dir).expanduser() - out_dir.mkdir(parents=True, exist_ok=True) - path = out_dir / safe_session_filename(session, fmt=fmt) + path = _export_dir(output_dir) / safe_session_filename(session, fmt=fmt) if path.exists() and not force: raise FileExistsError(str(path)) path.write_text(render_session_markdown(session, fmt=fmt), encoding="utf-8") @@ -255,19 +217,16 @@ def write_session_markdown( def append_manifest_entry(output_dir: Path | str, session: dict[str, Any], path: Path | str, *, fmt: str) -> Path: - out_dir = Path(output_dir).expanduser() - out_dir.mkdir(parents=True, exist_ok=True) - export_path = Path(path) entry = { "session_id": _session_id(session), "lineage_session_ids": session.get("lineage_session_ids") or [_session_id(session)], - "path": str(export_path), + "path": str(Path(path)), "format": fmt, "message_count": _message_count(session), - "sha256": file_sha256(export_path), + "sha256": file_sha256(path), "exported_at": time.time(), } - manifest = out_dir / "manifest.jsonl" + manifest = _export_dir(output_dir) / "manifest.jsonl" with manifest.open("a", encoding="utf-8") as fh: fh.write(json.dumps(entry, ensure_ascii=False, sort_keys=True) + "\n") return manifest diff --git a/hermes_cli/session_filters.py b/hermes_cli/session_filters.py index 9d252fdbd3..e23b9f5f9e 100644 --- a/hermes_cli/session_filters.py +++ b/hermes_cli/session_filters.py @@ -20,18 +20,15 @@ _UNIT_SECONDS = {"s": 1, "m": 60, "h": 3600, "d": 86400, "w": 604800} def parse_duration_seconds(value: str) -> Optional[float]: - """Parse ``5h`` / ``30m`` / ``2d`` / ``1w`` / ``90`` (bare = days) into - seconds. Returns None when the value doesn't look like a duration.""" + """Parse ``5h`` / ``30m`` / ``2d`` / ``1w`` / ``90`` (bare = days, backward compatible with + ``--older-than 90``) into seconds. Returns None when the value doesn't look like a duration.""" s = str(value).strip().lower() if not s: return None if re.fullmatch(r"\d+(?:\.\d+)?", s): - # Bare number = days (backward compatible with --older-than 90) return float(s) * 86400 m = _DURATION_RE.match(s) - if not m: - return None - return float(m.group(1)) * _UNIT_SECONDS[m.group(2)[0]] + return None if not m else float(m.group(1)) * _UNIT_SECONDS[m.group(2)[0]] def parse_point_in_time(value: str, flag: str) -> float: @@ -52,31 +49,58 @@ def parse_point_in_time(value: str, flag: str) -> float: f"'30m', '2d', '1w', a bare number of days, or an ISO timestamp " f"like '2026-07-05' or '2026-07-05 14:30'." ) from None - if dt.tzinfo is None: - return dt.timestamp() - return dt.astimezone(timezone.utc).timestamp() + return dt.timestamp() if dt.tzinfo is None else dt.astimezone(timezone.utc).timestamp() def format_epoch(ts: Optional[float]) -> str: """Render an epoch timestamp as a short local-time string.""" - if ts is None: - return "-" - return datetime.fromtimestamp(ts).strftime("%Y-%m-%d %H:%M") + return "-" if ts is None else datetime.fromtimestamp(ts).strftime("%Y-%m-%d %H:%M") + + +# (filter key, argparse attr, CLI flag, description template) for the four epoch bounds. +_TIME_BOUNDS = ( + ("last_active_before", "older_than", "--older-than", "last active before {v}"), + ("last_active_after", "newer_than", "--newer-than", "last active after {v}"), + ("started_before", "before", "--before", "started before {v}"), + ("started_after", "after", "--after", "started after {v}"), +) +# (lower key, upper key, window label, lower flag, upper flag); checked in this order. +_WINDOWS = ( + ("started_after", "started_before", "start-time", "--after", "--before"), + ("last_active_after", "last_active_before", "activity", "--newer-than", "--older-than"), +) +# (filter key, argparse attr, description template) for pass-through filters, in describe order. +# Numeric (min_/max_) filters are described when not None, text filters only when truthy. +_ARG_FILTERS = ( + ("source", "source", "source '{v}'"), + ("title_like", "title", "title contains '{v}'"), + ("end_reason", "end_reason", "end reason '{v}'"), + ("cwd_prefix", "cwd", "cwd under '{v}'"), + ("min_messages", "min_messages", ">= {v} messages"), + ("max_messages", "max_messages", "<= {v} messages"), + ("model_like", "model", "model contains '{v}'"), + ("provider", "provider", "provider '{v}'"), + ("user_id", "user", "user '{v}'"), + ("chat_id", "chat_id", "chat '{v}'"), + ("chat_type", "chat_type", "chat type '{v}'"), + ("branch_like", "branch", "git branch contains '{v}'"), + ("min_tokens", "min_tokens", ">= {v} tokens"), + ("max_tokens", "max_tokens", "<= {v} tokens"), + ("min_cost", "min_cost", ">= ${v}"), + ("max_cost", "max_cost", "<= ${v}"), + ("min_tool_calls", "min_tool_calls", ">= {v} tool calls"), + ("max_tool_calls", "max_tool_calls", "<= {v} tool calls"), +) def build_prune_filters(args: Any) -> Dict[str, Any]: """Translate argparse Namespace flags into SessionDB filter kwargs. - Understands: ``--older-than``, ``--newer-than``, ``--before``, ``--after``, ``--source``, - ``--title``, ``--end-reason``, ``--cwd``, ``--min-messages``, ``--max-messages``, - ``--archived``/``--no-archived``. - - ``--older-than`` / ``--newer-than`` bound last activity, while ``--before`` / ``--after`` - explicitly bound session start time. Last activity is the latest message timestamp, falling back - to ``started_at`` for empty sessions. + ``--older-than`` / ``--newer-than`` bound last activity (latest message timestamp, falling back + to ``started_at`` for empty sessions); ``--before`` / ``--after`` bound session start time. """ bounds: Dict[str, Optional[float]] = {} - for key, attr, flag in _TIME_BOUNDS: + for key, attr, flag, _ in _TIME_BOUNDS: raw = getattr(args, attr, None) bounds[key] = None if raw is None else parse_point_in_time(raw, flag) @@ -88,81 +112,22 @@ def build_prune_filters(args: Any) -> Dict[str, Any]: f"{hi_flag} bound ({format_epoch(bounds[hi])})." ) - # older_than_days=None: the epoch bounds above are the whole story. - # Without this, prune_sessions' default 90-day cutoff would silently - # cap an --after/--newer-than-only window. + # older_than_days=None: the epoch bounds are the whole story; otherwise prune_sessions' default + # 90-day cutoff would silently cap an --after/--newer-than-only window. filters: Dict[str, Any] = {"older_than_days": None, **bounds} - for key, attr in _ARG_FILTERS: + for key, attr, _ in _ARG_FILTERS: filters[key] = getattr(args, attr, None) return filters -# (filter key, argparse attr, CLI flag) for the four epoch bounds. -_TIME_BOUNDS = ( - ("last_active_before", "older_than", "--older-than"), - ("last_active_after", "newer_than", "--newer-than"), - ("started_before", "before", "--before"), - ("started_after", "after", "--after"), -) -# (lower key, upper key, window label, lower flag, upper flag); checked in this order. -_WINDOWS = ( - ("started_after", "started_before", "start-time", "--after", "--before"), - ("last_active_after", "last_active_before", "activity", "--newer-than", "--older-than"), -) -_ARG_FILTERS = ( - ("source", "source"), - ("title_like", "title"), - ("end_reason", "end_reason"), - ("cwd_prefix", "cwd"), - ("min_messages", "min_messages"), - ("max_messages", "max_messages"), - ("model_like", "model"), - ("provider", "provider"), - ("user_id", "user"), - ("chat_id", "chat_id"), - ("chat_type", "chat_type"), - ("branch_like", "branch"), - ("min_tokens", "min_tokens"), - ("max_tokens", "max_tokens"), - ("min_cost", "min_cost"), - ("max_cost", "max_cost"), - ("min_tool_calls", "min_tool_calls"), - ("max_tool_calls", "max_tool_calls"), -) - -# (filter key, description template, include when: "set" == `is not None`, "truthy" == bool(v)). -_DESCRIBE = ( - ("last_active_before", "last active before {e}", "set"), - ("last_active_after", "last active after {e}", "set"), - ("started_before", "started before {e}", "set"), - ("started_after", "started after {e}", "set"), - ("source", "source '{v}'", "truthy"), - ("title_like", "title contains '{v}'", "truthy"), - ("end_reason", "end reason '{v}'", "truthy"), - ("cwd_prefix", "cwd under '{v}'", "truthy"), - ("min_messages", ">= {v} messages", "set"), - ("max_messages", "<= {v} messages", "set"), - ("model_like", "model contains '{v}'", "truthy"), - ("provider", "provider '{v}'", "truthy"), - ("user_id", "user '{v}'", "truthy"), - ("chat_id", "chat '{v}'", "truthy"), - ("chat_type", "chat type '{v}'", "truthy"), - ("branch_like", "git branch contains '{v}'", "truthy"), - ("min_tokens", ">= {v} tokens", "set"), - ("max_tokens", "<= {v} tokens", "set"), - ("min_cost", ">= ${v}", "set"), - ("max_cost", "<= ${v}", "set"), - ("min_tool_calls", ">= {v} tool calls", "set"), - ("max_tool_calls", "<= {v} tool calls", "set"), -) - - def describe_filters(filters: Dict[str, Any]) -> str: """Human-readable summary of active filters for confirmation prompts.""" parts = [] - for key, template, mode in _DESCRIBE: + for key, _, _, template in _TIME_BOUNDS: + if (value := filters.get(key)) is not None: + parts.append(template.format(v=format_epoch(value))) + for key, _, template in _ARG_FILTERS: value = filters.get(key) - if (value is not None) if mode == "set" else bool(value): - shown = format_epoch(value) if "{e}" in template else value - parts.append(template.replace("{e}", "{v}").format(v=shown)) + if (value is not None) if key.startswith(("min_", "max_")) else bool(value): + parts.append(template.format(v=value)) return ", ".join(parts) if parts else "no filters (all ended sessions)" diff --git a/hermes_cli/session_listing.py b/hermes_cli/session_listing.py index 9cae4f4d53..a4c677bb12 100644 --- a/hermes_cli/session_listing.py +++ b/hermes_cli/session_listing.py @@ -2,20 +2,21 @@ from __future__ import annotations +import shlex from typing import Any +_LIST_WORDS = {"list", "ls", "browse"} +_SEARCH_WORDS = {"search", "find"} + def parse_session_listing_args(raw_args: str) -> tuple[bool, bool, str, str | None]: - """Parse `/sessions`-style args into listing flags, a resume target, and a search query. + """Parse `/sessions`-style args into ``(include_all_sources, include_unnamed, target, search_query)``. - Returns ``(include_all_sources, include_unnamed, target, search_query)``. ``all`` widens - source scope, ``full`` keeps unnamed sessions, ``search``/``find`` makes the rest a query - (``None`` = not requested, ``""`` = requested with no terms). Flags are honored only before - the first positional word so titles containing "all" aren't misparsed; anything else is a - target so `/sessions ` can delegate to `/resume`. + ``all`` widens source scope, ``full`` keeps unnamed sessions, ``search``/``find`` makes the rest + a query (``None`` = not requested, ``""`` = requested with no terms). Flags are honored only + before the first positional word so titles containing "all" aren't misparsed; anything else is + a target so `/sessions ` can delegate to `/resume`. """ - import shlex - parts = shlex.split(raw_args or "") include_all = False include_unnamed = False @@ -23,7 +24,7 @@ def parse_session_listing_args(raw_args: str) -> tuple[bool, bool, str, str | No for i, part in enumerate(parts): lower = part.strip().lower() if not target_parts: - if lower in {"list", "ls", "browse"}: + if lower in _LIST_WORDS: continue if lower in {"all", "--all"}: include_all = True @@ -31,9 +32,8 @@ def parse_session_listing_args(raw_args: str) -> tuple[bool, bool, str, str | No if lower in {"full", "--full"}: include_unnamed = True continue - if lower in {"search", "find"}: - query = " ".join(parts[i + 1:]).strip() - return include_all, include_unnamed, "", query + if lower in _SEARCH_WORDS: + return include_all, include_unnamed, "", " ".join(parts[i + 1:]).strip() target_parts.append(part) return include_all, include_unnamed, " ".join(target_parts).strip(), None @@ -51,22 +51,20 @@ def query_session_listing( limit: int = 10, exclude_sources: list[str] | None = None, ) -> list[dict[str, Any]]: - """Return session rows for interactive listing surfaces. + """Return session rows for interactive listing surfaces (shared CLI/gateway policy). - Shared CLI/gateway policy: source-scoped unless global is requested, unnamed hidden unless a - full listing is asked for, current session hidden unless requested (then marked - ``is_current_session``); ``session_key`` restricts gateway callers to one lane before the DB - limit applies. With ``search_query`` rows are filtered by title/id in SQL, ordered by recent - activity, and unnamed sessions stay visible since an id match may be the only handle. + Source-scoped unless global is requested; unnamed hidden unless a full listing is asked for; + current session hidden unless requested (then marked ``is_current_session``); ``session_key`` + restricts gateway callers to one lane before the DB limit applies. With ``search_query`` rows + are filtered by title/id in SQL, ordered by recent activity, and unnamed sessions stay visible + since an id match may be the only handle. """ - query_source = None if include_all_sources else source - fetch_limit = max(limit * 4, limit) search = (search_query or "").strip() rows = session_db.list_sessions_rich( - source=query_source, + source=None if include_all_sources else source, session_key=session_key, exclude_sources=exclude_sources, - limit=fetch_limit, + limit=max(limit * 4, limit), search_query=search or None, order_by_last_active=bool(search), ) @@ -78,8 +76,7 @@ def query_session_listing( if not include_unnamed and not row.get("title") and not search and not is_current: continue if is_current: - row = dict(row) - row["is_current_session"] = True + row = {**row, "is_current_session": True} result.append(row) if len(result) >= limit: break @@ -95,8 +92,8 @@ def format_gateway_session_listing( ) -> str: """Render a compact Markdown-ish session list for gateway messengers. - ``notice`` appends an explanatory line above the footer — used e.g. when a requested scope - widening (``all``) was declined so the caller isn't left guessing why sessions are missing. + ``notice`` adds an explanatory line above the footer — e.g. when a requested scope widening + (``all``) was declined, so the caller isn't left guessing why sessions are missing. """ if not rows: parts = [ @@ -110,14 +107,15 @@ def format_gateway_session_listing( lines = [f"📋 **{title}**", ""] for idx, row in enumerate(rows, start=1): - session_id = str(row.get("id") or "") - title_text = str(row.get("title") or "—") current_part = " (current)" if row.get("is_current_session") else "" preview = str(row.get("preview") or "")[:40] source = str(row.get("source") or "") source_part = f" `{source}`" if include_source and source else "" preview_part = f" — _{preview}_" if preview else "" - lines.append(f"{idx}. **{title_text}**{current_part}{source_part} — `{session_id}`{preview_part}") + lines.append( + f"{idx}. **{row.get('title') or '—'}**{current_part}{source_part}" + f" — `{row.get('id') or ''}`{preview_part}" + ) lines.append("") if notice: lines.append(notice)