Files
hermes-agent/hermes_constants.py

1429 lines
58 KiB
Python

"""Shared constants for Hermes Agent.
Import-safe module with no dependencies — can be imported from anywhere without risk of circular
imports.
"""
import os
import re
import shutil
import stat
import sys
from contextvars import ContextVar, Token
from pathlib import Path
_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.
INDICATOR_STYLES: tuple[str, ...] = ("ascii", "emoji", "kaomoji", "unicode")
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.
This is for in-process, per-task scoping. It deliberately does not mutate ``os.environ`` because
that is shared by every thread in the process.
"""
value: str | object = _UNSET if path is None else str(path)
return _HERMES_HOME_OVERRIDE.set(value)
def reset_hermes_home_override(token: Token) -> None:
"""Restore the previous context-local Hermes home override."""
_HERMES_HOME_OVERRIDE.reset(token)
def get_hermes_home_override() -> str | None:
"""Return the active context-local Hermes home override, if any."""
override = _HERMES_HOME_OVERRIDE.get()
return str(override) if override is not _UNSET and override else None
def _get_platform_default_hermes_home() -> Path:
"""Return the platform-native default Hermes home path."""
if sys.platform == "win32":
local_appdata = os.environ.get("LOCALAPPDATA", "").strip()
base = Path(local_appdata) if local_appdata else Path.home() / "AppData" / "Local"
return base / "hermes"
return Path.home() / ".hermes"
def _warn_profile_fallback_once() -> None:
"""Warn once when falling back to the default home while a profile is active.
Guard: if a non-default profile is sticky-active but ``HERMES_HOME`` is unset, the fallback to
the default profile is almost certainly wrong.
"""
global _profile_fallback_warned
if _profile_fallback_warned:
return
try:
fallback_home = _get_platform_default_hermes_home()
active_path = fallback_home / "active_profile"
active = active_path.read_text(encoding="utf-8").strip() if active_path.exists() else ""
except (UnicodeDecodeError, OSError):
active = ""
if active and active != "default":
_profile_fallback_warned = True
# Write directly to stderr. We intentionally do NOT route this
# through ``logging`` because (a) this function is called at
# module-import time from 30+ sites, often before logging is
# configured, and (b) root-logger propagation would double-emit
# on consoles where a StreamHandler is already attached.
msg = (
f"[HERMES_HOME fallback] HERMES_HOME is unset but active "
f"profile is {active!r}. Falling back to {fallback_home}, which "
f"is the DEFAULT profile — not {active!r}. Any data this "
f"process writes will land in the wrong profile. The "
f"subprocess spawner should pass HERMES_HOME explicitly "
f"(see issue #18594)."
)
try:
sys.stderr.write(msg + "\n")
sys.stderr.flush()
except Exception:
pass
def get_hermes_home() -> Path:
"""Return the Hermes home directory (default: platform-native path).
Resolution order: context-local override (see :func:`set_hermes_home_override`) →
``HERMES_HOME`` env var → the platform-native default. This is the single source of truth — all
other copies should import this.
"""
override = get_hermes_home_override()
if override:
return Path(override)
if not os.environ.get("HERMES_HOME", "").strip():
_warn_profile_fallback_once()
return get_process_hermes_home()
def hermes_home_key(path: str | Path | None = None) -> str:
"""Return a stable key for a Hermes home/profile directory.
Runtime registries use this key to isolate plugin-owned entries while keeping built-in
registrations process-global. ``strict=False`` preserves useful behavior for profiles whose
directories have not been created yet.
"""
candidate = Path(path) if path is not None else get_hermes_home()
resolved = candidate.expanduser().resolve(strict=False)
return os.path.normcase(str(resolved))
def get_process_hermes_home() -> Path:
"""Return the Hermes home for the running process, ignoring task overrides.
Unlike :func:`get_hermes_home`, this never follows the context-local override set by
:func:`set_hermes_home_override`.
Use this for machine/process-level dashboard-owned assets — theme YAML, dashboard plugin
manifests — that live under the server's launch home and must stay visible even while a request
is scoped to another profile (e.g. the embedded ``/chat`` running under ``--open-profile``).
Shared by :func:`get_hermes_home` so the two never drift.
"""
val = os.environ.get("HERMES_HOME", "").strip()
return Path(val) if val else _get_platform_default_hermes_home()
# Process-level memo for get_default_hermes_root(). The function resolves
# HERMES_HOME against the native home on every call (~80us of path
# resolution), and it is called at 31+ sites — every _load_global_auth_store()
# (per provider row in the /model picker), kanban, backup, gateway, update.
# Its result depends only on (HERMES_HOME, platform native home), which are
# compared for free on each call, so the memo is freshness-correct even if a
# test or plugin mutates HERMES_HOME mid-process.
_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 where ``HERMES_HOME`` is ``<root>/profiles/<name>``, returns ``<root>`` so that
``profile list`` can see all profiles. Works both for standard (``~/.hermes/profiles/coder``)
and Docker (``/opt/data/profiles/coder``) layouts.
Import-safe — no dependencies beyond stdlib.
"""
global _default_hermes_root_memo
native_home = _get_platform_default_hermes_home()
env_home = os.environ.get("HERMES_HOME", "")
if _default_hermes_root_memo is not None:
memo_native, memo_env, memo_result = _default_hermes_root_memo
if memo_native == str(native_home) and memo_env == env_home:
return memo_result
result = native_home
if env_home:
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.
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
# Named-profile deletion must survive stale mkdir from live serve/logging.
# The marker lives beside the profile dir, not inside it, so rmtree cannot
# erase the fact that the profile was deleted.
_DELETED_PROFILES_DIR = ".deleted"
# Files whose presence marks a directory as a real Hermes home. A fresh home
# always gains at least one of these on first use (config save, env backfill,
# session DB), while arbitrary directories that merely contain a ``profiles``
# path segment (e.g. ``/srv/profiles/buildcache``) do not.
_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``.
Anchors named-profile recognition so it only fires for directories that provably live under a
Hermes home: the classic ``~/.hermes`` layout, a root carrying Hermes-home marker files
(Docker/custom ``HERMES_HOME`` like ``/opt/data``), a ``profiles/.deleted`` tombstone directory
(only ever created by ``hermes profile delete``), or the process's resolved default Hermes root.
"""
root = profiles_dir.parent
if root.name == ".hermes":
return True
try:
if (profiles_dir / _DELETED_PROFILES_DIR).is_dir() or any(
(root / marker).exists() for marker in _HERMES_HOME_MARKERS
):
return True
except OSError:
pass
try:
return root.resolve(strict=False) == get_default_hermes_root().resolve(strict=False)
except OSError:
return False
def named_profile_home(path: str | Path) -> Path | None:
"""Return ``<root>/profiles/<name>`` when *path* is that home or under it.
A named profile home is only ``.../profiles/<id>`` where ``<id>`` does not start with ``.`` AND
the ``profiles`` directory's parent is a real Hermes home (see
:func:`_is_hermes_profiles_root`). A default Hermes home whose path merely contains a
``profiles`` segment (e.g.
"""
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 so a coincidental ``profiles/``
# ancestor is not treated as a named-profile root.
if candidate.name == ".hermes":
return None
return None
def profile_tombstone_path(profile_home: Path) -> Path:
return profile_home.parent / _DELETED_PROFILES_DIR / profile_home.name
def named_profile_is_deleted(profile_home: str | Path) -> bool:
return profile_tombstone_path(Path(profile_home)).exists()
def mark_named_profile_deleted(profile_home: str | Path) -> None:
marker = profile_tombstone_path(Path(profile_home))
marker.parent.mkdir(parents=True, exist_ok=True)
marker.write_text("deleted\n", encoding="utf-8")
def clear_named_profile_deleted(profile_home: str | Path) -> None:
profile_tombstone_path(Path(profile_home)).unlink(missing_ok=True)
def assert_named_profile_home_live(path: str | Path) -> None:
"""Refuse missing or tombstoned named profile homes."""
home = named_profile_home(path)
if home is None:
return
if named_profile_is_deleted(home) or not home.exists():
raise FileNotFoundError(
f"Named profile home does not exist: {home}. "
"Create the profile explicitly before using it."
)
def mkdir_under_hermes_home(path: str | Path) -> Path:
"""Create *path*, but never materialize a deleted/missing named profile."""
target = Path(path)
assert_named_profile_home_live(target)
target.mkdir(parents=True, exist_ok=True)
return target
def _packaged_dir(env_var: str, default: Path | None, subdir: str) -> Path:
"""Resolve a package-manager-relocatable directory.
Resolution order: 1. *env_var* (Nix wrapper / explicit override) 2. caller-supplied ``default``
(typically the source-checkout path) 3. ``<HERMES_HOME>/<subdir>`` last-resort.
"""
override = os.getenv(env_var, "").strip()
if override:
return Path(override)
return default if default is not None else get_hermes_home() / subdir
def get_optional_skills_dir(default: Path | None = None) -> Path:
"""Return the optional-skills directory, honoring package-manager wrappers."""
return _packaged_dir("HERMES_OPTIONAL_SKILLS", default, "optional-skills")
def get_optional_mcps_dir(default: Path | None = None) -> Path:
"""Return the optional-mcps directory, honoring package-manager wrappers.
Mirrors :func:`get_optional_skills_dir`: packaged installs may ship ``optional-mcps`` outside
the Python package tree and expose it via ``HERMES_OPTIONAL_MCPS``.
"""
return _packaged_dir("HERMES_OPTIONAL_MCPS", default, "optional-mcps")
def get_bundled_skills_dir(default: Path | None = None) -> Path:
"""Return the bundled skills directory for source and packaged installs.
Resolution order: 1. ``HERMES_BUNDLED_SKILLS`` env var (Nix wrapper / explicit override) 2.
Caller-supplied ``default`` (typically the source-checkout path) 3. ``<HERMES_HOME>/skills``
last-resort
"""
return _packaged_dir("HERMES_BUNDLED_SKILLS", default, "skills")
def get_hermes_dir(
new_subpath: str,
old_name: str,
*,
home: Path | None = None,
) -> Path:
"""Resolve a Hermes subdirectory with backward compatibility.
New installs get the consolidated layout (e.g. ``cache/images``). ``image_cache``) keep using it
— no migration required.
A bare empty ``<old_name>/`` directory does **not** count as "the legacy install is in use" —
install scaffolds, manual ``mkdir`` work, and cleared-then-abandoned locations all create empty
stubs that would otherwise silently shadow real data populated at ``<new_subpath>/``.
"""
home = home or get_hermes_home()
old_path = home / old_name
return old_path if _legacy_path_has_content(old_path) else home / new_subpath
def iter_hermes_node_dirs(home: Path | None = None) -> list[Path]:
"""Return Hermes-managed Node.js directories in preferred lookup order.
Windows installs unpack portable Node into ``%LOCALAPPDATA%\\hermes\\node``; POSIX installs use
``$HERMES_HOME/node/bin``. Both shapes are included on every platform so mixed or migrated
installs still work.
"""
node_dir = (home or get_hermes_home()) / "node"
# NOTE: keep this ordering in sync with hermesManagedNodePathEntries() in
# apps/desktop/electron/backend-env.ts — the Electron main process is Node
# and cannot import this module, so the platform-ordering rule is mirrored
# there (once; main.ts imports it rather than keeping its own copy).
return [node_dir, node_dir / "bin"] if sys.platform == "win32" else [node_dir / "bin", node_dir]
_WINDOWS_NODE_SHIMS = {
"npm": ["npm.cmd", "npm.exe", "npm"],
"npx": ["npx.cmd", "npx.exe", "npx"],
"node": ["node.exe", "node"],
}
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 execution policy, and
# CreateProcess cannot launch a bare .ps1 the way it can launch .cmd.
return _WINDOWS_NODE_SHIMS.get(base.lower(), [f"{base}.cmd", f"{base}.exe", base])
def _iter_managed_node_candidates(names: list[str], home: Path | None = None):
"""Yield existing (and on POSIX, executable) ``<node-dir>/<name>`` files."""
for directory in iter_hermes_node_dirs(home):
for name in names:
candidate = directory / name
if candidate.is_file() and (
sys.platform == "win32" or os.access(candidate, os.X_OK)
):
yield candidate
def _first_runnable_managed(names: list[str]) -> tuple[str | None, bool]:
"""Return ``(first runnable candidate, saw a broken one)``."""
broken = False
for candidate in _iter_managed_node_candidates(names):
resolved = str(candidate)
if node_tool_runnable(resolved):
return resolved, broken
broken = True
return None, broken
def _run_version_probe(argv: list[str], **kwargs):
"""Run ``argv`` (a ``--version`` probe) hidden; ``None`` when it cannot run."""
import subprocess
try:
from hermes_cli._subprocess_compat import windows_hide_flags
return subprocess.run(
argv,
capture_output=True,
timeout=10,
creationflags=windows_hide_flags(),
**kwargs,
)
except (OSError, subprocess.TimeoutExpired, ValueError):
return None
def _version_probe_ok(path: str) -> bool:
"""True when ``<path> --version`` exits 0 under the Hermes-managed Node PATH."""
result = _run_version_probe([path, "--version"], env=with_hermes_node_path())
return result is not None and result.returncode == 0
_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 (this file lives at <install_root>/hermes_constants.py).
# Used by secure_parent_dir() to skip chmod on the install dir — chmodding it
# 0700 breaks hermes-user traversal in Docker (UID 10000). See #25821, #93050.
_INSTALL_ROOT = Path(__file__).resolve().parent
def node_tool_runnable(path: str | None) -> bool:
"""Return True only when *path* is a Node/npm/npx binary that actually runs.
Probe with ``--version`` (same pattern as :func:`agent_browser_runnable`) so broken managed
wrappers are detected before use.
"""
if not path:
return False
if sys.platform == "win32":
if not Path(path).is_file():
return False
elif not os.path.exists(path) or not os.access(path, os.X_OK):
return False
return _version_probe_ok(path)
def hermes_managed_node_tree_present(home: Path | None = None) -> bool:
"""Return True when any Hermes-managed node/npm/npx shim exists on disk."""
names = [n for c in ("node", "npm", "npx") for n in _candidate_node_command_names(c)]
return next(_iter_managed_node_candidates(names, home), None) is not None
def _path_under_any(path: str, roots: list[str]) -> bool:
"""Return True when *path* sits inside one of *roots* (same drive).
Windows paths are case-insensitive and psutil / env vars can disagree on drive-letter casing,
so compare through ``normcase`` (a no-op on POSIX). Roots are evaluated individually.
"""
path_norm = os.path.normcase(os.path.normpath(path))
for root in roots:
root_norm = os.path.normcase(os.path.normpath(root))
try:
if os.path.commonpath([path_norm, root_norm]) == root_norm:
return True
except ValueError:
# Different drives on Windows — commonpath raises.
continue
return False
def managed_node_tree_in_use(home: Path | None = None) -> bool:
"""Return True when any running process executes from the managed Node tree.
Windows locks executables and loaded scripts against deletion or overwrite while a process runs
them, 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).
"""
if sys.platform != "win32":
return False
try:
import psutil
except Exception:
return False
dirs: list[str] = []
for directory in iter_hermes_node_dirs(home):
try:
dirs.append(str(Path(directory).resolve()))
except OSError:
continue
if not dirs:
return False
try:
procs = psutil.process_iter(["exe", "cmdline"])
except Exception:
return False
for proc in procs:
try:
info = proc.info
except Exception:
continue
exe = info.get("exe")
if exe:
try:
exe = str(Path(exe).resolve())
except (OSError, ValueError):
exe = str(exe)
if any(_path_under_any(p, dirs) for p in ([exe] if exe else []) + list(info.get("cmdline") or [])):
return True
return False
_managed_node_in_use_notice_printed = False
def _print_managed_node_in_use_notice() -> None:
"""Print the managed-Node deferral notice once per process."""
global _managed_node_in_use_notice_printed
if _managed_node_in_use_notice_printed:
return
_managed_node_in_use_notice_printed = True
print(
"→ Hermes-managed Node.js is in use by a running app; deferring its "
"upgrade until the app is closed (re-run `hermes update` afterwards).",
flush=True,
)
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), and ``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 once the tree is free.
The replacement is staging-first: the new tree is fully downloaded and
extracted to a sibling ``node.new-*`` directory, then the live tree is
renamed aside (``node.old-*``) and the staged tree renamed into place.
The live tree is never deleted before its replacement is ready, so an
interrupted heal cannot gut the running installation. Windows allows
renaming a tree whose executables are running (images are mapped with
``FILE_SHARE_DELETE`` — the same mechanism as the hermes.exe quarantine);
when the OS refuses the rename, that refusal *is* the in-use signal and
the heal defers instead of forcing the write and crashing with
``PermissionError: [WinError 5]`` on ``npm.cmd`` (#80926).
"""
import tempfile
import time
import urllib.request
import uuid
import zipfile
arch = (os.environ.get("PROCESSOR_ARCHITEW6432") or os.environ.get("PROCESSOR_ARCHITECTURE", "")).lower()
node_arch = {"amd64": "x64", "x86_64": "x64", "arm64": "arm64", "x86": "x86"}.get(arch)
if node_arch is None:
return False
home = home or get_hermes_home()
target = home / "node"
# Cheap pre-check: skip the download and staging work when the tree is
# already visibly in use. The rename-based swap below is the
# authoritative guard — this scan only avoids pointless re-downloads for
# long-lived processes whose npm resolution retries.
if managed_node_tree_in_use(home):
_print_managed_node_in_use_notice()
return None
# Best-effort sweep of staging/backup litter from interrupted runs; a
# locked file simply stays for the next attempt. Only dirs older than
# 10 minutes are removed so a concurrent heal's in-flight swap (whose
# staged/backup dirs are seconds old) is never disturbed.
cutoff = time.time() - 600
for stale in (*home.glob("node.old-*"), *home.glob("node.new-*")):
try:
if stale.stat().st_mtime < cutoff:
shutil.rmtree(stale, ignore_errors=True)
except OSError:
continue
def _fetch(url: str, timeout: int) -> bytes | None:
try:
with urllib.request.urlopen(url, timeout=timeout) as response:
return response.read()
except OSError:
return None
index_url = f"https://nodejs.org/dist/latest-v{_HERMES_NODE_TARGET_MAJOR}.x/"
index_bytes = _fetch(index_url, 60)
if index_bytes is None:
return False
match = re.search(
rf"node-v{_HERMES_NODE_TARGET_MAJOR}\.\d+\.\d+-win-{node_arch}\.zip",
index_bytes.decode("utf-8", errors="replace"),
)
if not match:
return False
zip_name = match.group(0)
zip_bytes = _fetch(f"{index_url}{zip_name}", 300)
if zip_bytes is None:
return False
token = uuid.uuid4().hex[:8]
staged = home / f"node.new-{token}"
backup = home / f"node.old-{token}"
try:
with tempfile.TemporaryDirectory() as tmp_dir:
tmp_path = Path(tmp_dir)
zip_path = tmp_path / zip_name
zip_path.write_bytes(zip_bytes)
extract_dir = tmp_path / "extract"
extract_dir.mkdir()
with zipfile.ZipFile(zip_path) as archive:
archive.extractall(extract_dir)
extracted = next(extract_dir.glob("node-v*"), None)
if extracted is None or not extracted.is_dir():
return False
# Move the fully-extracted tree to a sibling staging dir so the
# swap below is a same-volume rename.
shutil.move(str(extracted), str(staged))
except OSError:
return False
had_live = target.exists()
if had_live:
try:
os.replace(str(target), str(backup))
except OSError:
# The OS refuses to move the live tree — a running process holds
# it. Defer; the old tree is untouched and the next resolution
# (e.g. the next update after the app is closed) retries.
_print_managed_node_in_use_notice()
shutil.rmtree(staged, ignore_errors=True)
return None
# A rename preserves the directory's mtime, so a backup renamed from
# a long-lived tree would instantly look older than the litter-sweep
# cutoff to a concurrent heal. Touch it (best-effort — a failure
# must not abort the swap, which already succeeded) so the in-flight
# backup is never swept mid-swap.
try:
os.utime(backup, None)
except OSError:
pass
try:
os.replace(str(staged), str(target))
except OSError:
if had_live:
# Roll the live tree back and report the failure.
try:
os.replace(str(backup), str(target))
except OSError:
pass
shutil.rmtree(staged, ignore_errors=True)
return False
if had_live:
# The old tree is no longer canonical; locked files may keep it on
# disk until the next heal attempt, which is safe.
shutil.rmtree(backup, ignore_errors=True)
return node_tool_runnable(str(target / "node.exe"))
def _run_node_bootstrap(func: str, *, timeout: int, **extra_env: str) -> bool:
"""Source ``scripts/lib/node-bootstrap.sh`` and run shell function *func*."""
if not _NODE_BOOTSTRAP_SCRIPT.is_file():
return False
import subprocess
try:
result = subprocess.run(
["bash", "-c", f'source "{_NODE_BOOTSTRAP_SCRIPT}" && {func}'],
env={**os.environ, "HERMES_HOME": str(get_hermes_home()), **extra_env},
capture_output=True,
timeout=timeout,
check=False,
)
except (OSError, subprocess.SubprocessError):
return False
return result.returncode == 0
def bootstrap_hermes_managed_node() -> str | None:
"""Install a Hermes-managed Node tree and return its npm path.
Used when the only Node/npm on the machine belongs to the user (system, nvm, brew, Nix) and
cannot satisfy the repo's ``engines`` requirements — Hermes never modifies a toolchain it does
not own, so instead it provisions its own tree under ``$HERMES_HOME/node`` (the same tree a
fresh install creates) and works with that.
"""
existing = find_hermes_node_executable("npm")
if existing:
return existing
if sys.platform == "win32":
ok = _heal_managed_node_windows()
else:
# POSIX: ``_nb_install_bundled_node`` in node-bootstrap.sh builds the same tree a fresh
# install creates. HERMES_NODE_SKIP_LINKS=1 keeps node/npm/npx out of ~/.local/bin so the
# user's own toolchain on PATH is never shadowed.
ok = _run_node_bootstrap("_nb_install_bundled_node", timeout=600, HERMES_NODE_SKIP_LINKS="1")
if not ok:
return None
return _first_runnable_managed(_candidate_node_command_names("npm"))[0]
def heal_hermes_managed_node() -> bool:
"""Redownload Hermes-managed Node when the tree exists but is broken.
Runs at most once per process. POSIX shells out to ``heal_managed_node`` in node-bootstrap.sh;
Windows downloads the portable zip directly. A Windows deferral (tree in use by a running app)
does NOT record the attempt, so a later call or process can heal once the tree is free.
"""
global _managed_node_heal_attempted
if _managed_node_heal_attempted or not hermes_managed_node_tree_present():
return False
if sys.platform == "win32":
result = _heal_managed_node_windows()
if result is None:
# In-use deferral: leave the attempt flag clear so a later call
# in this process can heal after the app releases the tree.
return False
_managed_node_heal_attempted = True
return bool(result)
_managed_node_heal_attempted = True
return _run_node_bootstrap("heal_managed_node", timeout=300)
def _managed_node_tree_outdated(home: Path | None = None) -> bool:
"""Return 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, which redownloads the target major, so existing users are upgraded on
next launch rather than on the next installer run.
"""
for candidate in _iter_managed_node_candidates(_candidate_node_command_names("node"), home):
result = _run_version_probe([str(candidate), "--version"])
if result is None:
return False # broken, not outdated — the runnable probe handles it
try:
version = result.stdout.decode().strip().lstrip("v")
major = int(version.split(".")[0])
except (ValueError, IndexError):
return False
# A pre-release tree counts as outdated however high its major:
# nodejs.org publishes a headers tarball only for final releases, so
# node-gyp cannot build node-pty against one. Without this, an
# install that adopted such a tree stays broken forever — the heal
# only fires below the target major, and a pre-release is above it.
# Mirrors node_satisfies_build() in scripts/install.sh.
if "-" in version:
return True
return major < _HERMES_NODE_TARGET_MAJOR
return False
def find_hermes_node_executable(command: str) -> str | None:
"""Return a Hermes-managed Node/npm executable path, healing broken trees.
Outdated trees (major below ``_HERMES_NODE_TARGET_MAJOR``) heal the same way broken ones do.
When the heal fails (offline, download error) an outdated-but-runnable tree is still returned:
old Node beats no 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())
if needs_heal and heal_hermes_managed_node():
healed, _ = _first_runnable_managed(names)
if healed:
return healed
return resolved
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 resolve an extensionless npm shim before the ``.cmd`` shim on
Windows. Python's CreateProcess cannot execute that shim directly, so prefer the launchable
variants explicitly for Hermes-owned subprocesses.
"""
if sys.platform != "win32":
return shutil.which(command)
command_str = str(command)
if any(sep and sep in command_str for sep in (os.sep, os.altsep, "/", "\\")):
return command_str if Path(command_str).is_file() else None
for name in _candidate_node_command_names(command_str):
for directory in os.environ.get("PATH", "").split(os.pathsep):
if not directory:
continue
candidate = Path(directory) / name
if candidate.is_file():
return str(candidate)
return None
def find_node_executable(command: str) -> str | None:
"""Resolve a Node.js command, preferring healthy Hermes-managed installs.
This is for Hermes-owned subprocesses that should not be broken by a bad, missing, or elevation-
triggering system Node/npm on PATH. When a managed tree exists but cannot be healed, returns
``None`` instead of falling back to system npm on PATH.
"""
managed = find_hermes_node_executable(command)
if managed:
return managed
if hermes_managed_node_tree_present():
return None
return find_node_executable_on_path(command)
def with_hermes_node_path(env: dict[str, str] | None = None) -> dict[str, str]:
"""Return *env* with Hermes-managed Node directories prepended to PATH."""
merged = dict(os.environ if env is None else env)
existing = merged.get("PATH", "")
parts = [p for p in existing.split(os.pathsep) if p]
managed = [str(path) for path in iter_hermes_node_dirs() if path.is_dir()]
for entry in reversed(managed):
if entry not in parts:
parts.insert(0, entry)
merged["PATH"] = os.pathsep.join(parts)
return merged
def agent_browser_runnable(path: str | None) -> bool:
"""Return True only when *path* is an agent-browser CLI that actually runs.
This validates the candidate by resolving it to a real, executable file and running
``--version`` with a short timeout. Returns True only on a clean (exit 0) run, so a dead/wrong-
arch/hung binary is rejected and the caller can fall through to the next resolution candidate.
Special cases: * ``None`` / empty → False. * The ``"npx agent-browser"`` fallback form (contains
a space, not a real file) → True; npx resolves and validates the package at run time, so there
is nothing to stat here.
"""
if not path:
return False
# The npx fallback is a two-token command string, not a filesystem path.
if " " in path and path.split()[0].endswith("npx"):
return True
# exists() follows symlinks — a dangling link returns False here, so we
# never even spawn a subprocess for the broken-link case.
if not os.path.exists(path) or not os.access(path, os.X_OK):
return False
return _version_probe_ok(path)
def _legacy_path_has_content(path: Path) -> bool:
"""Return ``True`` iff ``path`` exists and has content worth honouring.
A populated directory or any non-directory file counts; an empty directory does not, so a
stale empty stub falls through to the new layout. Any ``OSError`` other than not-found means
"assume occupied" to avoid orphaning legacy data. Symlinks are resolved first; a dangling
symlink does NOT count and must not shadow populated new-layout data.
"""
try:
st = path.lstat()
if stat.S_ISLNK(st.st_mode):
st = path.stat() # judge a symlink on its target; dangling → FileNotFoundError
except FileNotFoundError:
return False
except OSError:
# PermissionError on a parent, or any other inspection failure:
# treat as occupied rather than silently orphaning legacy data.
return True
if not stat.S_ISDIR(st.st_mode):
return True
try:
next(path.iterdir())
except StopIteration:
return False
except OSError:
pass
return True
def display_hermes_home() -> str:
"""Return a user-friendly display string for the current HERMES_HOME.
Uses ``~/`` shorthand (``~/.hermes``, ``~/.hermes/profiles/coder``). Use this in user-facing
messages instead of hardcoding ``~/.hermes``; code needing a real ``Path`` should use
:func:`get_hermes_home`.
"""
home = get_hermes_home()
try:
# as_posix(): on Windows, str() of a relative Path renders
# backslashes, producing mixed-separator chimeras like
# ``~/AppData\Local\hermes/skills/`` once callers append
# sub-paths. ``~/`` shorthand implies POSIX rendering; keep the
# whole string consistent (forward slashes work everywhere,
# including Windows shells and Python APIs).
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 to chmod ``/`` or any top-level directory (resolved parent with fewer than 3 parts, i.e.
``/`` or any direct child like ``/usr``) to prevent catastrophic host bricking when
``HERMES_HOME`` or other path env vars resolve to an unexpected location.
"""
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 root. chmodding it 0700 breaks hermes-user
# traversal in Docker (UID 10000) and any other install where the
# runtime user doesn't own the install dir. See #25821, #93050.
if parent == _INSTALL_ROOT or _INSTALL_ROOT in parent.parents:
# A credential file inside the install tree usually means HERMES_HOME
# resolved somewhere unexpected — surface it instead of skipping
# silently, since this same misconfiguration previously caused
# production lockouts.
import logging
logging.getLogger(__name__).warning(
"Not restricting permissions on %s: it is inside the "
"hermes-agent install directory (%s). Credential files are "
"normally stored under the hermes home directory instead.",
parent,
_INSTALL_ROOT,
)
return
try:
os.chmod(parent, 0o700)
except OSError:
pass
def _norm_home_path(path: str | None) -> str:
"""Return a comparable absolute path string, or ``""`` for empty input."""
raw = (path or "").strip()
if not raw:
return ""
try:
return os.path.normcase(os.path.abspath(os.path.expanduser(raw)))
except Exception:
return os.path.normcase(raw)
def _profile_home_path(env: dict[str, str] | None = None) -> str | None:
"""Return ``{HERMES_HOME}/home`` when the profile-home directory exists."""
hermes_home = get_hermes_home_override() or (env or {}).get("HERMES_HOME") or os.getenv("HERMES_HOME")
if not hermes_home:
return None
profile_home = os.path.join(hermes_home, "home")
return profile_home if os.path.isdir(profile_home) else None
def _is_profile_home(candidate: str | None, profile_home: str | None) -> bool:
return bool(candidate and profile_home and _norm_home_path(candidate) == _norm_home_path(profile_home))
def _env_get(env: dict[str, str], key: str, default: str = "") -> str:
"""Stripped *key* from *env*, falling back to the process environment."""
return str(env.get(key) or os.getenv(key, default)).strip()
def _iter_real_home_candidates(env: dict[str, str] | None = None) -> list[str]:
"""Return likely OS-user home candidates in trust order."""
env = env or {}
candidates = [_env_get(env, "HERMES_REAL_HOME"), _env_get(env, "HOME")]
try:
import pwd
candidates.append(pwd.getpwuid(os.getuid()).pw_dir.strip()) # windows-footgun: ok — POSIX-only module inside try/except
except Exception:
pass
candidates.append(_env_get(env, "USERPROFILE"))
drive, path = _env_get(env, "HOMEDRIVE"), _env_get(env, "HOMEPATH")
if drive and path:
candidates.append(f"{drive}{path}" if path.startswith(("\\", "/")) else os.path.join(drive, path))
expanded = os.path.expanduser("~")
if expanded != "~":
candidates.append(expanded)
return [c for c in candidates if c]
def get_real_home(env: dict[str, str] | None = None) -> str:
"""Return the OS user's real home directory, avoiding Hermes profile HOME.
``HERMES_HOME`` scopes Hermes state; ``HOME`` belongs to the OS account and the external CLIs
that keep credentials under ``~``. If a parent already runs with ``HOME={HERMES_HOME}/home``,
this repairs back to the account home when possible.
"""
profile_home = _profile_home_path(env)
seen: set[str] = set()
for candidate in _iter_real_home_candidates(env):
key = _norm_home_path(candidate)
if not key or key in seen:
continue
seen.add(key)
if not _is_profile_home(candidate, profile_home):
return candidate
return "/tmp"
_HOME_MODE_ALIASES = {
"isolated": "profile", "profile_home": "profile", "profile-home": "profile",
"host": "real", "user": "real", "real_home": "real", "real-home": "real",
}
def get_subprocess_home(env: dict[str, str] | None = None) -> str | None:
"""Return a subprocess ``HOME`` override, if one should be applied.
* ``auto`` (default): host installs keep the real user HOME; containers use
``{HERMES_HOME}/home`` for persistent state. If a host parent already has HOME pointed at the
profile home, repair subprocesses back to real HOME. * ``real``: always prefer the real OS-user
HOME.
"""
env = env or {}
profile_home = _profile_home_path(env)
mode = _env_get(env, "TERMINAL_HOME_MODE", "auto").lower() or "auto"
mode = _HOME_MODE_ALIASES.get(mode, mode)
if mode == "profile":
return profile_home
real_home = get_real_home(env)
current_home = _env_get(env, "HOME")
repaired = real_home if _norm_home_path(real_home) != _norm_home_path(current_home) else None
if mode == "real":
return repaired
if profile_home and is_container():
return profile_home
if _is_profile_home(current_home, profile_home):
return repaired
return None
def apply_subprocess_home_env(env: dict[str, str]) -> None:
"""Apply Hermes' subprocess HOME contract to *env* in-place."""
real_home = get_real_home(env)
if real_home:
env["HERMES_REAL_HOME"] = real_home
home = get_subprocess_home(env)
if home:
env["HOME"] = home
VALID_REASONING_EFFORTS = ("minimal", "low", "medium", "high", "xhigh", "max", "ultra")
def parse_reasoning_effort(effort) -> dict | None:
"""Parse a reasoning effort level into a config dict.
Returns None for empty/unrecognized input (caller uses the default) and ``{"enabled": False}``
for "none" and its aliases ("false", "disabled", YAML boolean False): users write
``reasoning_effort: false``/``off``/``no`` and that must mean disabled, not "keep thinking".
Valid levels: none, minimal, low, medium, high, xhigh, max, ultra.
"""
if effort is False:
return {"enabled": False}
if effort is None or effort is True:
return None
effort = str(effort).strip().lower()
if not effort:
return None
if effort in {"none", "false", "disabled"}:
return {"enabled": False}
if effort in VALID_REASONING_EFFORTS:
return {"enabled": True, "effort": effort}
return None
def _canonical_model_variants(model: str) -> list[str]:
"""Generate bounded spelling variants for tolerant override matching.
Strategy: generate a small set of base forms, then apply version-dot recovery to EACH of them.
This ensures symmetry: ``claude-opus-4.5``, ``claude-opus-4-5``, and ``claude-opus.4.5`` all
produce the same variant set.
Duplicates removed in insertion order (exact always wins).
"""
# Version-dot regexes — digit-separator-digit interconversion
_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)
seen: set[str] = set()
variants: list[str] = []
def _add(*values):
for v in values:
if v and v not in seen:
seen.add(v)
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)
parts = model.split('/')
if len(parts) >= 2: # bare model (strip provider/aggregator prefix)
_add_with_derivatives(parts[-1])
if len(parts) >= 3: # strip aggregator only: "openrouter/anthropic/x" → "anthropic/x"
_add_with_derivatives('/'.join(parts[1:]))
known_providers = (
'anthropic', 'openai', 'google', 'openrouter', 'groq', 'mistral',
'xai', 'cohere', 'perplexity', 'together', 'fireworks', 'deepseek',
)
for v in [v for v in variants if '/' not in v]:
_add(*(f"{provider}/{v}" for provider in known_providers))
known_aggregators = ('openrouter', 'opencode', 'fireworks', 'groq', 'together')
for v in [v for v in variants if v.count('/') == 1]:
_add(*(f"{agg}/{v}" for agg in known_aggregators))
return variants
def resolve_per_model_reasoning_effort(model: str, overrides: dict | None) -> dict | None:
"""Lookup a per-model reasoning_effort override with spelling-tolerance.
Resolution order: 1. Exact match 2. Dots ↔ dashes variants 3. Strip provider prefix (bare model
name only) 4. Strip aggregator prefix (middle segment only) 5. Prepend known aggregator prefixes
to bare/single-slash variants
First non-None parse_reasoning_effort result wins.
"""
if not overrides or not isinstance(overrides, dict) or not model:
return None
for variant in _canonical_model_variants(model):
if variant in overrides:
result = parse_reasoning_effort(overrides[variant])
if result is not None:
return result
return None
def resolve_reasoning_config(cfg: dict | None, model: str = "") -> dict | None:
"""Resolve the effective reasoning config for *model* from a config dict.
Single chokepoint for reasoning-effort resolution, shared by every surface (CLI startup,
messaging gateway, Desktop/TUI, cron, ``/model`` switch, fallback activation). Priority:
"""
cfg = cfg if isinstance(cfg, dict) else {}
agent_cfg = cfg.get("agent")
if not isinstance(agent_cfg, dict):
agent_cfg = {}
if not model:
model_cfg = cfg.get("model")
if isinstance(model_cfg, dict):
model_cfg = model_cfg.get("default") or model_cfg.get("model") or ""
model = model_cfg.strip() if isinstance(model_cfg, str) else ""
overrides = agent_cfg.get("reasoning_overrides") or {}
per_model = resolve_per_model_reasoning_effort(model, overrides)
if per_model is not None:
return per_model
# Global fallback — keep the raw value; coercing with ``or ""`` turns a
# YAML boolean False into "", silently re-enabling thinking for users
# who explicitly disabled it.
effort = agent_cfg.get("reasoning_effort", "")
result = parse_reasoning_effort(effort)
if effort and str(effort).strip() and result is None:
import logging
logging.getLogger(__name__).warning(
"Unknown reasoning_effort '%s', using default (medium)", effort
)
return result
def is_termux() -> bool:
"""Return True when running inside a Termux (Android) environment.
Checks ``TERMUX_VERSION`` (set by Termux) or the Termux-specific ``PREFIX`` path. Import-safe —
no heavy deps.
"""
prefix = os.getenv("PREFIX", "")
return bool(os.getenv("TERMUX_VERSION") or "com.termux/files/usr" in prefix)
_wsl_detected: bool | None = None
def is_wsl() -> bool:
"""Return True when running inside WSL (Windows Subsystem for Linux).
Checks ``/proc/version`` for the ``microsoft`` marker that both WSL1 and WSL2 inject. Result is
cached for the process lifetime. Import-safe — no heavy deps.
"""
global _wsl_detected
if _wsl_detected is not None:
return _wsl_detected
try:
with open("/proc/version", "r", encoding="utf-8") as f:
_wsl_detected = "microsoft" in f.read().lower()
except Exception:
_wsl_detected = False
return _wsl_detected
def windows_path_to_wsl(path: str) -> str | None:
"""Convert a Windows drive path (``C:\\...``) to its ``/mnt/<drive>/...`` form."""
match = re.match(r"^([A-Za-z]):[\\/](.*)$", str(path or "").strip())
if not match:
return None
drive, tail = match.group(1).lower(), match.group(2).replace("\\", "/")
return f"/mnt/{drive}/{tail}"
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."""
normalized = str(path or "").strip().replace("/", "\\")
match = re.match(r"^\\\\wsl(?:\.localhost|\$)\\[^\\]+\\(.*)$", normalized, re.IGNORECASE)
if not match:
return None
tail = match.group(1).replace("\\", "/")
return f"/{tail}" if tail else "/"
def translate_cwd_for_wsl_backend(cwd: str) -> str:
r"""Normalize a cross-boundary cwd when Hermes itself runs inside 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 the POSIX equivalent so the picker, sidebar,
and sessions all agree on the workspace. No-op off WSL and for paths that are already POSIX.
"""
if not is_wsl():
return cwd
for translator in (wsl_unc_path_to_posix, windows_path_to_wsl):
translated = translator(cwd)
if translated is not None:
return translated
return cwd
_container_detected: bool | None = None
def is_container() -> bool:
"""Return True when running inside a container.
To cover those, also check: * ``KUBERNETES_SERVICE_HOST`` env var — set in every Kubernetes pod.
* ``kubepods`` / ``containerd`` / ``crio`` markers in ``/proc/1/cgroup``. * the same markers in
``/proc/self/mountinfo`` (cgroup-v2 fallback).
Result is cached for the process lifetime. Import-safe — no heavy deps.
"""
global _container_detected
if _container_detected is None:
_container_detected = _detect_container()
return _container_detected
def _proc_file_has_marker(path: str, markers: tuple[str, ...]) -> bool:
try:
with open(path, "r", encoding="utf-8") as f:
content = f.read()
except OSError:
return False
return any(marker in content for marker in markers)
def _detect_container() -> bool:
# Kubernetes always injects KUBERNETES_SERVICE_HOST into pod containers; absent on hosts.
if (
os.path.exists("/.dockerenv")
or os.path.exists("/run/.containerenv")
or os.environ.get("KUBERNETES_SERVICE_HOST")
or _proc_file_has_marker("/proc/1/cgroup", ("docker", "podman", "/lxc/", "kubepods", "containerd", "crio"))
):
return True
# cgroup v2: /proc/1/cgroup is just "0::/" with no marker. The container
# runtime still shows up in the mount table (overlay rootfs, runtime mount
# paths), so scan mountinfo as a last resort.
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"
def get_skills_dir() -> Path:
"""Return the path to the skills directory under HERMES_HOME."""
return get_hermes_home() / "skills"
def get_env_path() -> Path:
"""Return the path to the ``.env`` file under HERMES_HOME."""
return get_hermes_home() / ".env"
# ─── Network Preferences ─────────────────────────────────────────────────────
def apply_ipv4_preference(force: bool = False) -> None:
"""Monkey-patch ``socket.getaddrinfo`` to prefer IPv4 connections.
On servers with broken or unreachable IPv6, Python tries AAAA records first and hangs for the
full TCP timeout before falling back to IPv4. This affects httpx, requests, urllib, the OpenAI
SDK — everything that uses ``socket.getaddrinfo``.
When *force* is True, patches ``getaddrinfo`` so that calls with ``family=AF_UNSPEC`` (the
default) resolve as ``AF_INET`` instead, skipping IPv6 entirely. If no A record exists, falls
back to the original unfiltered resolution so pure-IPv6 hosts still work.
"""
if not force:
return
import socket
# Guard against double-patching
if getattr(socket.getaddrinfo, "_hermes_ipv4_patched", False):
return
_original_getaddrinfo = socket.getaddrinfo
def _ipv4_getaddrinfo(host, port, family=0, type=0, proto=0, flags=0):
if family == 0: # AF_UNSPEC — caller didn't request a specific family
try:
return _original_getaddrinfo(
host, port, socket.AF_INET, type, proto, flags
)
except socket.gaierror:
# No A record — fall back to full resolution (pure-IPv6 hosts)
return _original_getaddrinfo(host, port, family, type, proto, flags)
return _original_getaddrinfo(host, port, family, type, proto, flags)
_ipv4_getaddrinfo._hermes_ipv4_patched = True # type: ignore[attr-defined]
socket.getaddrinfo = _ipv4_getaddrinfo # type: ignore[assignment]
# ─── Streaming Response Constants ────────────────────────────────────────────
# Response ID for partial stream stubs used during error recovery
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``).
Canonical helper; this was open-coded in many places with three different Windows predicates.
*windows* lets callers pass their own platform verdict because tests patch predicates such as
``hermes_cli.main._is_windows`` to exercise Windows paths on Linux CI; reading ``sys.platform``
here would drop those paths from coverage. The path is returned unconditionally: callers differ
on whether a missing venv is an error, so existence checking stays with them.
"""
if windows is None:
windows = sys.platform == "win32"
return Path(venv_dir) / ("Scripts" if windows else "bin")
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``, so both layouts are in
the wild. Call sites that only knew about ``venv`` silently no-oped on a ``.venv`` install —
that is how the Windows shim-lock preflight skipped itself entirely (#79542).
"""
for name in ("venv", ".venv"):
candidate = Path(project_root) / name
if candidate.is_dir():
return candidate
return None
def venv_python_path(venv_dir, *, windows: bool | None = None) -> Path:
"""Path to the Python interpreter inside *venv_dir* (may not exist)."""
windows = sys.platform == "win32" if windows is None else windows
return venv_bin_dir(venv_dir, windows=windows) / ("python.exe" if windows else "python")
# ─── Partial-update diagnostics ──────────────────────────────────────────────
# Top-level packages/modules that ship as part of Hermes itself. An ImportError
# naming one of these means our own tree is inconsistent; anything else is a
# third-party problem with different remediation. Single source of truth —
# `hermes_cli.update_cmd`'s post-update probe consumes this same set so the
# guard that BLOCKS and the hint that EXPLAINS can never disagree.
FIRST_PARTY_MODULE_ROOTS = frozenset({
"agent", "acp_adapter", "cli", "cron", "gateway", "model_tools", "plugins",
"providers", "tools", "toolsets", "run_agent", "tui_gateway", "utils",
})
def is_first_party_module(name: str | None) -> bool:
"""True when *name* is a module that ships with Hermes.
Matches the first dotted segment against an exact set; a substring or ``startswith`` test would
also claim third-party ``agents``, ``agentops``, and ``toolsets_x``.
"""
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]:
"""Return recovery guidance lines when *exc* looks like a half-updated tree.
Users hit this as an opaque crash with no indication that the *install*, rather than their
config, is the problem — and `hermes update` is exactly the command they need but are least
likely to trust after a failed update. Return the guidance so callers can print it alongside the
raw error.
"""
# A missing third-party dependency is a different problem (bad venv, missing
# extra) with different remediation, so don't claim a partial update.
if not isinstance(exc, ImportError) or isinstance(exc, ModuleNotFoundError):
return []
if not is_first_party_module(getattr(exc, "name", None)):
return []
return [
"",
"This looks like a partially-updated install: one module was refreshed "
"and a related one was not.",
"Re-run the update to bring the whole tree to the same version:",
" hermes update",
"If that also fails, reinstall: https://hermes-agent.nousresearch.com",
]
def emit_partial_update_hint(exc: BaseException, *, file=None) -> bool:
"""Print recovery guidance for a half-updated tree."""
lines = partial_update_hint(exc)
if not lines:
return False
out = sys.stderr if file is None else file
for line in (f"Error: {exc}", *lines):
print(line, file=out)
return True