"""Shared constants for Hermes Agent. Import-safe module with no dependencies — can be imported from anywhere without risk of circular imports. """ import os 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() if override is _UNSET or not override: return None return str(override) 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 _hermes_home_from_env() -> Path: """Resolve HERMES_HOME from the process environment only. Reads the ``HERMES_HOME`` env var, falling back to the platform-native default. Deliberately ignores the context-local override installed by :func:`set_hermes_home_override`, so this reflects the process/launch scope rather than a per-task profile. Shared by :func:`get_hermes_home` and :func:`get_process_hermes_home` so the two never drift. """ val = os.environ.get("HERMES_HOME", "").strip() if val: return Path(val) return _get_platform_default_hermes_home() 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. When ``HERMES_HOME`` is unset but an ``active_profile`` file indicates a non-default profile is active, logs a loud one-shot warning to ``errors.log`` so cross-profile data corruption is diagnosable instead of silent. Behavior is unchanged otherwise — we still return the platform-native default — because raising here would brick 30+ module-level callers that import this at load time. Subprocess spawners are expected to propagate ``HERMES_HOME`` explicitly (see the systemd template in ``hermes_cli/gateway.py`` and the kanban dispatcher in ``hermes_cli/kanban_db.py``). See https://github.com/NousResearch/hermes-agent/issues/18594. """ override = get_hermes_home_override() if override: return Path(override) if not os.environ.get("HERMES_HOME", "").strip(): _warn_profile_fallback_once() return _hermes_home_from_env() # Resolved keys, keyed by the path string that was handed in. Path.resolve() # is a filesystem call, and this function sits under every ToolRegistry # lookup through current_scope_key(), so without this the registry pays a # syscall per lookup. A process only ever sees a handful of home paths, so # the dict stays tiny. Only paths that really exist are stored, see below. _HOME_KEY_CACHE: dict[str, str] = {} 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. The resolved value is remembered per input path. A directory that does not exist yet is resolved without touching the cache, because the answer can change once it is created, for example when part of the path turns out to be a symlink. """ candidate = Path(path) if path is not None else get_hermes_home() raw = str(candidate) cached = _HOME_KEY_CACHE.get(raw) if cached is not None: return cached expanded = candidate.expanduser() try: resolved = expanded.resolve(strict=True) except OSError: # Not on disk yet. Fall back to the lenient resolve and do not store # it, so the real answer is picked up once the directory appears. return os.path.normcase(str(expanded.resolve(strict=False))) key = os.path.normcase(str(resolved)) _HOME_KEY_CACHE[raw] = key return key def reset_hermes_home_key_cache() -> None: """Forget every remembered home key. For tests that move a home directory around on disk under one path. Normal callers never need this: a different home path is a different cache key already. """ _HOME_KEY_CACHE.clear() 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`. It resolves only the process ``HERMES_HOME`` env var (falling back to the platform default), so it reflects the scope the process was launched under **as long as nothing mutates ``os.environ`` in-process**. 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``). Do NOT use it for genuinely profile-scoped data (memories, backups, checkpoints, provider config) — those should keep following the override. """ return _hermes_home_from_env() # 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 standard deployments this is the platform-native Hermes home (``~/.hermes`` on POSIX, ``%LOCALAPPDATA%\\hermes`` on native Windows). In Docker or custom deployments where ``HERMES_HOME`` points outside ``~/.hermes`` (e.g. ``/opt/data``), returns ``HERMES_HOME`` directly — that IS the root. 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. """ 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 if not env_home: result = native_home else: env_path = Path(env_home) try: env_path.resolve().relative_to(native_home.resolve()) # HERMES_HOME is under ~/.hermes (normal or profile mode) result = native_home except ValueError: # Docker / custom deployment. # Check if this is a profile path: /profiles/ # If the immediate parent dir is named "profiles", the root is # the grandparent — this covers Docker profiles correctly. if env_path.parent.name == "profiles": result = env_path.parent.parent else: # Not a profile path — HERMES_HOME itself is the root result = 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 ``/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(): return True if 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 ``/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. ``/tmp/foo/profiles/notahome/.hermes``) is not a named profile, and neither is an unrelated custom home like ``/srv/profiles/buildcache`` — those must keep mkdir-ing normally. ``.../profiles/worker/logs`` still resolves to ``.../profiles/worker``. """ 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. Default ``HERMES_HOME`` (not under ``profiles/``) is unchanged. """ 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 get_optional_skills_dir(default: Path | None = None) -> Path: """Return the optional-skills directory, honoring package-manager wrappers. Packaged installs may ship ``optional-skills`` outside the Python package tree and expose it via ``HERMES_OPTIONAL_SKILLS``. """ override = os.getenv("HERMES_OPTIONAL_SKILLS", "").strip() if override: return Path(override) if default is not None: return default return get_hermes_home() / "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` for the MCP catalog (Nous-approved Model Context Protocol servers shipped with the repo but disabled by default). Packaged installs may ship ``optional-mcps`` outside the Python package tree and expose it via ``HERMES_OPTIONAL_MCPS``. """ override = os.getenv("HERMES_OPTIONAL_MCPS", "").strip() if override: return Path(override) if default is not None: return default return get_hermes_home() / "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 """ override = os.getenv("HERMES_BUNDLED_SKILLS", "").strip() if override: return Path(override) if default is not None: return default return get_hermes_home() / "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``). Existing installs that already have the old path (e.g. ``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 ``/``. See #27602 for the pairing-store regression where a dormant empty ``pairing/`` orphaned approved-user data in ``platforms/pairing/``. Args: new_subpath: Preferred path relative to HERMES_HOME (e.g. ``"cache/images"``). old_name: Legacy path relative to HERMES_HOME (e.g. ``"image_cache"``). home: Optional explicit Hermes home. Profile-aware callers that manage more than one home in the same process use this instead of temporarily mutating the process or context-local HERMES_HOME. Returns: Absolute ``Path`` — legacy location if it exists with content, otherwise the new location. """ home = home or get_hermes_home() old_path = home / old_name if _legacy_path_has_content(old_path): return old_path return 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 from ``scripts/install.ps1`` unpack portable Node directly into ``%LOCALAPPDATA%\\hermes\\node``. POSIX installs use ``$HERMES_HOME/node/bin``. Include both shapes on every platform so mixed or migrated installs still work. """ root = home or get_hermes_home() dirs = [root / "node"] bin_dir = root / "node" / "bin" # 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). if sys.platform == "win32": return dirs + [bin_dir] return [bin_dir] + dirs def _candidate_node_command_names(command: str) -> list[str]: base = Path(command).name if sys.platform != "win32" or "." in base: return [base] if base.lower() == "npm": # 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 ["npm.cmd", "npm.exe", "npm"] if base.lower() == "npx": return ["npx.cmd", "npx.exe", "npx"] if base.lower() == "node": return ["node.exe", "node"] return [f"{base}.cmd", f"{base}.exe", base] _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_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. Hermes-managed Node trees live under ``$HERMES_HOME/node`` (or a profile's ``HERMES_HOME``). A partial upgrade or interrupted install can leave ``bin/npm`` behind while ``lib/cli.js`` is missing — the wrapper exists but immediately throws ``MODULE_NOT_FOUND``. ``find_hermes_node_executable`` used to trust file presence alone, so ``hermes update`` would pick that broken npm and fail the Node refresh / web UI build. Probe with ``--version`` (same pattern as :func:`agent_browser_runnable`) so broken managed wrappers are detected before use. """ if not path: return False candidate = Path(path) if sys.platform == "win32": if not candidate.is_file(): return False elif not os.path.exists(path) or not os.access(path, os.X_OK): return False import subprocess try: from hermes_cli._subprocess_compat import windows_hide_flags result = subprocess.run( [path, "--version"], capture_output=True, timeout=10, env=with_hermes_node_path(), creationflags=windows_hide_flags(), ) except (OSError, subprocess.TimeoutExpired, ValueError): return False return result.returncode == 0 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 = set() for command in ("node", "npm", "npx"): names.update(_candidate_node_command_names(command)) 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) ): return True return False 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). Each root is evaluated individually so disjoint roots both work. """ 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). Always ``False`` on POSIX, which has no equivalent lock semantics. The scan is a fast pre-check that avoids pointless re-downloads in long-lived processes; the rename-based swap in :func:`_heal_managed_node_windows` is the authoritative in-use guard. """ 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_path = str(Path(exe).resolve()) except (OSError, ValueError): exe_path = str(exe) if _path_under_any(exe_path, dirs): return True for arg in info.get("cmdline") or []: if _path_under_any(arg, dirs): 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 re import tempfile import time import urllib.request import uuid import zipfile arch = (os.environ.get("PROCESSOR_ARCHITEW6432") or os.environ.get("PROCESSOR_ARCHITECTURE", "")).lower() if arch in ("amd64", "x86_64"): node_arch = "x64" elif arch == "arm64": node_arch = "arm64" elif arch in ("x86",): node_arch = "x86" else: 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-*"): try: if stale.stat().st_mtime < cutoff: shutil.rmtree(stale, ignore_errors=True) except OSError: continue for stale in home.glob("node.new-*"): try: if stale.stat().st_mtime < cutoff: shutil.rmtree(stale, ignore_errors=True) except OSError: continue index_url = f"https://nodejs.org/dist/latest-v{_HERMES_NODE_TARGET_MAJOR}.x/" try: with urllib.request.urlopen(index_url, timeout=60) as response: index_html = response.read().decode("utf-8", errors="replace") except OSError: return False match = re.search( rf"node-v{_HERMES_NODE_TARGET_MAJOR}\.\d+\.\d+-win-{node_arch}\.zip", index_html, ) if not match: return False zip_name = match.group(0) download_url = f"{index_url}{zip_name}" try: with urllib.request.urlopen(download_url, timeout=300) as response: zip_bytes = response.read() except OSError: 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 if target.exists(): 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: # 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 # 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) else: try: os.replace(str(staged), str(target)) except OSError: shutil.rmtree(staged, ignore_errors=True) return False return node_tool_runnable(str(target / "node.exe")) def _bootstrap_managed_node_posix() -> bool: """Install a fresh managed Node under ``$HERMES_HOME/node`` on POSIX. Shells out to ``_nb_install_bundled_node`` in ``scripts/lib/node-bootstrap.sh`` (the same pinned-nodejs.org path ``install.sh`` uses), so the resulting tree matches what a normal install would have produced. Runs with ``HERMES_NODE_SKIP_LINKS=1`` so the user's own node/npm on PATH is not shadowed by ``~/.local/bin`` symlinks. """ if not _NODE_BOOTSTRAP_SCRIPT.is_file(): return False import subprocess try: result = subprocess.run( [ "bash", "-c", f'source "{_NODE_BOOTSTRAP_SCRIPT}" && _nb_install_bundled_node', ], env={ **os.environ, "HERMES_HOME": str(get_hermes_home()), # Private provisioning: do not symlink node/npm/npx into # ~/.local/bin — the user has their own toolchain on PATH and # this tree must not shadow it. "HERMES_NODE_SKIP_LINKS": "1", }, capture_output=True, timeout=600, 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. Returns the managed npm executable path on success, ``None`` on failure. No-ops (returning the existing npm) when a healthy managed tree is already present. """ existing = find_hermes_node_executable("npm") if existing: return existing if sys.platform == "win32": ok = _heal_managed_node_windows() else: ok = _bootstrap_managed_node_posix() if not ok: return None for directory in iter_hermes_node_dirs(): for name in _candidate_node_command_names("npm"): candidate = directory / name if candidate.is_file() and ( sys.platform == "win32" or os.access(candidate, os.X_OK) ): resolved = str(candidate) if node_tool_runnable(resolved): return resolved return 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 installs shell out to ``heal_managed_node`` in ``scripts/lib/node-bootstrap.sh``; Windows downloads the portable zip directly (same source as ``install.ps1``). A Windows deferral (the tree is in use by a running app) does NOT record the attempt, so a later call — or the next process — can heal once the tree is free (#80926). """ global _managed_node_heal_attempted if _managed_node_heal_attempted: return False if 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 if not _NODE_BOOTSTRAP_SCRIPT.is_file(): return False import subprocess try: result = subprocess.run( [ "bash", "-c", f'source "{_NODE_BOOTSTRAP_SCRIPT}" && heal_managed_node', ], env={**os.environ, "HERMES_HOME": str(get_hermes_home())}, capture_output=True, timeout=300, check=False, ) except (OSError, subprocess.SubprocessError): return False return result.returncode == 0 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 managed Node (e.g. a 22 tree from an older install) heals the same way a broken one does: :func:`find_hermes_node_executable` triggers the once-per-process heal, which redownloads ``latest-v{_HERMES_NODE_TARGET_MAJOR}.x`` — so existing users are upgraded on next launch, not just on the next installer re-run. Mirrors ``_nb_managed_node_outdated`` in ``scripts/lib/node-bootstrap.sh``. """ import subprocess for directory in iter_hermes_node_dirs(home): for name in _candidate_node_command_names("node"): candidate = directory / name if not candidate.is_file() or ( sys.platform != "win32" and not os.access(candidate, os.X_OK) ): continue try: from hermes_cli._subprocess_compat import windows_hide_flags result = subprocess.run( [str(candidate), "--version"], capture_output=True, timeout=10, creationflags=windows_hide_flags(), ) version = result.stdout.decode().strip().lstrip("v") major = int(version.split(".")[0]) except (OSError, subprocess.TimeoutExpired, ValueError, IndexError): return False # broken, not outdated — the runnable probe handles it # 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 (node major below ``_HERMES_NODE_TARGET_MAJOR``) heal the same way broken ones do — the once-per-process heal redownloads the target major, upgrading existing users on next launch rather than next reinstall. 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) def _first_runnable() -> tuple[str | None, bool]: broken = False for directory in iter_hermes_node_dirs(): for name in names: candidate = directory / name if candidate.is_file() and ( sys.platform == "win32" or os.access(candidate, os.X_OK) ): resolved = str(candidate) if node_tool_runnable(resolved): return resolved, broken broken = True return None, broken resolved, broken_present = _first_runnable() 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() 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) has_path_separator = any( sep and sep in command_str for sep in (os.sep, os.altsep, "/", "\\") ) if has_path_separator: 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. A bare presence check (``shutil.which`` / ``Path.exists``) is not enough: agent-browser's npm ``postinstall`` re-points a *global* install symlink (e.g. ``/opt/homebrew/bin/agent-browser``) at our local ``node_modules/agent-browser/bin/...`` binary, which then disappears on the next ``hermes update`` — leaving a **dangling symlink** that ``which`` still reports but exec fails on with exit 127 (issue #48521). Callers that trust such a path silently break every browser tool. 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 import subprocess try: from hermes_cli._subprocess_compat import windows_hide_flags result = subprocess.run( [path, "--version"], capture_output=True, timeout=10, env=with_hermes_node_path(), creationflags=windows_hide_flags(), ) except (OSError, subprocess.TimeoutExpired, ValueError): return False return result.returncode == 0 def _legacy_path_has_content(path: Path) -> bool: """Return ``True`` iff ``path`` exists and has content worth honouring. A populated *directory* (any entry inside) counts. A non-directory file at ``path`` also counts — the consumer presumably wrote it. An empty directory does **not** count, so a stale empty legacy stub falls through to the new layout. If the path cannot be inspected (``PermissionError`` on ``stat``/``iterdir``, or any other ``OSError`` short of "not found"), assume occupied so we don't accidentally orphan legacy data. Only a genuine ``FileNotFoundError`` counts as absent. Symlinks are resolved before judging content: a symlink pointing at a populated directory (or any existing non-directory target) counts, but a **dangling** symlink (broken target) does **not** — it must not be allowed to shadow populated new-layout data, matching the old ``exists()`` gate's behaviour for broken links. """ try: st = path.lstat() 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 stat.S_ISLNK(st.st_mode): # Resolve the link's target. A dangling symlink has no content and # must not shadow the new layout; a valid one is judged on its target. try: target_st = path.stat() # follows the link except FileNotFoundError: return False # dangling symlink → fall through to new layout except OSError: return True # can't resolve → assume occupied, don't orphan data if not stat.S_ISDIR(target_st.st_mode): return True # target is a directory — fall through to the iterdir() emptiness check elif not stat.S_ISDIR(st.st_mode): return True try: next(path.iterdir()) except StopIteration: return False except OSError: return True return True def display_hermes_home() -> str: """Return a user-friendly display string for the current HERMES_HOME. Uses ``~/`` shorthand for readability:: default: ``~/.hermes`` profile: ``~/.hermes/profiles/coder`` custom: ``/opt/hermes-custom`` Use this in **user-facing** print/log messages instead of hardcoding ``~/.hermes``. For code that needs a real ``Path``, use :func:`get_hermes_home` instead. """ 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. Also refuses to chmod the hermes-agent install tree (the directory this module lives in, and anything below it): restricting the install dir to 0700 locks the runtime user out of traversing it when it does not own the dir, as in the Docker image. A warning is logged when this happens. See https://github.com/NousResearch/hermes-agent/issues/25821 and https://github.com/NousResearch/hermes-agent/pull/93050. """ 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") if os.path.isdir(profile_home): return profile_home return 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 _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: list[str] = [] explicit = str(env.get("HERMES_REAL_HOME") or os.getenv("HERMES_REAL_HOME", "")).strip() if explicit: candidates.append(explicit) home = str(env.get("HOME") or os.getenv("HOME", "")).strip() if home: candidates.append(home) try: import pwd pw_home = pwd.getpwuid(os.getuid()).pw_dir.strip() # windows-footgun: ok — POSIX-only module inside try/except if pw_home: candidates.append(pw_home) except Exception: pass userprofile = str(env.get("USERPROFILE") or os.getenv("USERPROFILE", "")).strip() if userprofile: candidates.append(userprofile) drive = str(env.get("HOMEDRIVE") or os.getenv("HOMEDRIVE", "")).strip() path = str(env.get("HOMEPATH") or os.getenv("HOMEPATH", "")).strip() if drive and path: candidates.append(f"{drive}{path}" if path.startswith(("\\", "/")) else os.path.join(drive, path)) expanded = os.path.expanduser("~") if expanded and expanded != "~": candidates.append(expanded) return candidates 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`` is reserved for the OS/user account and the many external CLIs that store credentials under ``~``. If a parent process is already running with ``HOME={HERMES_HOME}/home``, this helper 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" def get_subprocess_home(env: dict[str, str] | None = None) -> str | None: """Return a subprocess ``HOME`` override, if one should be applied. Policy is controlled by ``terminal.home_mode`` (bridged to ``TERMINAL_HOME_MODE``): * ``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. * ``profile``: use ``{HERMES_HOME}/home`` when it exists, preserving the older strict per-profile tool-config isolation. """ env = env or {} profile_home = _profile_home_path(env) mode = str(env.get("TERMINAL_HOME_MODE") or os.getenv("TERMINAL_HOME_MODE", "auto")).strip().lower() or "auto" if mode in {"isolated", "profile_home", "profile-home"}: mode = "profile" if mode in {"host", "user", "real_home", "real-home"}: mode = "real" if mode == "profile": return profile_home real_home = get_real_home(env) current_home = str(env.get("HOME") or os.getenv("HOME", "")).strip() if mode == "real": return real_home if _norm_home_path(real_home) != _norm_home_path(current_home) else None if profile_home and is_container(): return profile_home if _is_profile_home(current_home, profile_home): return real_home if _norm_home_path(real_home) != _norm_home_path(current_home) else None 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. Valid levels: "none", "minimal", "low", "medium", "high", "xhigh", "max", "ultra". Returns None when the input is empty or unrecognized (caller uses default). Returns {"enabled": False} for "none" (aliases: "false", "disabled", and YAML boolean False — users write ``reasoning_effort: false``/``off``/``no`` in config.yaml and YAML hands us a bool, which must mean disabled, not "fall back to the default and keep thinking"). Returns {"enabled": True, "effort": } for valid effort levels. """ if effort is False: return {"enabled": False} if effort is None or effort is True: return None effort = str(effort) if not effort.strip(): return None effort = effort.strip().lower() 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. Model names mix two types of separators: - **Word separators**: dashes between words (``claude-opus``) - **Version separators**: dots or dashes between version digits (``4.5``, ``4-5``) The tricky case is that ``.`` appears in BOTH roles (word sep in some spellings, version sep in others), so a blanket ``.replace('.', '-')`` is lossy — it collapses version dots into dashes and no later step recovers the canonical form (``claude-opus-4.5``). 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. Steps: 1. Exact input 2. Dots/dashes cross-substitution on the entire string 3. Version-dot recovery applied to ALL derivatives 4. Strip provider/aggregator prefix → bare model variants 5. Apply version-dot recovery to bare derivatives 6. Prepend known provider/aggregator prefixes Duplicates removed in insertion order (exact always wins). """ import re # 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() variants = [] def _add(v): 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.""" _add(s) all_dashed = s.replace('.', '-') _add(all_dashed) all_dotted = s.replace('-', '.') _add(all_dotted) # Version-dot recovery on each base form _add(_dash_to_dot(s)) _add(_dot_to_dash(s)) _add(_dash_to_dot(all_dashed)) _add(_dot_to_dash(all_dotted)) # 1-3. Base variants for the full string _add_with_derivatives(model) # Split by / to handle provider prefix parts = model.split('/') # 4. Bare model variants (strip provider/aggregator prefix) if len(parts) >= 2: bare = parts[-1] _add_with_derivatives(bare) # Strip aggregator only (3+ parts) # e.g. "openrouter/anthropic/claude-opus-4.5" → "anthropic/claude-opus-4.5" if len(parts) >= 3: _add_with_derivatives('/'.join(parts[1:])) # 5. Prepend known provider prefixes to bare variants known_providers = ( 'anthropic', 'openai', 'google', 'openrouter', 'groq', 'mistral', 'xai', 'cohere', 'perplexity', 'together', 'fireworks', 'deepseek', ) bare_variants = [v for v in variants if '/' not in v] for v in bare_variants: for provider in known_providers: _add(f"{provider}/{v}") # Prepend aggregator to single-slash variants single_slash_variants = [v for v in variants if v.count('/') == 1] known_aggregators = ('openrouter', 'opencode', 'fireworks', 'groq', 'together') for v in single_slash_variants: for agg in known_aggregators: _add(f"{agg}/{v}") 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. Args: model: The model string (any spelling — exact, normalized, bare, with provider prefix, etc.) overrides: The dict of per-model overrides from agent.reasoning_overrides in config.yaml. Keys can be any sensible spelling of the model name. Returns: The parsed reasoning_config dict if a match is found, None otherwise (caller should fall back to global reasoning_effort). 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: 1. Per-model override from ``agent.reasoning_overrides`` (spelling-tolerant — see :func:`resolve_per_model_reasoning_effort`) 2. Global ``agent.reasoning_effort`` — the raw value is passed through so a YAML boolean ``False`` (``reasoning_effort: false``/``off``/ ``no``) means "thinking disabled", never silently re-enabled. Session-scoped overrides (gateway ``/reasoning --session``) are resolved by the caller BEFORE this function — they always win. Args: cfg: A loaded config dict (any of the three loaders' shapes — only the ``agent`` and ``model`` sections are read). model: The effective model for this surface/session. When empty, it is derived from the config's ``model`` section (string form, or a dict's ``default``/``model`` keys). Returns: The parsed reasoning config dict, or None when unset/unrecognized (caller uses the provider default). """ 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, str): model = model_cfg.strip() elif isinstance(model_cfg, dict): model = str( model_cfg.get("default") or model_cfg.get("model") or "" ).strip() else: model = "" 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//...`` form.""" import re match = re.match(r"^([A-Za-z]):[\\/](.*)$", str(path or "").strip()) if not match: return None drive = match.group(1).lower() tail = 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\\\\...`` or the legacy ``\\\\wsl$\\...``) to a POSIX path inside the distro.""" import re 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: """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. Recognizes Docker (``/.dockerenv``), Podman (``/run/.containerenv``), and — via ``/proc/1/cgroup`` — the docker/podman/lxc cgroup-v1 markers. cgroup v2 collapses ``/proc/1/cgroup`` to a single ``0::/`` line with no runtime marker, so containerd/CRI-O runtimes (the common case on Kubernetes/k3s) were previously missed. 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. See: NousResearch/hermes-agent#47111 """ global _container_detected if _container_detected is not None: return _container_detected if os.path.exists("/.dockerenv"): _container_detected = True return True if os.path.exists("/run/.containerenv"): _container_detected = True return True # Kubernetes always injects this into pod containers; absent on hosts. if os.environ.get("KUBERNETES_SERVICE_HOST"): _container_detected = True return True _CGROUP_MARKERS = ("docker", "podman", "/lxc/", "kubepods", "containerd", "crio") try: with open("/proc/1/cgroup", "r", encoding="utf-8") as f: cgroup = f.read() if any(marker in cgroup for marker in _CGROUP_MARKERS): _container_detected = True return True except OSError: pass # 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. try: with open("/proc/self/mountinfo", "r", encoding="utf-8") as f: mountinfo = f.read() if any(marker in mountinfo for marker in ("kubepods", "containerd", "crio")): _container_detected = True return True except OSError: pass _container_detected = False return False # ─── Well-Known Paths ───────────────────────────────────────────────────────── def get_config_path() -> Path: """Return the path to ``config.yaml`` under HERMES_HOME. Replaces the ``get_hermes_home() / "config.yaml"`` pattern repeated in 7+ files (skill_utils.py, hermes_logging.py, hermes_time.py, etc.). """ 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. Safe to call multiple times — only patches once. Set ``network.force_ipv4: true`` in ``config.yaml`` to enable. """ 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 for venv layout. This was open-coded in seven places across four ``hermes_cli`` modules using three different Windows predicates (``platform.system()``, ``is_windows()``, ``_is_windows()``); each new call site had to re-derive it, and #76091 shipped an eighth copy because the correct behaviour lived 2400 lines away in another function. A few sites outside ``hermes_cli`` (``tools/code_execution_tool.py``, ``agent/lsp/install.py``, ``agent/lsp/servers.py``) still hand-roll it — convert them as they are touched. *windows* lets a caller pass its own platform verdict. Several callers resolve this through predicates the test-suite patches to exercise Windows paths on Linux CI (``hermes_cli.main._is_windows`` and friends); reading ``sys.platform`` unconditionally here would silently drop those paths out of coverage. Defaults to the host platform. The path is returned unconditionally — callers legitimately 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). ``venv`` wins when both exist, matching what the installers write. """ 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).""" 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_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 on 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 "" if not root: return False return 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. An interrupted or partially-applied update can leave the checkout with new files in one package and stale files in another. Every file still parses, so nothing is corrupt in the usual sense — but a module that imports a name added in the same release from a sibling that wasn't refreshed dies with ``ImportError: cannot import name 'X' from 'y'`` on every startup. 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. Returns an empty list for unrelated exceptions, so callers can splat it unconditionally. """ if not isinstance(exc, ImportError): return [] # A missing third-party dependency is a different problem (bad venv, missing # extra) with different remediation, so don't claim a partial update. if isinstance(exc, ModuleNotFoundError): return [] name = getattr(exc, "name", None) if not is_first_party_module(name): 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. Returns True when guidance was written (caller should then exit), False when *exc* is not a first-party ``ImportError`` (caller should re-raise). """ lines = partial_update_hint(exc) if not lines: return False out = sys.stderr if file is None else file print(f"Error: {exc}", file=out) for line in lines: print(line, file=out) return True