diff --git a/hermes_constants.py b/hermes_constants.py index 0b8b494cae..ab2cfbea75 100644 --- a/hermes_constants.py +++ b/hermes_constants.py @@ -1,7 +1,6 @@ """Shared constants for Hermes Agent. -Import-safe module with no dependencies — can be imported from anywhere without risk of circular -imports. +Import-safe, stdlib-only — importable from anywhere without circular-import risk. """ import os @@ -29,8 +28,8 @@ 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. + In-process, per-task scoping; 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) @@ -57,10 +56,9 @@ def _get_platform_default_hermes_home() -> Path: def _warn_profile_fallback_once() -> None: - """Warn once when falling back to the default home while a profile is active. + """Warn once when HERMES_HOME is unset but a non-default profile is sticky-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. + The fallback to the default profile is almost certainly wrong in that case. """ global _profile_fallback_warned if _profile_fallback_warned: @@ -73,11 +71,9 @@ def _warn_profile_fallback_once() -> None: 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. + # 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. msg = ( f"[HERMES_HOME fallback] HERMES_HOME is unset but active " f"profile is {active!r}. Falling back to {fallback_home}, which " @@ -94,11 +90,10 @@ def _warn_profile_fallback_once() -> None: def get_hermes_home() -> Path: - """Return the Hermes home directory (default: platform-native path). + """Return the Hermes home directory — the single source of truth. - 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. + Resolution order: context-local override (:func:`set_hermes_home_override`) → ``HERMES_HOME`` + env var → platform-native default. """ override = get_hermes_home_override() if override: @@ -111,9 +106,8 @@ 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. - 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. + 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. """ candidate = Path(path) if path is not None else get_hermes_home() resolved = candidate.expanduser().resolve(strict=False) @@ -123,36 +117,26 @@ 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. - 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. + 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`. """ 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. +# 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. _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 ``/profiles/``, returns ```` 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. + In profile mode (``HERMES_HOME=/profiles/``) returns ```` so ``profile list`` + sees all profiles; handles both ``~/.hermes/profiles/x`` and Docker ``/opt/data/profiles/x``. """ global _default_hermes_root_memo native_home = _get_platform_default_hermes_home() @@ -175,25 +159,21 @@ def get_default_hermes_root() -> Path: 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. +# 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. _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. +# 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. _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 ``/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. + 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. """ root = profiles_dir.parent if root.name == ".hermes": @@ -214,10 +194,9 @@ def _is_hermes_profiles_root(profiles_dir: Path) -> bool: def named_profile_home(path: str | Path) -> Path | None: """Return ``/profiles/`` when *path* is that home or under it. - A named profile home is only ``.../profiles/`` where ```` 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. + Only ``.../profiles/`` where ```` 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. """ current = Path(path) for candidate in (current, *current.parents): @@ -227,8 +206,7 @@ def named_profile_home(path: str | Path) -> Path | None: 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. + # Stop at a default Hermes home: a coincidental ``profiles/`` ancestor is not a root. if candidate.name == ".hermes": return None return None @@ -275,8 +253,8 @@ def mkdir_under_hermes_home(path: str | Path) -> Path: 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. ``/`` last-resort. + Order: *env_var* (Nix wrapper / explicit override) → caller ``default`` (source checkout) → + ``/``. """ override = os.getenv(env_var, "").strip() if override: @@ -290,21 +268,12 @@ def get_optional_skills_dir(default: Path | None = None) -> Path: 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 the optional-mcps directory, honoring package-manager wrappers (``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. ``/skills`` - last-resort - """ + """Return the bundled skills directory, honoring package-manager wrappers (``HERMES_BUNDLED_SKILLS``).""" return _packaged_dir("HERMES_BUNDLED_SKILLS", default, "skills") @@ -316,12 +285,9 @@ def get_hermes_dir( ) -> 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 ``/`` 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 installs get the consolidated layout (e.g. ``cache/images``); a populated legacy + ``/`` keeps being used — no migration. A bare empty ``/`` does NOT count + (install scaffolds, manual mkdir, cleared locations) so it cannot shadow real data at the new path. """ home = home or get_hermes_home() old_path = home / old_name @@ -331,15 +297,12 @@ def get_hermes_dir( 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. + Windows unpacks portable Node into ``%LOCALAPPDATA%\\hermes\\node``; POSIX uses + ``$HERMES_HOME/node/bin``. Both shapes are included everywhere so migrated installs 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). + # 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] @@ -354,8 +317,8 @@ 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. + # Prefer npm.cmd: PowerShell may block npm.ps1 by policy and CreateProcess + # cannot launch a bare .ps1. return _WINDOWS_NODE_SHIMS.get(base.lower(), [f"{base}.cmd", f"{base}.exe", base]) @@ -409,24 +372,23 @@ _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 /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 tree root; secure_parent_dir() refuses to chmod inside it. _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. +def _is_executable_file(path: str) -> bool: + """``exists()`` follows symlinks — a dangling link never spawns a probe.""" + return os.path.exists(path) and os.access(path, os.X_OK) - Probe with ``--version`` (same pattern as :func:`agent_browser_runnable`) so broken managed - wrappers are detected before use. - """ + +def node_tool_runnable(path: str | None) -> bool: + """True only when *path* is a Node/npm/npx binary that actually runs (``--version`` probe).""" 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): + elif not _is_executable_file(path): return False return _version_probe_ok(path) @@ -440,8 +402,8 @@ 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). - 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. + Compared through ``normcase`` (no-op on POSIX): Windows paths are case-insensitive and psutil / + env vars can disagree on drive-letter casing. """ path_norm = os.path.normcase(os.path.normpath(path)) for root in roots: @@ -449,8 +411,7 @@ def _path_under_any(path: str, roots: list[str]) -> bool: try: if os.path.commonpath([path_norm, root_norm]) == root_norm: return True - except ValueError: - # Different drives on Windows — commonpath raises. + except ValueError: # different drives on Windows continue return False @@ -458,10 +419,9 @@ 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. - 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). + 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). """ if sys.platform != "win32": return False @@ -516,21 +476,15 @@ def _print_managed_node_in_use_notice() -> 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), 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. + 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. - 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). + 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). """ import tempfile import time @@ -546,18 +500,14 @@ def _heal_managed_node_windows(home: Path | None = None) -> bool | None: 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. + # Cheap pre-check; the rename-based swap below is the authoritative guard. 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. + # 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. cutoff = time.time() - 600 for stale in (*home.glob("node.old-*"), *home.glob("node.new-*")): try: @@ -605,8 +555,7 @@ def _heal_managed_node_windows(home: Path | None = None) -> bool | None: 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. + # Sibling staging dir so the swap below is a same-volume rename. shutil.move(str(extracted), str(staged)) except OSError: return False @@ -616,17 +565,14 @@ def _heal_managed_node_windows(home: Path | None = None) -> bool | None: 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. + # A running process holds the live tree. Defer; the old tree is + # untouched and the next resolution 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. + # 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. try: os.utime(backup, None) except OSError: @@ -634,8 +580,7 @@ def _heal_managed_node_windows(home: Path | None = None) -> bool | None: try: os.replace(str(staged), str(target)) except OSError: - if had_live: - # Roll the live tree back and report the failure. + if had_live: # roll the live tree back try: os.replace(str(backup), str(target)) except OSError: @@ -643,8 +588,7 @@ def _heal_managed_node_windows(home: Path | None = None) -> bool | None: 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. + # Locked files may keep the old tree on disk until the next heal; safe. shutil.rmtree(backup, ignore_errors=True) return node_tool_runnable(str(target / "node.exe")) @@ -672,10 +616,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. - 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. + 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). """ existing = find_hermes_node_executable("npm") if existing: @@ -683,8 +626,7 @@ def bootstrap_hermes_managed_node() -> str | None: 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 + # 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: @@ -695,31 +637,28 @@ def bootstrap_hermes_managed_node() -> str | None: 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. + 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. """ 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) + else: + result = _run_node_bootstrap("heal_managed_node", timeout=300) + if result is None: # in-use deferral: leave the attempt flag clear + return False _managed_node_heal_attempted = True - return _run_node_bootstrap("heal_managed_node", timeout=300) + return bool(result) 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. + """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. + 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. """ for candidate in _iter_managed_node_candidates(_candidate_node_command_names("node"), home): result = _run_version_probe([str(candidate), "--version"]) @@ -730,11 +669,9 @@ def _managed_node_tree_outdated(home: Path | None = None) -> bool: 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. + # 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. if "-" in version: return True @@ -743,9 +680,8 @@ 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 trees. + """Return a Hermes-managed Node/npm executable path, healing broken/outdated 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. """ @@ -762,9 +698,8 @@ 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 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. + ``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. """ if sys.platform != "win32": return shutil.which(command) @@ -773,10 +708,9 @@ def find_node_executable_on_path(command: str) -> str | None: 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 + directories = [d for d in os.environ.get("PATH", "").split(os.pathsep) if d] for name in _candidate_node_command_names(command_str): - for directory in os.environ.get("PATH", "").split(os.pathsep): - if not directory: - continue + for directory in directories: candidate = Path(directory) / name if candidate.is_file(): return str(candidate) @@ -786,9 +720,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. - 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. + 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. """ managed = find_hermes_node_executable(command) if managed: @@ -812,35 +746,26 @@ def with_hermes_node_path(env: dict[str, str] | None = None) -> dict[str, str]: def agent_browser_runnable(path: str | None) -> bool: - """Return True only when *path* is an agent-browser CLI that actually runs. + """True only when *path* is an agent-browser CLI that actually runs (``--version`` exits 0). - 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. + 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. """ 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) + return _is_executable_file(path) and _version_probe_ok(path) def _legacy_path_has_content(path: Path) -> bool: - """Return ``True`` iff ``path`` exists and has content worth honouring. + """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. + 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. """ try: st = path.lstat() @@ -848,9 +773,7 @@ def _legacy_path_has_content(path: Path) -> bool: 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. + except OSError: # e.g. PermissionError on a parent: assume occupied return True if not stat.S_ISDIR(st.st_mode): return True @@ -864,20 +787,15 @@ def _legacy_path_has_content(path: Path) -> bool: def display_hermes_home() -> str: - """Return a user-friendly display string for the current HERMES_HOME. + """User-friendly ``~/`` display string for HERMES_HOME (``~/.hermes/profiles/coder``). - Uses ``~/`` shorthand (``~/.hermes``, ``~/.hermes/profiles/coder``). Use this in user-facing - messages instead of hardcoding ``~/.hermes``; code needing a real ``Path`` should use + Use in user-facing messages instead of hardcoding ``~/.hermes``; for a real ``Path`` 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). + # as_posix(): on Windows str() renders backslashes, giving mixed + # chimeras like ``~/AppData\Local\hermes/skills/`` once callers append. return "~/" + home.relative_to(Path.home()).as_posix() except ValueError: return str(home) @@ -886,22 +804,17 @@ def display_hermes_home() -> str: 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. + Refuses ``/`` and any top-level directory (resolved parent with fewer than 3 parts) so a + misresolved ``HERMES_HOME`` can never brick the host. """ 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. + # 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). 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( @@ -970,9 +883,9 @@ 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. - ``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. + ``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. """ profile_home = _profile_home_path(env) seen: set[str] = set() @@ -995,10 +908,9 @@ _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. - * ``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. + ``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. """ env = env or {} profile_home = _profile_home_path(env) @@ -1037,10 +949,9 @@ 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. - 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. + 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". """ if effort is False: return {"enabled": False} @@ -1057,15 +968,11 @@ def parse_reasoning_effort(effort) -> dict | None: def _canonical_model_variants(model: str) -> list[str]: - """Generate bounded spelling variants for tolerant override matching. + """Bounded spelling variants for tolerant override matching, exact first, deduped in order. - 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 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. """ - # 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() @@ -1101,13 +1008,10 @@ 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. + """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. + Order: exact → dots↔dashes → bare model (provider stripped) → aggregator stripped → known + provider/aggregator prefixes prepended. First non-None parse_reasoning_effort result wins. """ if not overrides or not isinstance(overrides, dict) or not model: return None @@ -1122,8 +1026,8 @@ 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. - Single chokepoint for reasoning-effort resolution, shared by every surface (CLI startup, - messaging gateway, Desktop/TUI, cron, ``/model`` switch, fallback activation). Priority: + 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``. """ cfg = cfg if isinstance(cfg, dict) else {} agent_cfg = cfg.get("agent") @@ -1141,9 +1045,8 @@ 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; coercing with ``or ""`` turns a - # YAML boolean False into "", silently re-enabling thinking for users - # who explicitly disabled it. + # Global fallback — 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: @@ -1155,11 +1058,7 @@ def resolve_reasoning_config(cfg: dict | None, model: str = "") -> dict | None: 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. - """ + """True inside Termux (Android): ``TERMUX_VERSION`` or the Termux-specific ``PREFIX`` path.""" prefix = os.getenv("PREFIX", "") return bool(os.getenv("TERMUX_VERSION") or "com.termux/files/usr" in prefix) @@ -1168,11 +1067,7 @@ _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. - """ + """True inside WSL1/WSL2 (``microsoft`` marker in ``/proc/version``); cached per process.""" global _wsl_detected if _wsl_detected is not None: return _wsl_detected @@ -1205,11 +1100,11 @@ def wsl_unc_path_to_posix(path: str) -> str | None: def translate_cwd_for_wsl_backend(cwd: str) -> str: - r"""Normalize a cross-boundary cwd when Hermes itself runs inside WSL. + """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. + 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(): return cwd @@ -1224,13 +1119,8 @@ _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. + """True inside a container (Docker/Podman/LXC markers, Kubernetes env, cgroup/mountinfo + markers); cached per process. """ global _container_detected if _container_detected is None: @@ -1248,7 +1138,6 @@ def _proc_file_has_marker(path: str, markers: tuple[str, ...]) -> bool: 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") @@ -1256,9 +1145,7 @@ def _detect_container() -> bool: 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. + # cgroup v2: /proc/1/cgroup is just "0::/"; the runtime still shows in mountinfo. return _proc_file_has_marker("/proc/self/mountinfo", ("kubepods", "containerd", "crio")) @@ -1284,22 +1171,18 @@ def get_env_path() -> Path: def apply_ipv4_preference(force: bool = False) -> None: - """Monkey-patch ``socket.getaddrinfo`` to prefer IPv4 connections. + """Monkey-patch ``socket.getaddrinfo`` to prefer IPv4 connections when *force* is True. - 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. + 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. """ if not force: return import socket - # Guard against double-patching if getattr(socket.getaddrinfo, "_hermes_ipv4_patched", False): return @@ -1311,8 +1194,7 @@ def apply_ipv4_preference(force: bool = False) -> None: 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) + except socket.gaierror: # no A record — pure-IPv6 host return _original_getaddrinfo(host, port, family, type, proto, flags) return _original_getaddrinfo(host, port, family, type, proto, flags) @@ -1322,7 +1204,6 @@ def apply_ipv4_preference(force: bool = False) -> None: # ─── Streaming Response Constants ──────────────────────────────────────────── -# Response ID for partial stream stubs used during error recovery PARTIAL_STREAM_STUB_ID = "partial-stream-stub" FINISH_REASON_LENGTH = "length" @@ -1339,11 +1220,9 @@ AI_GATEWAY_BASE_URL = "https://ai-gateway.vercel.sh/v1" 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. + *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. """ if windows is None: windows = sys.platform == "win32" @@ -1353,9 +1232,8 @@ 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``, 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). + ``uv venv`` defaults to ``.venv`` while our installers create ``venv``; call sites that only + knew ``venv`` silently no-oped on ``.venv`` installs (#79542). """ for name in ("venv", ".venv"): candidate = Path(project_root) / name @@ -1366,17 +1244,16 @@ def project_venv_dir(project_root) -> Path | 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 + if windows is None: + windows = sys.platform == "win32" 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 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_MODULE_ROOTS = frozenset({ "agent", "acp_adapter", "cli", "cron", "gateway", "model_tools", "plugins", "providers", "tools", "toolsets", "run_agent", "tui_gateway", "utils", @@ -1386,23 +1263,20 @@ FIRST_PARTY_MODULE_ROOTS = frozenset({ 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``. + Exact match on the first dotted segment; ``startswith`` would also claim third-party + ``agents``, ``agentops``, ``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. + """Recovery guidance lines when *exc* looks like a half-updated tree, else ``[]``. - 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. + 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. """ - # A missing third-party dependency is a different problem (bad venv, missing - # extra) with different remediation, so don't claim a partial update. + # A missing third-party dep (bad venv, missing extra) is a different problem. if not isinstance(exc, ImportError) or isinstance(exc, ModuleNotFoundError): return [] if not is_first_party_module(getattr(exc, "name", None)):