Merge branch 'simp/r3-23' into simp/integration3

This commit is contained in:
Teknium
2026-09-02 21:57:22 -07:00
32 changed files with 2241 additions and 5108 deletions
+202 -418
View File
@@ -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 <custom>"
)
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 <custom>")
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:")
+31 -100
View File
@@ -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)
+84 -265
View File
@@ -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 ``#<ref>`` 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 <name> to install under a "
"new profile."
"root profile (~/.hermes). Pass --name <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 <source> --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()
+392 -973
View File
File diff suppressed because it is too large Load Diff
+36 -63
View File
@@ -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 <name>`.")
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.<op>(conn, proj.id)`` followed by ``"<verb> <slug>"``."""
@_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,
}
+113 -334
View File
@@ -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: ``<project-slug>/<task-id>`` (optionally ``-<title-slug>``). Stable and human-meaningful,
replacing the random ``wt/<task-id>`` fallback.
"""
slug = project.slug or _slugify(project.name)
base = f"{slug}/{task_id}"
"""Deterministic ``<project-slug>/<task-id>[-<title-slug>]`` branch name for a project-linked kanban
task (stable and human-meaningful, replacing the random ``wt/<task-id>`` 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
+75 -159
View File
@@ -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"<available_skills>.*?</available_skills>", re.DOTALL)
# A rendered skill entry inside <available_skills> 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 ``<available_skills>``.
* ``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 <available_skills> 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))
+18 -55
View File
@@ -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
+4 -15
View File
@@ -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 <name>`` CLI flag.
ADAPTERS: Dict[str, Type[UpstreamAdapter]] = {
"nous": NousPortalAdapter,
"xai": XAIGrokAdapter,
}
# Keyed by the ``hermes proxy start --provider <name>`` 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]()
+13 -39
View File
@@ -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
+19 -68
View File
@@ -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)
+9 -36
View File
@@ -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
)
+27 -51
View File
@@ -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 <name>]"
)
print("\nStart the proxy with: hermes proxy start [--provider <name>]")
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 <subcommand>``."""
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 <subcommand>``; 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"]
+110 -206
View File
@@ -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"]
+24 -48
View File
@@ -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"]
+209 -421
View File
@@ -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-<unix>`` sibling is plain JSON and lets
# the operator manually recover tokens if they realise the
# rotation was a mistake.
return None
# Plain-JSON ``.rotated-<ts>`` 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)"))),
+13 -43
View File
@@ -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
+131 -294
View File
@@ -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 <modifier>+Enter (Kitty CSI-u ``ESC[13;<m>u`` plus lock-bit twins, xterm
``ESC[27;<m>;13~`` / ``;13u``) to (Escape, ControlM) so the Alt+Enter newline handler fires.
"""Map <modifier>+Enter (Kitty CSI-u ``ESC[13;<m>u`` plus lock twins, xterm ``ESC[27;<m>;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;<cp>~``; 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+<ch> 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+<ch>: 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, <letter>) ----
# Under modifyOtherKeys, Alt+a = ESC[27;3;97~. Without mapping, this
# leaks as literal text. prompt_toolkit handles bare Alt+letter as
# (Escape, <letter>), 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[<code>;129u etc.
for mod in _lock_twins(1):
for mod in _lock_twins(1): # with a lock on these arrive as ESC[<code>;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)
+34 -85
View File
@@ -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
+22 -40
View File
@@ -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)
+26 -72
View File
@@ -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"
+20 -41
View File
@@ -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()
))
+14 -38
View File
@@ -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"]
+49 -134
View File
@@ -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:
# ``<id> <iso8601_timestamp>``).
#
# 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
# ``<id> <timestamp>`` 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.")
+74 -206
View File
@@ -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<name>[A-Za-z0-9][A-Za-z0-9._-]*)
(?:\[[^\]]+\])? # extras
\s*==\s*
(?P<version>[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<name>[A-Za-z0-9][A-Za-z0-9._-]*)(?:\[[^\]]+\])?\s*==\s*(?P<version>[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:
+35 -87
View File
@@ -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
+153 -362
View File
@@ -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 ``<scandir>/gateway-<profile>/``. We reject anything that
# could traverse paths, span filesystems, or break s6's own naming rules.
# Profile names become s6 service directory names (``<scandir>/gateway-<profile>/``), 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 <profile>`` 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 <profile>``); 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><op>`` 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><op>`` 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-<profile>`` 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
``<root>/profiles/<profile>``.
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 ``<root>/profiles/<profile>``.
"""
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-<profile>``) 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 <action_flag>``; 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-<profile>.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-<profile>.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 [
+70 -105
View File
@@ -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"<details><summary>{html_escape(tool_name)}</summary>", "",
_fenced_text(text), "", "</details>", "",
]
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}"
+73 -111
View File
@@ -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 = """<!DOCTYPE html>
</html>
"""
def _escape_html(text: str) -> str:
if not isinstance(text, str):
text = str(text)
return text.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;").replace('"', "&quot;").replace("'", "&#39;")
def _escape_html(text: Any) -> str:
return (
str(text).replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
.replace('"', "&quot;").replace("'", "&#39;")
)
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}<div class="{outer_class or kind}">
{indent} <div class="{kind}-header">
{indent} {_CHEVRON_HTML}
@@ -685,98 +684,66 @@ def _collapsible(kind: str, icon: str, title: str, inner: str, indent: str, oute
{indent} {inner}
{indent} </div>
{indent}</div>
{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-<role>` 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>`).
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'<div class="{msg_class}"{delay_style}>'
html += f' <div class="message-header">'
html += f' <div class="role-badge">{_CHEVRON_HTML} {role_icon} {safe_role}</div>'
html += f' <div class="timestamp">{timestamp}</div>'
html += ' </div>'
html += ' <div class="message-body">'
delay_style = f' style="animation-delay: {min(i * 0.05, 1.0)}s"' if i < 10 else "" # stagger first items
html = (
f'<div class="message message-{role_class} active"{delay_style}>'
f' <div class="message-header">'
f' <div class="role-badge">{_CHEVRON_HTML} {_ROLE_ICONS.get(role, ICON_TERMINAL)} {_escape_html(role)}</div>'
f' <div class="timestamp">{_format_timestamp(msg.get("timestamp", 0))}</div>'
' </div>'
' <div class="message-body">'
)
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"<pre><code>{_escape_html(args)}</code></pre>", " " * 16,
"tool-call", ICON_WRENCH, f"Tool Call: {_escape_html(fn.get('name', 'unknown'))}",
f"<pre><code>{_escape_html(fn.get('arguments', '{}'))}</code></pre>", " " * 16,
)
if content:
if role == "tool":
html += f' <div class="content"><pre><code>{_escape_html(content)}</code></pre></div>'
else:
html += f' <div class="content">{_escape_html(content)}</div>'
reasoning = msg.get("reasoning") or msg.get("reasoning_content")
if reasoning:
escaped = _escape_html(content)
body = f"<pre><code>{escaped}</code></pre>" if role == "tool" else escaped
html += f' <div class="content">{body}</div>'
if reasoning := msg.get("reasoning") or msg.get("reasoning_content"):
html += _collapsible(
"reasoning", ICON_SPARKLES, "Reasoning",
f'<div class="content">{_escape_html(reasoning)}</div>', " " * 12,
)
html += ' </div>'
html += '</div>'
html_list.append(html)
html_list.append(html + " </div></div>")
return "\n".join(html_list)
def generate_multi_session_html_export(sessions: List[Dict[str, Any]]) -> str:
if not sessions:
return "<html><body><h1>No sessions to export.</h1></body></html>"
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'''
<a class="session-item" data-id="{escaped_sid}" href="#{quote(sid, safe='')}">
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"""
<a class="session-item" data-id="{_escape_html(sid)}" href="#{quote(sid, safe='')}">
<div class="session-item-title">{_escape_html(title)}</div>
<div class="session-item-meta">
<span>{_escape_html(sid[:8])}</span>
<span>{date}</span>
</div>
</a>
'''
sidebar_items.append(item)
sidebar_html = f'''
""")
return f"""
<aside class="sidebar">
<div class="sidebar-header">
<div class="sidebar-brand">
@@ -788,59 +755,54 @@ def generate_multi_session_html_export(sessions: List[Dict[str, Any]]) -> str:
</div>
</div>
<div class="session-list">
{"".join(sidebar_items)}
{"".join(items)}
</div>
</aside>
'''
"""
# 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'<div class="content">{_escape_html(system_prompt)}</div>', " " * 12,
outer_class="system-prompt-section active",
)
session_html = f'''
<div class="{view_class}" id="view-{escaped_sid}">
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'<div class="content">{_escape_html(system_prompt)}</div>', " " * 12,
outer_class="system-prompt-section active",
)
return f"""
<div class="{"session-view" if is_multi else "session-view active"}" id="view-{escaped_sid}">
<header class="fade-in">
<h1>{_escape_html(title)}</h1>
<h1>{_escape_html(s.get("title") or "Hermes Session")}</h1>
<div class="meta">
<div class="meta-item"><strong>ID:</strong> {escaped_sid}</div>
<div class="meta-item"><strong>Model:</strong> {_escape_html(model)}</div>
<div class="meta-item"><strong>Started:</strong> {started_at}</div>
<div class="meta-item"><strong>Model:</strong> {_escape_html(s.get("model") or "Unknown")}</div>
<div class="meta-item"><strong>Started:</strong> {_format_timestamp(s.get("started_at", 0))}</div>
</div>
{system_html}
</header>
<main>
{messages_html}
{_generate_messages_html(s.get("messages", []))}
</main>
</div>
'''
sessions_html_list.append(session_html)
"""
def generate_multi_session_html_export(sessions: List[Dict[str, Any]]) -> str:
if not sessions:
return "<html><body><h1>No sessions to export.</h1></body></html>"
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])
+81 -122
View File
@@ -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
+53 -88
View File
@@ -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)"
+27 -29
View File
@@ -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 <id-or-title>` 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 <id-or-title>` 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)