refactor(constants): compact docstrings/comments and section banners in hermes_constants

This commit is contained in:
Teknium
2026-09-02 23:07:06 -07:00
parent d67246da4c
commit bb67389275
+99 -226
View File
@@ -16,11 +16,8 @@ _profile_fallback_warned: bool = False
_UNSET = object()
_HERMES_HOME_OVERRIDE: ContextVar[str | object] = ContextVar("_HERMES_HOME_OVERRIDE", default=_UNSET)
# ── TUI busy-indicator styles ─────────────────────────────────────────
# Single source of truth shared by the CLI /indicator command, the TUI
# gateway config handler, and the /help command registry. Keep in sync
# with ``INDICATOR_STYLES`` / ``DEFAULT_INDICATOR_STYLE`` in
# ``ui-tui/src/app/interfaces.ts`` on the frontend side.
# TUI busy-indicator styles (CLI /indicator, TUI gateway config, /help registry).
# Keep in sync with INDICATOR_STYLES / DEFAULT_INDICATOR_STYLE in ui-tui/src/app/interfaces.ts.
INDICATOR_STYLES: tuple[str, ...] = ("ascii", "emoji", "kaomoji", "unicode")
DEFAULT_INDICATOR_STYLE: str = "kaomoji"
@@ -28,8 +25,7 @@ DEFAULT_INDICATOR_STYLE: str = "kaomoji"
def set_hermes_home_override(path: str | Path | None) -> Token:
"""Set a context-local Hermes home override and return its reset token.
In-process, per-task scoping; deliberately does not mutate ``os.environ`` (shared by every
thread in the process).
Deliberately does not mutate ``os.environ`` (shared by every thread in the process).
"""
value: str | object = _UNSET if path is None else str(path)
return _HERMES_HOME_OVERRIDE.set(value)
@@ -56,10 +52,7 @@ def _get_platform_default_hermes_home() -> Path:
def _warn_profile_fallback_once() -> None:
"""Warn once when HERMES_HOME is unset but a non-default profile is sticky-active.
The fallback to the default profile is almost certainly wrong in that case.
"""
"""Warn once when HERMES_HOME is unset but a non-default profile is sticky-active (fallback is wrong)."""
global _profile_fallback_warned
if _profile_fallback_warned:
return
@@ -71,9 +64,8 @@ def _warn_profile_fallback_once() -> None:
active = ""
if active and active != "default":
_profile_fallback_warned = True
# Direct stderr, not ``logging``: this runs at module-import time from
# 30+ sites (often before logging is configured) and root-logger
# propagation would double-emit where a StreamHandler is attached.
# Direct stderr, not logging: runs at import time (often before logging is
# configured) and root-logger propagation would double-emit.
msg = (
f"[HERMES_HOME fallback] HERMES_HOME is unset but active "
f"profile is {active!r}. Falling back to {fallback_home}, which "
@@ -90,11 +82,7 @@ def _warn_profile_fallback_once() -> None:
def get_hermes_home() -> Path:
"""Return the Hermes home directory — the single source of truth.
Resolution order: context-local override (:func:`set_hermes_home_override`) → ``HERMES_HOME``
env var → platform-native default.
"""
"""Hermes home: context-local override → ``HERMES_HOME`` env var → platform default."""
override = get_hermes_home_override()
if override:
return Path(override)
@@ -104,10 +92,9 @@ def get_hermes_home() -> Path:
def hermes_home_key(path: str | Path | None = None) -> str:
"""Return a stable key for a Hermes home/profile directory.
"""Stable registry key for a Hermes home/profile dir.
Runtime registries use it to isolate plugin-owned entries while keeping built-in registrations
process-global. ``strict=False`` keeps working for profiles whose directories don't exist yet.
``strict=False`` so profiles whose directories don't exist yet still get a key.
"""
candidate = Path(path) if path is not None else get_hermes_home()
resolved = candidate.expanduser().resolve(strict=False)
@@ -115,29 +102,22 @@ def hermes_home_key(path: str | Path | None = None) -> str:
def get_process_hermes_home() -> Path:
"""Return the Hermes home for the running process, ignoring task overrides.
"""Hermes home of the running process, ignoring task overrides.
For machine/process-level dashboard-owned assets (theme YAML, dashboard plugin manifests) that
live under the server's launch home and must stay visible while a request is scoped to another
profile (e.g. embedded ``/chat`` under ``--open-profile``). Shared by :func:`get_hermes_home`.
For process-level assets (theme YAML, dashboard plugin manifests) that must stay visible while a
request is scoped to another profile (e.g. embedded ``/chat`` under ``--open-profile``).
"""
val = os.environ.get("HERMES_HOME", "").strip()
return Path(val) if val else _get_platform_default_hermes_home()
# Memo for get_default_hermes_root(): ~80us of path resolution per call at 31+
# sites (every _load_global_auth_store(), kanban, backup, gateway, update). The
# result depends only on (HERMES_HOME, native home), compared for free on each
# call, so it stays fresh even if a test or plugin mutates HERMES_HOME.
# get_default_hermes_root() memo keyed on (native home, HERMES_HOME) so it stays
# fresh when a test or plugin mutates HERMES_HOME; saves ~80us/call at 31+ sites.
_default_hermes_root_memo: "tuple[str, str, Path] | None" = None
def get_default_hermes_root() -> Path:
"""Return the root Hermes directory for profile-level operations.
In profile mode (``HERMES_HOME=<root>/profiles/<name>``) returns ``<root>`` so ``profile list``
sees all profiles; handles both ``~/.hermes/profiles/x`` and Docker ``/opt/data/profiles/x``.
"""
"""Root Hermes dir for profile-level ops: ``<root>`` when ``HERMES_HOME=<root>/profiles/<name>``."""
global _default_hermes_root_memo
native_home = _get_platform_default_hermes_home()
env_home = os.environ.get("HERMES_HOME", "")
@@ -150,29 +130,23 @@ def get_default_hermes_root() -> Path:
env_path = Path(env_home)
try:
env_path.resolve().relative_to(native_home.resolve()) # under ~/.hermes (normal or profile mode)
except ValueError:
# Docker / custom deployment: ``<root>/profiles/<name>`` roots at the grandparent,
# otherwise HERMES_HOME itself is the root.
except ValueError: # Docker/custom root: <root>/profiles/<name> -> <root>, else HERMES_HOME itself
result = env_path.parent.parent if env_path.parent.name == "profiles" else env_path
_default_hermes_root_memo = (str(native_home), env_home, result)
return result
# Deletion marker lives beside the profile dir (not inside) so a stale mkdir
# from live serve/logging or an rmtree cannot erase the fact of deletion.
# Tombstone lives beside the profile dir (not inside) so a stale mkdir or rmtree cannot erase it.
_DELETED_PROFILES_DIR = ".deleted"
# Files marking a real Hermes home (a fresh home gains one on first use);
# arbitrary dirs with a ``profiles`` segment (``/srv/profiles/buildcache``) do not.
# Files marking a real Hermes home; arbitrary dirs with a ``profiles`` segment lack them.
_HERMES_HOME_MARKERS = ("config.yaml", ".env", "state.db")
def _is_hermes_profiles_root(profiles_dir: Path) -> bool:
"""Return True when *profiles_dir* is a canonical ``<hermes-home>/profiles``.
"""True when *profiles_dir* is provably ``<hermes-home>/profiles``.
Fires only for directories provably under a Hermes home: the classic ``~/.hermes`` layout, a
root carrying Hermes-home marker files (Docker/custom ``HERMES_HOME``), a ``profiles/.deleted``
tombstone dir (only ``hermes profile delete`` creates it), or the resolved default Hermes root.
Accepts the classic ``~/.hermes`` layout, a root carrying Hermes-home marker files, a
``profiles/.deleted`` tombstone dir (only ``profile delete`` creates it), or the default root.
"""
root = profiles_dir.parent
if root.name == ".hermes":
@@ -193,17 +167,15 @@ def _is_hermes_profiles_root(profiles_dir: Path) -> bool:
def named_profile_home(path: str | Path) -> Path | None:
"""Return ``<root>/profiles/<name>`` when *path* is that home or under it.
Only ``.../profiles/<id>`` where ``<id>`` does not start with ``.`` AND the ``profiles`` parent is
a real Hermes home (:func:`_is_hermes_profiles_root`); a default home whose path merely contains
a ``profiles`` segment is not a named profile.
Requires ``<name>`` not to start with ``.`` and the ``profiles`` parent to be a real Hermes home;
a default home whose path merely contains a ``profiles`` segment is not a named profile.
"""
current = Path(path)
for candidate in (current, *current.parents):
if (candidate.parent.name == "profiles" and not candidate.name.startswith(".")
and _is_hermes_profiles_root(candidate.parent)):
return candidate
# Stop at a default Hermes home: a coincidental ``profiles/`` ancestor is not a root.
if candidate.name == ".hermes":
if candidate.name == ".hermes": # default home: a coincidental profiles/ ancestor is not a root
return None
return None
@@ -270,11 +242,9 @@ def get_bundled_skills_dir(default: Path | None = None) -> Path:
def get_hermes_dir(new_subpath: str, old_name: str, *, home: Path | None = None) -> Path:
"""Resolve a Hermes subdirectory with backward compatibility.
"""Resolve a Hermes subdirectory, honouring a populated legacy ``<old_name>/`` (no migration).
New installs get the consolidated layout (e.g. ``cache/images``); a populated legacy
``<old_name>/`` keeps being used — no migration. A bare empty ``<old_name>/`` does NOT count
(install scaffolds, manual mkdir, cleared locations) so it cannot shadow real data at the new path.
An empty legacy dir does NOT count (install scaffolds, manual mkdir) so it cannot shadow the new path.
"""
home = home or get_hermes_home()
old_path = home / old_name
@@ -282,14 +252,11 @@ def get_hermes_dir(new_subpath: str, old_name: str, *, home: Path | None = None)
def iter_hermes_node_dirs(home: Path | None = None) -> list[Path]:
"""Return Hermes-managed Node.js directories in preferred lookup order.
"""Hermes-managed Node dirs in lookup order; both Windows and POSIX shapes so migrated installs work.
Windows unpacks portable Node into ``%LOCALAPPDATA%\\hermes\\node``; POSIX uses
``$HERMES_HOME/node/bin``. Both shapes are included everywhere so migrated installs work.
Keep in sync with hermesManagedNodePathEntries() in apps/desktop/electron/backend-env.ts.
"""
node_dir = (home or get_hermes_home()) / "node"
# Keep in sync with hermesManagedNodePathEntries() in
# apps/desktop/electron/backend-env.ts (Electron cannot import this module).
return [node_dir, node_dir / "bin"] if sys.platform == "win32" else [node_dir / "bin", node_dir]
@@ -304,8 +271,7 @@ def _candidate_node_command_names(command: str) -> list[str]:
base = Path(command).name
if sys.platform != "win32" or "." in base:
return [base]
# Prefer npm.cmd: PowerShell may block npm.ps1 by policy and CreateProcess
# cannot launch a bare .ps1.
# Prefer npm.cmd: PowerShell may block npm.ps1 by policy; CreateProcess cannot launch a bare .ps1.
return _WINDOWS_NODE_SHIMS.get(base.lower(), [f"{base}.cmd", f"{base}.exe", base])
@@ -330,7 +296,7 @@ def _first_runnable_managed(names: list[str]) -> tuple[str | None, bool]:
def _run_version_probe(argv: list[str], **kwargs):
"""Run ``argv`` (a ``--version`` probe) hidden; ``None`` when it cannot run."""
"""Run a hidden ``--version`` probe; ``None`` when it cannot run."""
import subprocess
try:
@@ -353,12 +319,11 @@ _HERMES_NODE_TARGET_MAJOR = int(os.environ.get("HERMES_NODE_TARGET_MAJOR", "22")
_managed_node_heal_attempted = False
_NODE_BOOTSTRAP_SCRIPT = Path(__file__).resolve().parent / "scripts" / "lib" / "node-bootstrap.sh"
# Install tree root; secure_parent_dir() refuses to chmod inside it.
_INSTALL_ROOT = Path(__file__).resolve().parent
def _is_executable_file(path: str) -> bool:
"""``exists()`` follows symlinks — a dangling link never spawns a probe."""
"""``exists()`` follows symlinks, so a dangling link never spawns a probe."""
return os.path.exists(path) and os.access(path, os.X_OK)
@@ -377,10 +342,9 @@ def hermes_managed_node_tree_present(home: Path | None = None) -> bool:
def _path_under_any(path: str, roots: list[str]) -> bool:
"""Return True when *path* sits inside one of *roots* (same drive).
"""True when *path* sits inside one of *roots* (same drive).
Compared through ``normcase`` (no-op on POSIX): Windows paths are case-insensitive and psutil /
env vars can disagree on drive-letter casing.
Compared via ``normcase``: psutil and env vars can disagree on Windows drive-letter casing.
"""
path_norm = os.path.normcase(os.path.normpath(path))
for root in roots:
@@ -394,11 +358,10 @@ def _path_under_any(path: str, roots: list[str]) -> bool:
def managed_node_tree_in_use(home: Path | None = None) -> bool:
"""Return True when any running process executes from the managed Node tree.
"""True when a running process executes from the managed Node tree.
Windows locks running executables and loaded scripts against delete/overwrite, so the updater
must not rewrite ``%HERMES_HOME%\\node`` while the desktop app's Node processes hold it —
``PermissionError: [WinError 5]`` on ``npm.cmd`` is the classic symptom (#80926).
Windows locks running executables against delete/overwrite, so the updater must not rewrite
``%HERMES_HOME%\\node`` while the desktop app holds it (``[WinError 5]`` on ``npm.cmd``).
"""
if sys.platform != "win32":
return False
@@ -460,9 +423,9 @@ def _fetch_url(url: str, timeout: int) -> bytes | None:
def _stage_windows_node_zip(home: Path, node_arch: str) -> Path | None:
"""Download and extract the target-major portable Node zip into a sibling ``node.new-*`` dir.
"""Download the target-major portable Node zip into a sibling ``node.new-*`` dir.
A sibling staging dir makes the later swap a same-volume rename. ``None`` on any failure.
A sibling makes the later swap a same-volume rename. ``None`` on any failure.
"""
import tempfile
import uuid
@@ -502,9 +465,8 @@ def _stage_windows_node_zip(home: Path, node_arch: str) -> Path | None:
def _swap_node_tree(target: Path, staged: Path) -> bool | None:
"""Rename the live tree aside (``node.old-*``) and *staged* into place.
``None`` when the OS refuses to move the live tree (a running process holds it): the old tree
is untouched and the next resolution retries. ``False`` when the staged tree cannot be moved in
(live tree rolled back).
``None`` when the OS refuses to move the live tree (in use; untouched, retried next time),
``False`` when the staged tree cannot be moved in (live tree rolled back).
"""
backup = target.parent / f"node.old-{staged.name.removeprefix('node.new-')}"
had_live = target.exists()
@@ -515,9 +477,8 @@ def _swap_node_tree(target: Path, staged: Path) -> bool | None:
_print_managed_node_in_use_notice()
shutil.rmtree(staged, ignore_errors=True)
return None
# A rename preserves mtime, so a backup of a long-lived tree would look
# older than the litter-sweep cutoff to a concurrent heal. Touch it
# (best-effort — the swap already succeeded) so it is never swept mid-swap.
# Rename preserves mtime: touch the backup (best-effort) so a concurrent heal's
# litter sweep never removes it mid-swap.
try:
os.utime(backup, None)
except OSError:
@@ -541,15 +502,10 @@ def _swap_node_tree(target: Path, staged: Path) -> bool | None:
def _heal_managed_node_windows(home: Path | None = None) -> bool | None:
"""Redownload the portable Node zip into ``%HERMES_HOME%\\node`` on Windows.
Returns ``True`` on success, ``False`` on a genuine failure (offline, download error, bad
archive), ``None`` when the tree is in use and the heal is deferred — callers must not record
the once-per-process attempt for ``None`` so a later call can retry.
Staging-first: the new tree is fully extracted to a sibling ``node.new-*`` dir, the live tree
renamed aside (``node.old-*``), the staged tree renamed into place — so an interrupted heal
cannot gut the running install. Windows allows renaming a tree whose executables are running
(``FILE_SHARE_DELETE``); when the OS refuses the rename, that refusal *is* the in-use signal
and the heal defers instead of crashing with ``[WinError 5]`` on ``npm.cmd`` (#80926).
``True`` on success, ``False`` on genuine failure (offline, bad archive), ``None`` when the
tree is in use and the heal is deferred — callers must not record the once-per-process attempt
for ``None``. Staging-first (extract to ``node.new-*``, rename live aside, rename staged in) so
an interrupted heal cannot gut the install; a refused rename *is* the in-use signal.
"""
import time
@@ -559,13 +515,11 @@ def _heal_managed_node_windows(home: Path | None = None) -> bool | None:
return False
home = home or get_hermes_home()
target = home / "node"
# Cheap pre-check; the rename-based swap below is the authoritative guard.
# Cheap pre-check; the rename-based swap is the authoritative guard.
if managed_node_tree_in_use(home):
_print_managed_node_in_use_notice()
return None
# Sweep staging/backup litter from interrupted runs (locked files stay for
# next time). Only dirs older than 10 minutes, so a concurrent heal's
# in-flight swap (seconds old) is never disturbed.
# Sweep litter from interrupted runs; only dirs >10 min old so a concurrent in-flight swap survives.
cutoff = time.time() - 600
for stale in (*home.glob("node.old-*"), *home.glob("node.new-*")):
try:
@@ -601,11 +555,9 @@ def _run_node_bootstrap(func: str, *, timeout: int, **extra_env: str) -> bool:
def bootstrap_hermes_managed_node() -> str | None:
"""Install a Hermes-managed Node tree and return its npm path.
"""Install a Hermes-managed Node tree under ``$HERMES_HOME/node`` and return its npm path.
For when the only Node/npm on the machine belongs to the user (system, nvm, brew, Nix) and
cannot satisfy the repo's ``engines`` — Hermes never modifies a toolchain it does not own, so it
provisions its own tree under ``$HERMES_HOME/node`` (same tree a fresh install creates).
Hermes never modifies a user-owned toolchain (system, nvm, brew, Nix) that fails ``engines``.
"""
existing = find_hermes_node_executable("npm")
if existing:
@@ -613,8 +565,7 @@ def bootstrap_hermes_managed_node() -> str | None:
if sys.platform == "win32":
ok = _heal_managed_node_windows()
else:
# HERMES_NODE_SKIP_LINKS=1 keeps node/npm/npx out of ~/.local/bin so the
# user's own toolchain on PATH is never shadowed.
# HERMES_NODE_SKIP_LINKS=1 keeps node/npm/npx out of ~/.local/bin (never shadow the user's toolchain).
ok = _run_node_bootstrap("_nb_install_bundled_node", timeout=600, HERMES_NODE_SKIP_LINKS="1")
if not ok:
return None
@@ -622,11 +573,9 @@ def bootstrap_hermes_managed_node() -> str | None:
def heal_hermes_managed_node() -> bool:
"""Redownload Hermes-managed Node when the tree exists but is broken.
"""Redownload Hermes-managed Node when the tree exists but is broken; at most once per process.
At most once per process. POSIX shells out to ``heal_managed_node`` in node-bootstrap.sh;
Windows downloads the portable zip directly. A Windows in-use deferral does NOT record the
attempt so a later call can heal once the tree is free.
A Windows in-use deferral does NOT record the attempt so a later call can heal once free.
"""
global _managed_node_heal_attempted
if _managed_node_heal_attempted or not hermes_managed_node_tree_present():
@@ -642,11 +591,7 @@ def heal_hermes_managed_node() -> bool:
def _managed_node_tree_outdated(home: Path | None = None) -> bool:
"""True when the managed tree's node runs but is below the target major.
An outdated tree heals like a broken one (:func:`find_hermes_node_executable` triggers the
once-per-process heal), so existing users are upgraded on next launch.
"""
"""True when the managed node runs but is below the target major (heals like a broken tree)."""
for candidate in _iter_managed_node_candidates(_candidate_node_command_names("node"), home):
result = _run_version_probe([str(candidate), "--version"])
if result is None:
@@ -656,10 +601,8 @@ def _managed_node_tree_outdated(home: Path | None = None) -> bool:
major = int(version.split(".")[0])
except (ValueError, IndexError):
return False
# A pre-release counts as outdated however high its major: nodejs.org
# publishes headers only for final releases, so node-gyp cannot build
# node-pty against one and the install would stay broken forever.
# Mirrors node_satisfies_build() in scripts/install.sh.
# A pre-release is outdated whatever its major: nodejs.org publishes headers only for
# final releases, so node-gyp cannot build node-pty. Mirrors node_satisfies_build() in install.sh.
if "-" in version:
return True
return major < _HERMES_NODE_TARGET_MAJOR
@@ -667,11 +610,7 @@ def _managed_node_tree_outdated(home: Path | None = None) -> bool:
def find_hermes_node_executable(command: str) -> str | None:
"""Return a Hermes-managed Node/npm executable path, healing broken/outdated trees.
When the heal fails (offline, download error) an outdated-but-runnable tree is still returned:
old Node beats no Node.
"""
"""Hermes-managed Node/npm path, healing broken/outdated trees; heal failure still returns old Node."""
names = _candidate_node_command_names(command)
resolved, broken_present = _first_runnable_managed(names)
needs_heal = broken_present or (resolved is not None and _managed_node_tree_outdated())
@@ -683,11 +622,7 @@ def find_hermes_node_executable(command: str) -> str | None:
def find_node_executable_on_path(command: str) -> str | None:
"""Return a Node/npm executable from PATH with Windows shim ordering.
``shutil.which("npm")`` can pick the extensionless npm shim before ``.cmd`` on Windows; Python's
CreateProcess cannot execute that shim, so prefer the launchable variants explicitly.
"""
"""Node/npm from PATH; on Windows prefer ``.cmd``/``.exe`` (CreateProcess cannot run the bare shim)."""
if sys.platform != "win32":
return shutil.which(command)
command_str = str(command)
@@ -703,11 +638,9 @@ def find_node_executable_on_path(command: str) -> str | None:
def find_node_executable(command: str) -> str | None:
"""Resolve a Node.js command, preferring healthy Hermes-managed installs.
"""Resolve a Node command, preferring a healthy managed install.
For Hermes-owned subprocesses that must not break on a bad/missing/elevation-triggering system
Node on PATH. When a managed tree exists but cannot be healed, returns ``None`` rather than
falling back to system npm.
A managed tree that exists but cannot be healed yields ``None`` rather than system Node.
"""
managed = find_hermes_node_executable(command)
if managed:
@@ -731,26 +664,23 @@ def with_hermes_node_path(env: dict[str, str] | None = None) -> dict[str, str]:
def agent_browser_runnable(path: str | None) -> bool:
"""True only when *path* is an agent-browser CLI that actually runs (``--version`` exits 0).
"""True when *path* is an agent-browser CLI that runs (``--version`` exits 0) or the npx fallback.
A dead/wrong-arch/hung binary is rejected so callers fall through to the next candidate. The
``"npx agent-browser"`` fallback (two tokens, not a file) → True; npx validates at run time.
Dead/wrong-arch/hung binaries are rejected so callers try the next candidate.
"""
if not path:
return False
# The npx fallback is a two-token command string, not a filesystem path.
# The npx fallback is a two-token command string, not a path; npx validates at run time.
if " " in path and path.split()[0].endswith("npx"):
return True
return _is_executable_file(path) and _version_probe_ok(path)
def _legacy_path_has_content(path: Path) -> bool:
"""True iff *path* exists and has content worth honouring.
"""True iff *path* is a non-directory file or a populated directory.
A populated directory or any non-directory file counts; an empty directory does not, so a stale
stub falls through to the new layout. Any ``OSError`` other than not-found means "assume
occupied" to avoid orphaning legacy data. Symlinks are judged on their target; a dangling one
does NOT count and must not shadow populated new-layout data.
Non-not-found ``OSError`` means "assume occupied" (never orphan legacy data). Symlinks are
judged on their target; a dangling one does NOT count.
"""
try:
st = path.lstat()
@@ -772,33 +702,22 @@ def _legacy_path_has_content(path: Path) -> bool:
def display_hermes_home() -> str:
"""User-friendly ``~/`` display string for HERMES_HOME (``~/.hermes/profiles/coder``).
Use in user-facing messages instead of hardcoding ``~/.hermes``; for a real ``Path`` use
:func:`get_hermes_home`.
"""
"""User-facing ``~/`` display string for HERMES_HOME (``~/.hermes/profiles/coder``)."""
home = get_hermes_home()
try:
# as_posix(): on Windows str() renders backslashes, giving mixed
# chimeras like ``~/AppData\Local\hermes/skills/`` once callers append.
# as_posix(): str() on Windows yields chimeras like ~/AppData\Local\hermes/skills/.
return "~/" + home.relative_to(Path.home()).as_posix()
except ValueError:
return str(home)
def secure_parent_dir(path: Path) -> None:
"""Chmod ``0o700`` on the parent directory of *path*, but only if safe.
Refuses ``/`` and any top-level directory (resolved parent with fewer than 3 parts) so a
misresolved ``HERMES_HOME`` can never brick the host.
"""
"""Chmod ``0o700`` on *path*'s parent, refusing ``/`` and top-level dirs (misresolved HERMES_HOME)."""
parent = path.parent.resolve()
# Refuse root and its direct children (/usr, /home, /var, /tmp, …).
if parent == Path("/") or len(parent.parts) < 3:
return
# Refuse the install tree: chmod 0700 breaks hermes-user traversal in Docker
# (UID 10000) — #25821, #93050. A credential file here usually means
# HERMES_HOME misresolved; surface it (this caused production lockouts).
# Refuse the install tree: chmod 0700 breaks hermes-user traversal in Docker (UID 10000).
# A credential file here means HERMES_HOME misresolved; surface it (caused production lockouts).
if parent == _INSTALL_ROOT or _INSTALL_ROOT in parent.parents:
import logging
@@ -864,11 +783,10 @@ def _iter_real_home_candidates(env: dict[str, str] | None = None) -> list[str]:
def get_real_home(env: dict[str, str] | None = None) -> str:
"""Return the OS user's real home directory, avoiding Hermes profile HOME.
"""The OS user's real home, avoiding the Hermes profile HOME.
``HERMES_HOME`` scopes Hermes state; ``HOME`` belongs to the OS account and external CLIs that
keep credentials under ``~``. If a parent already runs with ``HOME={HERMES_HOME}/home``, repair
back to the account home when possible.
``HOME`` belongs to the OS account and external CLIs keeping credentials under ``~``; a parent
already running with ``HOME={HERMES_HOME}/home`` is repaired back when possible.
"""
profile_home = _profile_home_path(env)
seen: set[str] = set()
@@ -889,11 +807,10 @@ _HOME_MODE_ALIASES = {
def get_subprocess_home(env: dict[str, str] | None = None) -> str | None:
"""Return a subprocess ``HOME`` override, if one should be applied.
"""Subprocess ``HOME`` override, or ``None``.
``auto`` (default): hosts keep the real user HOME, containers use ``{HERMES_HOME}/home``; a
host parent whose HOME is the profile home is repaired back to real HOME. ``real``: always
prefer the real OS-user HOME. ``profile``: always the profile home.
``auto``: hosts keep real HOME (repairing a profile-home parent), containers use
``{HERMES_HOME}/home``; ``real``: always real HOME; ``profile``: always the profile home.
"""
env = env or {}
profile_home = _profile_home_path(env)
@@ -931,9 +848,8 @@ VALID_REASONING_EFFORTS = ("minimal", "low", "medium", "high", "xhigh", "max", "
def parse_reasoning_effort(effort) -> dict | None:
"""Parse a reasoning effort level into a config dict.
None for empty/unrecognized input (caller uses the default); ``{"enabled": False}`` for
"none" and its aliases ("false", "disabled", YAML boolean False) — ``reasoning_effort:
false``/``off``/``no`` must mean disabled, not "keep thinking".
``None`` for empty/unrecognized input (caller uses the default); ``{"enabled": False}`` for
"none"/"false"/"disabled"/YAML False — ``reasoning_effort: false`` must mean disabled.
"""
if effort is None or effort is True:
return None
@@ -946,10 +862,9 @@ def parse_reasoning_effort(effort) -> dict | None:
def _canonical_model_variants(model: str) -> list[str]:
"""Bounded spelling variants for tolerant override matching, exact first, deduped in order.
"""Spelling variants for tolerant override matching, exact first, deduped in order.
Version-dot recovery is applied to EACH base form so ``claude-opus-4.5``, ``claude-opus-4-5``
and ``claude-opus.4.5`` all yield the same variant set.
Dot/dash recovery runs on EACH base form so ``x-4.5``, ``x-4-5`` and ``x.4.5`` share one variant set.
"""
_dash_to_dot = lambda s: re.sub(r'(\d)-(\d)', r'\1.\2', s)
_dot_to_dash = lambda s: re.sub(r'(\d)\.(\d)', r'\1-\2', s)
@@ -963,7 +878,6 @@ def _canonical_model_variants(model: str) -> list[str]:
variants.append(v)
def _add_with_derivatives(s):
"""Add s plus its dots↔dashes and version-dot derivatives."""
dashed, dotted = s.replace('.', '-'), s.replace('-', '.')
_add(s, dashed, dotted, _dash_to_dot(s), _dot_to_dash(s), _dash_to_dot(dashed), _dot_to_dash(dotted))
_add_with_derivatives(model)
@@ -985,10 +899,9 @@ def _canonical_model_variants(model: str) -> list[str]:
def resolve_per_model_reasoning_effort(model: str, overrides: dict | None) -> dict | None:
"""Lookup a per-model reasoning_effort override with spelling tolerance.
"""Per-model reasoning_effort override with spelling tolerance; first non-None parse wins.
Order: exact → dots↔dashes → bare model (provider stripped) → aggregator stripped → known
provider/aggregator prefixes prepended. First non-None parse_reasoning_effort result wins.
Order: exact → dots↔dashes → provider stripped → aggregator stripped → known prefixes prepended.
"""
if not overrides or not isinstance(overrides, dict) or not model:
return None
@@ -1001,10 +914,9 @@ def resolve_per_model_reasoning_effort(model: str, overrides: dict | None) -> di
def resolve_reasoning_config(cfg: dict | None, model: str = "") -> dict | None:
"""Resolve the effective reasoning config for *model* from a config dict.
"""Effective reasoning config for *model*: per-model override, then global ``agent.reasoning_effort``.
Single chokepoint shared by every surface (CLI startup, gateway, Desktop/TUI, cron, ``/model``,
fallback activation): per-model override first, then the global ``agent.reasoning_effort``.
Single chokepoint for every surface (CLI, gateway, TUI, cron, ``/model``, fallback activation).
"""
cfg = cfg if isinstance(cfg, dict) else {}
agent_cfg = cfg.get("agent") if isinstance(cfg.get("agent"), dict) else {}
@@ -1019,8 +931,7 @@ def resolve_reasoning_config(cfg: dict | None, model: str = "") -> dict | None:
if per_model is not None:
return per_model
# Global fallback — keep the raw value: ``or ""`` would turn a YAML False
# into "" and silently re-enable thinking.
# Keep the raw value: ``or ""`` would turn a YAML False into "" and silently re-enable thinking.
effort = agent_cfg.get("reasoning_effort", "")
result = parse_reasoning_effort(effort)
if effort and str(effort).strip() and result is None:
@@ -1061,8 +972,7 @@ def windows_path_to_wsl(path: str) -> str | None:
def wsl_unc_path_to_posix(path: str) -> str | None:
"""Convert a Windows WSL UNC path (``\\\\wsl.localhost\\<distro>\\...`` or the
legacy ``\\\\wsl$\\...``) to a POSIX path inside the distro."""
"""Convert a ``\\\\wsl.localhost\\<distro>\\...`` (or legacy ``\\\\wsl$``) UNC path to POSIX."""
normalized = str(path or "").strip().replace("/", "\\")
match = re.match(r"^\\\\wsl(?:\.localhost|\$)\\[^\\]+\\(.*)$", normalized, re.IGNORECASE)
if not match:
@@ -1072,10 +982,8 @@ def wsl_unc_path_to_posix(path: str) -> str | None:
def translate_cwd_for_wsl_backend(cwd: str) -> str:
"""Normalize a cross-boundary cwd when Hermes itself runs inside WSL.
"""Map a Windows-host cwd (drive path or ``\\\\wsl.localhost\\`` UNC) to POSIX when Hermes runs in WSL.
A Windows-host UI (native picker / drive path / ``\\\\wsl.localhost\\`` UNC) can hand the WSL
backend a path it can't ``chdir`` into; map it to POSIX so picker, sidebar and sessions agree.
No-op off WSL and for paths already POSIX.
"""
if not is_wsl():
@@ -1091,9 +999,7 @@ _container_detected: bool | None = None
def is_container() -> bool:
"""True inside a container (Docker/Podman/LXC markers, Kubernetes env, cgroup/mountinfo
markers); cached per process.
"""
"""True inside a container (Docker/Podman/LXC/Kubernetes markers); cached per process."""
global _container_detected
if _container_detected is None:
_container_detected = _detect_container()
@@ -1121,9 +1027,6 @@ def _detect_container() -> bool:
return _proc_file_has_marker("/proc/self/mountinfo", ("kubepods", "containerd", "crio"))
# ─── Well-Known Paths ─────────────────────────────────────────────────────────
def get_config_path() -> Path:
"""Return the path to ``config.yaml`` under HERMES_HOME."""
return get_hermes_home() / "config.yaml"
@@ -1139,16 +1042,11 @@ def get_env_path() -> Path:
return get_hermes_home() / ".env"
# ─── Network Preferences ─────────────────────────────────────────────────────
def apply_ipv4_preference(force: bool = False) -> None:
"""Monkey-patch ``socket.getaddrinfo`` to prefer IPv4 connections when *force* is True.
"""Monkey-patch ``socket.getaddrinfo`` to prefer IPv4 when *force* is True.
On hosts with broken IPv6, Python tries AAAA first and hangs for the full TCP timeout before
falling back — this hits httpx, requests, urllib, the OpenAI SDK. ``family=AF_UNSPEC`` calls
resolve as ``AF_INET``; if no A record exists, fall back to full resolution so pure-IPv6 hosts
still work.
Broken-IPv6 hosts hang on AAAA for the full TCP timeout; ``AF_UNSPEC`` resolves as ``AF_INET``,
falling back to full resolution when no A record exists (pure-IPv6 hosts still work).
"""
if not force:
return
@@ -1170,27 +1068,17 @@ def apply_ipv4_preference(force: bool = False) -> None:
socket.getaddrinfo = _ipv4_getaddrinfo # type: ignore[assignment]
# ─── Streaming Response Constants ────────────────────────────────────────────
PARTIAL_STREAM_STUB_ID = "partial-stream-stub"
FINISH_REASON_LENGTH = "length"
OPENROUTER_BASE_URL = "https://openrouter.ai/api/v1"
OPENROUTER_MODELS_URL = f"{OPENROUTER_BASE_URL}/models"
AI_GATEWAY_BASE_URL = "https://ai-gateway.vercel.sh/v1"
# ─── Venv layout ─────────────────────────────────────────────────────────────
def venv_bin_dir(venv_dir, *, windows: bool | None = None) -> Path:
"""Directory holding a venv's executables (``Scripts`` / ``bin``).
"""Venv executable dir (``Scripts``/``bin``); *windows* lets tests exercise Windows paths on Linux.
*windows* lets callers pass their own platform verdict: tests patch predicates such as
``hermes_cli.main._is_windows`` to exercise Windows paths on Linux CI. Returned
unconditionally — callers differ on whether a missing venv is an error.
Returned unconditionally — callers differ on whether a missing venv is an error.
"""
if windows is None:
windows = sys.platform == "win32"
@@ -1198,11 +1086,7 @@ def venv_bin_dir(venv_dir, *, windows: bool | None = None) -> Path:
def project_venv_dir(project_root) -> Path | None:
"""The project's venv directory, ``venv`` or ``.venv``, when one exists.
``uv venv`` defaults to ``.venv`` while our installers create ``venv``; call sites that only
knew ``venv`` silently no-oped on ``.venv`` installs (#79542).
"""
"""The project's ``venv`` or ``.venv`` dir when one exists (``uv venv`` defaults to ``.venv``)."""
for name in ("venv", ".venv"):
candidate = Path(project_root) / name
if candidate.is_dir():
@@ -1216,11 +1100,8 @@ def venv_python_path(venv_dir, *, windows: bool | None = None) -> Path:
return bin_dir / ("python.exe" if bin_dir.name == "Scripts" else "python")
# ─── Partial-update diagnostics ──────────────────────────────────────────────
# First-party roots: an ImportError naming one means our own tree is
# inconsistent. `hermes_cli.update_cmd`'s post-update probe consumes this same
# set so the guard that BLOCKS and the hint that EXPLAINS never disagree.
# First-party roots: an ImportError naming one means our own tree is inconsistent. The
# update post-probe shares this set so the guard that BLOCKS and the hint that EXPLAINS agree.
FIRST_PARTY_MODULE_ROOTS = frozenset({
"agent", "acp_adapter", "cli", "cron", "gateway", "model_tools", "plugins",
"providers", "tools", "toolsets", "run_agent", "tui_gateway", "utils",
@@ -1228,21 +1109,13 @@ FIRST_PARTY_MODULE_ROOTS = frozenset({
def is_first_party_module(name: str | None) -> bool:
"""True when *name* is a module that ships with Hermes.
Exact match on the first dotted segment; ``startswith`` would also claim third-party
``agents``, ``agentops``, ``toolsets_x``.
"""
"""True when *name* ships with Hermes (exact first segment; ``startswith`` would claim ``agentops``)."""
root = str(name).split(".")[0] if name else ""
return bool(root) and (root in FIRST_PARTY_MODULE_ROOTS or root.startswith("hermes_"))
def partial_update_hint(exc: BaseException) -> list[str]:
"""Recovery guidance lines when *exc* looks like a half-updated tree, else ``[]``.
Users see an opaque crash with no hint that the *install* (not their config) is broken, and
``hermes update`` is exactly what they need but least trust after a failed update.
"""
"""Recovery guidance lines when *exc* looks like a half-updated tree, else ``[]``."""
# A missing third-party dep (bad venv, missing extra) is a different problem.
if not isinstance(exc, ImportError) or isinstance(exc, ModuleNotFoundError):
return []