Files
hermes-agent/tools/environments/local.py

1118 lines
43 KiB
Python

"""Local execution environment — spawn-per-call with session snapshot."""
import logging
import ntpath
import os
import platform
import re
import shutil
import signal
import subprocess
import sys
import tempfile
import threading
import time
from collections.abc import Mapping
from pathlib import Path
from hermes_constants import get_process_hermes_home
from tools.environments.base import BaseEnvironment, _pipe_stdin
from hermes_cli._subprocess_compat import windows_hide_flags
# Re-exported so ``from tools.environments.local import X`` and
# ``patch("tools.environments.local.X")`` keep working after the split.
from tools.environments.local_env_policy import ( # noqa: F401
_ACTIVE_VENV_MARKER_VARS, _ALWAYS_STRIP_KEYS, _AWS_SDK_CREDENTIAL_ENV_VARS,
_HERMES_PROVIDER_ENV_BLOCKLIST, _HERMES_PROVIDER_ENV_FORCE_PREFIX,
_TERMINAL_FIRST_PARTY_ENV_PREFIXES, _build_provider_env_blocklist,
_buzz_terminal_context_active, _is_hermes_internal_secret,
_is_terminal_first_party_env, _matches_terminal_first_party_prefix,
_plugin_terminal_env_strip_keys,
)
from tools.environments.local_gitbash_probe import ( # noqa: F401
_BASH_EXTERNAL_PROGRAM_PROBE, _bash_probe_details_cache, _bash_starts,
_bash_starts_cache, _git_bash_aslr_help, _git_root_from_bash,
_looks_like_msys_spawn_failure, _mandatory_aslr_enabled,
)
from tools.environments.local_pythonpath import ( # noqa: F401
_build_hermes_repo_root_aliases, _get_hermes_site_packages, _same_path,
_strip_hermes_owned_pythonpath, _strip_hermes_owned_pythonpath_and_runtime_markers,
_validated_runtime_venv,
)
_IS_WINDOWS = platform.system() == "Windows"
logger = logging.getLogger(__name__)
# --- Terminal temp-cache pruning ---------------------------------------------
# get_temp_dir() defaults to HERMES_HOME/cache/terminal (real storage, not tmpfs
# /tmp), so stale artifacts no longer vanish on reboot for free: the gateway
# housekeeping loop prunes hourly and a once-per-process sweep covers CLI-only
# installs.
TERMINAL_TEMP_MAX_AGE_HOURS = 72
_terminal_temp_prune_lock = threading.Lock()
_terminal_temp_pruned_once = False
# Background-process artifacts come in triplets (hermes_bg_<id>.log/.pid/.exit).
# A live server's .pid never changes mtime while its .log does, so age is judged
# per GROUP (newest mtime sharing a stem) to avoid yanking pid/exit files from
# under a still-running background session.
_BG_GROUP_RE = re.compile(r"^(hermes_bg_[A-Za-z0-9_-]+)\.(log|pid|exit)$")
def _default_terminal_temp_dir() -> "Path | None":
"""Return HERMES_HOME/cache/terminal, or None if unresolvable."""
try:
from hermes_constants import get_hermes_home
return get_hermes_home() / "cache" / "terminal"
except Exception:
return None
def cleanup_terminal_temp_cache(
max_age_hours: int = TERMINAL_TEMP_MAX_AGE_HOURS,
) -> int:
"""Delete session temp artifacts older than *max_age_hours*; return count
(``cleanup_*_cache`` contract). Only the managed default dir is pruned —
never a user-pointed ``terminal.temp_dir`` we don't own."""
root = _default_terminal_temp_dir()
if root is None:
return 0
cutoff = time.time() - (max_age_hours * 3600)
try:
entries = list(root.iterdir())
except OSError:
return 0
mtimes: dict[Path, float] = {}
group_newest: dict[str, float] = {}
for f in entries:
try:
mtimes[f] = mt = f.stat().st_mtime
except OSError:
continue
m = _BG_GROUP_RE.match(f.name)
if m:
group_newest[m.group(1)] = max(group_newest.get(m.group(1), 0.0), mt)
removed = 0
for f, mt in mtimes.items():
m = _BG_GROUP_RE.match(f.name)
if (group_newest[m.group(1)] if m else mt) >= cutoff:
continue
try:
if f.is_dir():
shutil.rmtree(f, ignore_errors=True)
else:
f.unlink()
removed += 1
except OSError:
continue
return removed
def _prune_terminal_temp_once() -> None:
"""Best-effort prune, at most once per process (CLI-only installs)."""
global _terminal_temp_pruned_once
with _terminal_temp_prune_lock:
if _terminal_temp_pruned_once:
return
_terminal_temp_pruned_once = True
try:
cleanup_terminal_temp_cache()
except Exception as exc:
logger.debug("Terminal temp prune failed: %s", exc)
# --- Windows / MSYS path translation ---
def _msys_to_windows_path(cwd: str) -> str:
"""Translate a Git Bash / MSYS path (``/c/Users/x``, ``/cygdrive/c/..``,
``/mnt/c/..``) to native ``C:\\Users\\x`` so ``isdir``/``Popen(cwd=)`` find
it. No-op off Windows, for empty input and for multi-segment POSIX paths
like ``/home/x``; idempotent on native paths."""
if not _IS_WINDOWS or not cwd:
return cwd
m = re.match(r'^/(?:(?:cygdrive|mnt)/)?([a-zA-Z])(/.*)?$', cwd)
if not m:
return cwd
drive = m.group(1).upper()
tail = (m.group(2) or "").replace('/', '\\')
return f"{drive}:{tail or chr(92)}" # chr(92) = backslash, avoid raw-string escape
def _resolve_local_initial_cwd(cwd: str) -> str:
"""Resolve the initial cwd to an absolute host path. A relative
``TERMINAL_CWD`` like ``hermes-agent`` naming the launch directory would
otherwise make the wrapper ``cd hermes-agent`` *inside* the project; anchor
it once so ``Popen(cwd=)`` and the in-shell ``cd`` agree."""
expanded = os.path.expanduser(cwd) if cwd else os.getcwd()
if _IS_WINDOWS:
expanded = _msys_to_windows_path(expanded)
# ntpath explicitly: with _IS_WINDOWS patched on a POSIX host,
# os.path.isabs would reject ``C:\Users\x`` and mangle it below.
if ntpath.isabs(expanded):
return expanded
if os.path.isabs(expanded):
return expanded
candidate = os.path.abspath(expanded)
current = os.getcwd()
# Relative name matching the tail of the current dir: use the current dir.
if not os.path.isdir(candidate):
wanted, have = Path(expanded).parts, Path(current).parts
if wanted and len(wanted) <= len(have) and have[-len(wanted):] == wanted:
return current
return candidate
def _windows_to_msys_path(cwd: str) -> str:
"""Translate native ``C:\\Users\\x`` to Git Bash form ``/c/Users/x`` so
``builtin cd`` resolves it. No-op off Windows / for non-drive paths."""
if not _IS_WINDOWS or not cwd:
return cwd
m = re.match(r'^([a-zA-Z]):[\\/]*(.*)$', cwd)
if not m:
return cwd
drive = m.group(1).lower()
tail = (m.group(2) or "").replace('\\', '/').lstrip('/')
return f"/{drive}/{tail}" if tail else f"/{drive}/"
def _bash_safe_path(path: str) -> str:
"""Return *path* in a form safe to embed in a Git Bash script: native
``C:\\Users\\x`` / ``C:/Users/x`` become ``/c/Users/x`` (MSYS argument
conversion mangles ``C:/`` forms), and leftover backslashes are normalized
so bash does not eat ``\\U``. No-op off Windows and for empty input."""
if not _IS_WINDOWS or not path:
return path
return _windows_to_msys_path(path).replace("\\", "/")
def _quote_bash_path(path: str) -> str:
"""Quote *path* for safe interpolation into a Git Bash script on Windows."""
import shlex
return shlex.quote(_bash_safe_path(path))
def _cwd_usable(path: str) -> bool:
"""True when *path* is a directory this process can actually chdir into.
``isdir`` alone is not enough: stat() on ``/root`` succeeds for a non-root
user but ``Popen(cwd='/root')`` dies with PermissionError (a root-launched
CLI leaking ``/root`` into a non-root gateway's shared state)."""
return os.path.isdir(path) and os.access(path, os.X_OK)
def _resolve_safe_cwd(cwd: str) -> str:
"""Return ``cwd`` if enterable, else the nearest usable ancestor, else
``tempfile.gettempdir()``. MSYS paths are normalized first on Windows so a
valid ``pwd -P`` result is not rejected as missing. Lets ``_run_bash``
recover from a deleted/inaccessible cwd instead of ``Popen`` raising before
bash starts and wedging every subsequent terminal call."""
cwd = _msys_to_windows_path(cwd) if _IS_WINDOWS else cwd
if cwd and _cwd_usable(cwd):
return cwd
if cwd and os.path.isdir(cwd):
logger.warning(
"Configured terminal cwd %r exists but is not accessible to "
"this user (uid=%s) — falling back to the nearest usable "
"directory. If this is a gateway/cron process, check for "
"root-owned paths leaking into terminal.cwd / TERMINAL_CWD "
"(#65583).",
cwd, getattr(os, "getuid", lambda: "?")(),
)
parent = os.path.dirname(cwd) if cwd else ""
while parent:
if _cwd_usable(parent):
return parent
next_parent = os.path.dirname(parent)
if next_parent == parent:
break # filesystem root itself is unusable
parent = next_parent
return tempfile.gettempdir()
# --- Child-process environment construction ---
def _inject_context_hermes_home(env: dict) -> None:
"""Bridge the context-local Hermes home override into subprocess env."""
try:
from hermes_constants import get_hermes_home_override
value = get_hermes_home_override()
if value:
env["HERMES_HOME"] = value
except Exception:
pass
def _apply_profile_home(env: dict) -> None:
"""HERMES_HOME override bridge + the subprocess HOME contract."""
_inject_context_hermes_home(env)
from hermes_constants import apply_subprocess_home_env
apply_subprocess_home_env(env)
def _inject_session_context_env(env: dict) -> None:
"""Bridge gateway session ContextVars (HERMES_SESSION_*) into a child env.
Cross-session leak guard: the vars also have a last-writer-wins ``os.environ``
mirror that, under a concurrent multi-session host, may belong to another
turn. Once the session-context system is engaged, ContextVars are
authoritative: a bound value (incl. "") wins and an _UNSET var is STRIPPED
rather than inherited. A CLI that never engaged it keeps the inherited value.
"""
try:
from gateway.session_context import _UNSET, _VAR_MAP, session_context_engaged
except Exception:
return
_engaged = session_context_engaged()
for var_name, var in _VAR_MAP.items():
value = var.get()
if value is not _UNSET:
env[var_name] = "" if value is None else str(value)
elif _engaged:
env.pop(var_name, None)
def _scrub_delegated_child_kanban_env(env: dict[str, str]) -> dict[str, str]:
"""Strip dispatcher-owned Kanban env from delegate_task child subprocesses."""
try:
from agent.delegation_context import is_delegated_child_process_context, scrub_kanban_env
if is_delegated_child_process_context():
return scrub_kanban_env(env)
except Exception:
pass
return env
def _passthrough_hooks():
"""Return ``(is_passthrough, resolve_passthrough_value)`` from the
env_passthrough skill registry, or inert fallbacks."""
try:
from tools.env_passthrough import is_env_passthrough, resolve_passthrough_value
return is_env_passthrough, resolve_passthrough_value
except Exception:
return (lambda _: False), (lambda _name, fallback: fallback)
def _filter_secret_env(
items: Mapping[str, str],
out: dict,
*,
unwrap_force: bool,
plugin_strip: frozenset = frozenset(),
) -> None:
"""Copy *items* into *out*, dropping Hermes-managed secrets.
``_HERMES_FORCE_<NAME>`` unwraps to ``NAME`` when ``unwrap_force`` (caller
extras / terminal env), else is dropped. Blocklisted names survive only via
env_passthrough registration or as context-entitled first-party ``BUZZ_*``
vars; the latter are used directly, never scope-resolved (UnscopedSecretError
under multiplex) — only passthrough names resolve through the secret scope.
"""
is_passthrough, resolve_passthrough_value = _passthrough_hooks()
for key, value in items.items():
if key.startswith(_HERMES_PROVIDER_ENV_FORCE_PREFIX):
if not unwrap_force:
continue
real_key = key[len(_HERMES_PROVIDER_ENV_FORCE_PREFIX):]
if _is_hermes_internal_secret(real_key):
continue
out[real_key] = value
continue
if _is_hermes_internal_secret(key) or key in plugin_strip:
continue
first_party = _is_terminal_first_party_env(key)
passthrough = is_passthrough(key)
if key in _HERMES_PROVIDER_ENV_BLOCKLIST and not (passthrough or first_party):
continue
resolved = value
if passthrough and not first_party:
resolved = resolve_passthrough_value(key, value)
if resolved is not None:
out[key] = resolved
def _finalize_child_env(env: dict) -> dict:
"""Guards shared by every spawn surface: profile-home propagation,
session-context bridging, Hermes-owned PYTHONPATH + venv-marker strip, MSYS
defaults, delegate_task Kanban scrub. Returns the (possibly new) dict."""
_apply_profile_home(env)
_inject_session_context_env(env)
_strip_hermes_owned_pythonpath_and_runtime_markers(env)
_apply_windows_msys_bash_env_defaults(env)
return _scrub_delegated_child_kanban_env(env)
def _sanitize_subprocess_env(base_env: dict | None, extra_env: dict | None = None) -> dict:
"""Filter Hermes-managed secrets from a subprocess environment.
Background/PTY spawn path (``process_registry.spawn_local``), search workers,
the computer-use driver, and user-script runners build their env here.
"""
plugin_strip = _plugin_terminal_env_strip_keys()
sanitized: dict[str, str] = {}
_filter_secret_env(base_env or {}, sanitized, unwrap_force=False, plugin_strip=plugin_strip)
_filter_secret_env(extra_env or {}, sanitized, unwrap_force=True, plugin_strip=plugin_strip)
# Keep bare ``hermes`` resolvable for children even when the gateway was
# launched by a service manager or cron without the console-script dir on
# PATH (cron scripts use this sanitizer directly).
path_key = _path_env_key(sanitized)
if path_key is not None:
sanitized[path_key] = _prepend_hermes_bin_dir(sanitized.get(path_key, ""))
return _finalize_child_env(sanitized)
def hermes_subprocess_env(*, inherit_credentials: bool = False) -> dict[str, str]:
"""Sanitized env for the **non-terminal** spawn surface (browser, ACP/CLI
executors, computer-use driver, TUI Node host). Tier 1 (``_ALWAYS_STRIP_KEYS``,
plugin keys, force-prefixed hints, dynamic internal secrets) is always
removed; Tier 2 (the provider/tool blocklist) unless ``inherit_credentials``
— pass that **only** for children that legitimately need LLM credentials
(user-blessed claude/codex/gemini CLI, TUI Node host); it is grep-able for
audit. Terminal/execute_code use the skill-aware ``_sanitize_subprocess_env``.
"""
env = os.environ.copy()
for key in _ALWAYS_STRIP_KEYS:
env.pop(key, None)
for key in _plugin_terminal_env_strip_keys():
env.pop(key, None)
for key in list(env):
if key.startswith(_HERMES_PROVIDER_ENV_FORCE_PREFIX) or _is_hermes_internal_secret(key):
env.pop(key, None)
if not inherit_credentials:
for key in _HERMES_PROVIDER_ENV_BLOCKLIST:
env.pop(key, None)
# Windows UTF-8 safety for spawned processes.
env.setdefault("PYTHONUTF8", "1")
return _finalize_child_env(env)
def build_subprocess_env(
base: "Mapping[str, str] | None" = None,
*,
inherit_profile_home: bool = True,
scrub_secrets: bool = True,
extra: "Mapping[str, str] | None" = None,
) -> dict[str, str]:
"""Single factory for child-process environments, so profile-home
propagation and the secret-scrub policy have one owner.
``base=None`` snapshots ``os.environ``. ``scrub_secrets=True`` delegates to
:func:`_sanitize_subprocess_env` (``extra`` -> ``extra_env``; profile home is
inherent, ``inherit_profile_home`` ignored). ``scrub_secrets=False`` keeps the
base byte-for-byte (git credential flows, ``bws``/``op``); then
``inherit_profile_home`` bridges HERMES_HOME + HOME and ``extra`` is applied
last so caller overrides win.
"""
if scrub_secrets:
return _sanitize_subprocess_env(
dict(base) if base is not None else os.environ.copy(),
dict(extra) if extra else None,
)
env: dict[str, str] = dict(base) if base is not None else os.environ.copy()
if inherit_profile_home:
_apply_profile_home(env)
if extra:
env.update(extra)
return env
# --- Shell discovery ---
def _windows_bash_candidates(custom: "str | None") -> list[str]:
"""Ordered bash.exe candidates on Windows: HERMES_GIT_BASH_PATH, our
portable Git under %LOCALAPPDATA%\\hermes\\git (PortableGit ``bin`` and
MinGit ``usr\\bin`` layouts), known Git-for-Windows dirs, then PATH last —
``shutil.which`` may return WSL's bash, which fails silently on Windows paths."""
candidates: list[str] = []
def add(candidate: str) -> None:
if candidate and os.path.isfile(candidate) and candidate not in candidates:
candidates.append(candidate)
add(custom or "")
local_appdata = os.environ.get("LOCALAPPDATA", "")
if local_appdata:
portable = os.path.join(local_appdata, "hermes", "git")
add(os.path.join(portable, "bin", "bash.exe"))
add(os.path.join(portable, "usr", "bin", "bash.exe"))
add(os.path.join(os.environ.get("ProgramFiles", r"C:\Program Files"), "Git", "bin", "bash.exe"))
add(os.path.join(os.environ.get("ProgramFiles(x86)", r"C:\Program Files (x86)"), "Git", "bin", "bash.exe"))
if local_appdata:
add(os.path.join(local_appdata, "Programs", "Git", "bin", "bash.exe"))
found = shutil.which("bash")
if found and found not in candidates:
candidates.append(found)
return candidates
def _find_bash() -> str:
"""Find bash for command execution."""
if not _IS_WINDOWS:
return (
shutil.which("bash")
or ("/usr/bin/bash" if os.path.isfile("/usr/bin/bash") else None)
or ("/bin/bash" if os.path.isfile("/bin/bash") else None)
or os.environ.get("SHELL")
or "/bin/sh"
)
custom = os.environ.get("HERMES_GIT_BASH_PATH")
candidates = _windows_bash_candidates(custom)
# First candidate that can actually start wins: a stale HERMES_GIT_BASH_PATH
# pointing at a broken install must not beat a healthy portable Git.
for candidate in candidates:
if _bash_starts(candidate):
if candidate != custom and custom and os.path.isfile(custom):
logger.warning(
"HERMES_GIT_BASH_PATH=%s fails to start; using %s instead", custom, candidate,
)
return candidate
if candidates:
probe_details = "\n".join(
detail for c in candidates if (detail := _bash_probe_details_cache.get(c))
)
if _mandatory_aslr_enabled() is True or _looks_like_msys_spawn_failure(probe_details):
raise RuntimeError(_git_bash_aslr_help(candidates[0], probe_details))
# Unknown failure class: return the first path so the caller sees the
# real bash error instead of a less useful "not found".
return candidates[0]
raise RuntimeError(
"Git Bash not found. Hermes Agent requires Git for Windows on Windows.\n"
"Install it from: https://git-scm.com/download/win\n"
"Or set HERMES_GIT_BASH_PATH to your bash.exe location."
)
_git_bash_bin_dirs_cache: "list[str] | None" = None
def _git_bash_bin_dirs() -> list[str]:
"""Git Bash's coreutils dirs in ``/etc/profile`` order (mingw first so
coreutils beat System32 lookalikes); ``[]`` off Windows. A non-login
``bash -c`` (fallback when ``bash -l`` is broken) never sources
``/etc/profile``, so without these ``cat``/``mktemp``/``mv`` are missing:
``write_file`` fails with an empty error and commands exit 127."""
global _git_bash_bin_dirs_cache
if _git_bash_bin_dirs_cache is None:
_git_bash_bin_dirs_cache = _compute_git_bash_bin_dirs() if _IS_WINDOWS else []
return _git_bash_bin_dirs_cache
def _compute_git_bash_bin_dirs() -> list[str]:
try:
bash = _find_bash()
except Exception:
return []
bin_dir = os.path.dirname(bash) # <root>\bin or <root>\usr\bin (MinGit)
parent = os.path.dirname(bin_dir)
root = os.path.dirname(parent) if os.path.basename(parent).lower() == "usr" else parent
dirs: list[str] = []
for sub in (("mingw64", "bin"), ("mingw32", "bin"), ("usr", "local", "bin"), ("usr", "bin"), ("bin",)):
candidate = os.path.join(root, *sub)
if os.path.isdir(candidate) and candidate not in dirs:
dirs.append(candidate)
return dirs
def _prepend_missing_path_entries(existing_path: str, dirs: list[str]) -> str:
"""Prepend *dirs* missing from *existing_path* (``os.pathsep``); an
already-listed dir keeps its position and the input is returned unchanged
when nothing is missing."""
if not dirs:
return existing_path
sep = os.pathsep
entries = [e for e in existing_path.split(sep) if e] if existing_path else []
missing = [d for d in dirs if d not in entries]
if not missing:
return existing_path
return sep.join([*missing, *entries])
def _prepend_git_bash_dirs(existing_path: str) -> str:
"""Prepend Git Bash's binary dirs if missing (no-op off Windows), so the
non-login ``bash -c`` fallback can find coreutils when no login snapshot
re-exports the full PATH inside the shell."""
return _prepend_missing_path_entries(existing_path, _git_bash_bin_dirs())
# POSIX-sh-family shells that understand spawn_local's ``[shell, "-lic",
# "set +m; …"]`` invocation. fish, csh/tcsh, nushell, elvish, xonsh would error
# on that syntax, so _find_shell falls back to bash for them.
_SPAWN_COMPATIBLE_SHELLS = frozenset({"bash", "zsh", "sh", "dash", "ksh", "mksh"})
def _find_shell() -> str:
"""User's login shell for background spawning: ``$SHELL`` on POSIX when it
is an executable sh-family shell, else ``_find_bash``. macOS's system bash
3.2 under ``-l`` with stdin ``/dev/null`` sources ``~/.bash_profile``, which
often ``exec /bin/zsh -l`` and drops ``-c`` — the command silently never
runs. Non-allowlisted shells would trade that for a parse error."""
if not _IS_WINDOWS:
user_shell = os.environ.get("SHELL")
if (
user_shell
and os.path.isfile(user_shell)
and os.access(user_shell, os.X_OK)
and Path(user_shell).name in _SPAWN_COMPATIBLE_SHELLS
):
return user_shell
return _find_bash()
# --- PATH completion for the terminal subshell ---
# Standard PATH entries for environments with minimal PATH.
_SANE_PATH = (
"/opt/homebrew/bin:/opt/homebrew/sbin:"
"/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"
)
# Cached directory containing the ``hermes`` console-script.
# ``_SENTINEL`` distinguishes "not resolved yet" from a resolved ``None``.
_SENTINEL = object()
_HERMES_BIN_DIR: "str | None | object" = _SENTINEL
def _resolve_hermes_bin_dir() -> str | None:
"""Directory holding the ``hermes`` console-script, or None (cached).
Launched by systemd/cron/a desktop launcher, the gateway's PATH lacks the
install dir (``~/.local/bin``, venv ``bin``, pipx, nix) and bare ``hermes``
exits 127. Order: ``which``; absolute ``sys.argv[0]`` naming a real hermes
executable; ``sys.executable``'s dir if it holds the shim."""
global _HERMES_BIN_DIR
if _HERMES_BIN_DIR is not _SENTINEL:
return _HERMES_BIN_DIR # type: ignore[return-value]
candidate: str | None = None
which = shutil.which("hermes")
if which:
candidate = os.path.dirname(which)
if candidate is None:
argv0 = sys.argv[0] if sys.argv else ""
base = os.path.basename(argv0).lower()
if (
os.path.isabs(argv0)
and (base == "hermes" or base.startswith("hermes."))
and os.path.isfile(argv0)
):
candidate = os.path.dirname(argv0)
if candidate is None:
exe_dir = os.path.dirname(sys.executable) if sys.executable else ""
shim = "hermes.exe" if _IS_WINDOWS else "hermes"
if exe_dir and os.path.isfile(os.path.join(exe_dir, shim)):
candidate = exe_dir
if candidate and not os.path.isdir(candidate):
candidate = None
_HERMES_BIN_DIR = candidate
return candidate
def _prepend_hermes_bin_dir(existing_path: str) -> str:
"""Prepend the hermes install dir to ``existing_path`` if it's missing
(unchanged when already present or unresolvable)."""
bin_dir = _resolve_hermes_bin_dir()
return _prepend_missing_path_entries(existing_path, [bin_dir] if bin_dir else [])
def _managed_runtime_path_entries() -> list[str]:
"""Existing Hermes-managed runtime dirs: ``$HERMES_HOME/node`` (+``/bin``)
and ``$HERMES_HOME/bin`` (managed ``uv``). Per call, not cached: home is
profile-scoped and a managed tree can appear mid-process."""
try:
from hermes_constants import get_hermes_home, iter_hermes_node_dirs
candidates = [*iter_hermes_node_dirs(), get_hermes_home() / "bin"]
return [str(d) for d in candidates if d.is_dir()]
except Exception:
return []
def _append_missing_sane_path_entries(existing_path: str) -> str:
"""Normalised POSIX PATH with missing sane entries appended: empty entries
dropped (shells read them as cwd), duplicates collapsed (first wins), then
missing ``_SANE_PATH`` / managed-runtime dirs appended so user entries keep
precedence. Windows is a no-op passthrough (native ``;`` PATH untouched)."""
if _IS_WINDOWS:
return existing_path
sane_entries = [entry for entry in _SANE_PATH.split(":") if entry]
sane_entries.extend(
entry for entry in _managed_runtime_path_entries() if entry not in sane_entries
)
if not existing_path:
return ":".join(sane_entries)
# dict preserves first-occurrence order; empty entries dropped.
ordered = dict.fromkeys(entry for entry in existing_path.split(":") if entry)
ordered.update(dict.fromkeys(sane_entries))
return ":".join(ordered)
def _apply_windows_msys_bash_env_defaults(env: dict) -> None:
"""Disable MSYS argument path conversion (``/FO`` -> ``C:/.../git/FO``
breaks tasklist/schtasks/wmic/``cmd /c``). Git for Windows honors
``MSYS_NO_PATHCONV``; MSYS2/Cygwin bash honor ``MSYS2_ARG_CONV_EXCL`` — set
both. Users can override in their env."""
if not _IS_WINDOWS:
return
env.setdefault("MSYS_NO_PATHCONV", "1")
env.setdefault("MSYS2_ARG_CONV_EXCL", "*")
def _path_env_key(run_env: dict) -> str | None:
"""Return the PATH env key to update without altering Windows casing
(``Path`` vs ``PATH``); None when a Windows env has no PATH key at all."""
if not _IS_WINDOWS:
return "PATH"
for key in run_env:
if key.upper() == "PATH":
return key
return None
def _make_run_env(env: dict) -> dict:
"""Build a run environment with a sane PATH and provider-var stripping."""
run_env: dict = {}
_filter_secret_env(dict(os.environ | env), run_env, unwrap_force=True)
path_key = _path_env_key(run_env)
if path_key is not None:
new_path = _append_missing_sane_path_entries(run_env.get(path_key, ""))
new_path = _prepend_git_bash_dirs(new_path)
run_env[path_key] = _prepend_hermes_bin_dir(new_path)
return _finalize_child_env(run_env)
# --- Hermes venv / repo-root detection (module-level, computed once) ---
# Owned here; read lazily by tools.environments.local_pythonpath.
#: The Hermes repository root (three levels up from this file). The Electron
#: app prepends it to PYTHONPATH so the backend can ``import tools``; other
#: subprocesses don't need it and it can shadow local packages.
_hermes_repo_root: Path = Path(__file__).resolve().parents[2]
#: Alternate repo-root spellings Hermes launchers may emit. ``resolve()``
#: canonicalizes junctions, but the Windows gateway launcher renders
#: Hermes-owned paths under the configured HERMES_HOME spelling (possibly a
#: junction to another drive); the unresolved ``Path(__file__)`` keeps it.
_hermes_repo_root_aliases: tuple[Path, ...] = _build_hermes_repo_root_aliases(
_hermes_repo_root,
Path(__file__).absolute().parents[2],
get_process_hermes_home(),
)
#: Whether the interpreter runs inside a venv (``sys.real_prefix`` is the old
#: virtualenv<20 marker).
_in_venv: bool = (
getattr(sys, "base_prefix", sys.prefix) != sys.prefix
or hasattr(sys, "real_prefix")
)
#: Lazily-cached site-packages dirs of the running interpreter's own venv.
_hermes_site_packages: list[Path] | None = None
# --- Login-shell init files ---
def _read_terminal_shell_init_config() -> tuple[list[str], bool]:
"""Return (shell_init_files, auto_source_bashrc) from config.yaml.
Best-effort: defaults on any failure so terminal execution never breaks."""
try:
from hermes_cli.config import load_config
cfg = load_config() or {}
terminal_cfg = cfg.get("terminal") or {}
files = terminal_cfg.get("shell_init_files") or []
if not isinstance(files, list):
files = []
auto_bashrc = bool(terminal_cfg.get("auto_source_bashrc", True))
return [str(f) for f in files if f], auto_bashrc
except Exception:
return [], True
def _resolve_shell_init_files() -> list[str]:
"""Files to source before the login-shell snapshot (``~``/``${VAR}``
expanded, missing dropped). ``auto_source_bashrc`` applies only without an
explicit list: ~/.profile and ~/.bash_profile first (no interactivity guard;
where n/nvm/asdf/pyenv add PATH), ~/.bashrc last (Debian's default returns
early when non-interactive, but guard-less bashrcs keep working)."""
explicit, auto_bashrc = _read_terminal_shell_init_config()
candidates: list[str] = []
if explicit:
candidates.extend(explicit)
elif auto_bashrc and not _IS_WINDOWS:
candidates.extend(["~/.profile", "~/.bash_profile", "~/.bashrc"])
resolved: list[str] = []
for raw in candidates:
try:
path = os.path.expandvars(os.path.expanduser(raw))
except Exception:
continue
if path and os.path.isfile(path):
resolved.append(path)
return resolved
def _prepend_shell_init(cmd_string: str, files: list[str]) -> str:
"""Prepend guarded, silent ``source <file>`` lines to a bash script:
``set +e`` keeps going on errors, ``2>/dev/null`` hides noisy prompts,
``|| true`` neutralises the exit status."""
if not files:
return cmd_string
prelude_parts = ["set +e"]
for path in files:
safe = path.replace("'", "'\\''")
prelude_parts.append(f"[ -r '{safe}' ] && . '{safe}' 2>/dev/null || true")
prelude = "\n".join(prelude_parts) + "\n"
return prelude + cmd_string
# --- Process-group teardown (POSIX) ---
def _group_alive(pgid: int) -> bool:
"""POSIX-only probe; callers are behind the _IS_WINDOWS gate."""
try:
os.killpg(pgid, 0) # windows-footgun: ok — POSIX process-group alive probe
return True
except ProcessLookupError:
return False
except PermissionError:
return True # exists, even if we cannot signal it
def _wait_for_group_exit(proc, pgid: int, timeout: float) -> bool:
"""Wait until the process group is gone, reaping the wrapper as we go
(a dead but unreaped group leader still makes ``killpg(pgid, 0)`` succeed)."""
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
try:
proc.poll()
except Exception:
pass
if not _group_alive(pgid):
return True
time.sleep(0.05)
try:
proc.poll()
except Exception:
pass
return not _group_alive(pgid)
def _snapshot_descendants(proc) -> list:
"""psutil children snapshot; empty on any failure (must never break the kill)."""
try:
import psutil
return psutil.Process(proc.pid).children(recursive=True)
except Exception:
return []
def _sweep_escaped_descendants(descendants: list, pgid: int) -> None:
"""SIGKILL snapshotted survivors that escaped the process group via
``setsid`` — after TERM→KILL so in-group members keep their grace; psutil's
identity-aware Process skips recycled PIDs. POSIX-only (see _IS_WINDOWS gate)."""
for child in descendants:
try:
if not child.is_running():
continue
try:
if os.getpgid(child.pid) == pgid:
continue # group-kill already covers it
except (ProcessLookupError, PermissionError, OSError):
pass
child.kill()
except Exception:
continue
def _kill_process_group_posix(proc) -> None:
"""TERM the group, wait, KILL, then sweep setsid escapees. POSIX-only
(_IS_WINDOWS handled by the caller). Descendants are snapshotted BEFORE the
first signal — once the wrapper dies they reparent to init — and we wait on
the group, not the wrapper, which can exit before grandchildren under load."""
try:
pgid = os.getpgid(proc.pid)
except ProcessLookupError:
pgid = getattr(proc, "_hermes_pgid", None)
if pgid is None:
raise
descendants = _snapshot_descendants(proc)
try:
os.killpg(pgid, signal.SIGTERM) # windows-footgun: ok — POSIX only (see _IS_WINDOWS gate in caller)
except ProcessLookupError:
_sweep_escaped_descendants(descendants, pgid)
return
if _wait_for_group_exit(proc, pgid, 1.0):
_sweep_escaped_descendants(descendants, pgid)
return
try:
os.killpg(pgid, signal.SIGKILL) # windows-footgun: ok — POSIX only (see _IS_WINDOWS gate in caller)
except ProcessLookupError:
_sweep_escaped_descendants(descendants, pgid)
return
_wait_for_group_exit(proc, pgid, 2.0)
try:
proc.wait(timeout=0.2)
except (subprocess.TimeoutExpired, OSError):
pass
_sweep_escaped_descendants(descendants, pgid)
def _kill_process_windows(proc) -> None:
try:
from gateway.status import get_process_start_time, terminate_pid
terminate_pid(
proc.pid,
force=True,
expected_start_time=get_process_start_time(proc.pid),
)
except Exception:
proc.kill()
try:
proc.wait(timeout=2.0)
except (subprocess.TimeoutExpired, OSError):
pass
class LocalEnvironment(BaseEnvironment):
"""Run commands directly on the host machine.
Spawn-per-call: every execute() spawns a fresh bash process.
Session snapshot preserves env vars across calls.
CWD persists via file-based read after each command.
"""
_profile_scoped_passthrough = True
# Commands run on the Hermes host itself — controller-side platform
# behavior (macOS TCC pruning, etc.) legitimately applies here.
is_local = True
def _additional_profile_scoped_passthrough_names(self) -> tuple[str, ...]:
"""First-party ``BUZZ_*`` names present in the env, excluded from the
shared session snapshot. env_passthrough can never list them (it refuses
blocklisted names), so under a multiplexed gateway profile A's
BUZZ_PRIVATE_KEY would land in the snapshot and be sourced by profile B.
Prefix-only and monotonic on purpose: conservative even when the
context-gated carve-out is inactive."""
merged = dict(os.environ | self.env)
return tuple(
sorted(
name
for name in merged
if isinstance(name, str) and _matches_terminal_first_party_prefix(name)
)
)
def __init__(self, cwd: str = "", timeout: int = 60, env: dict = None):
cwd = _resolve_local_initial_cwd(cwd)
super().__init__(cwd=cwd, timeout=timeout, env=env)
self.init_session()
def get_temp_dir(self) -> str:
"""Shell-safe writable temp dir. Precedence: ``TERMINAL_TEMP_DIR``,
POSIX TMPDIR/TMP/TEMP (Termux has no /tmp), ``HERMES_HOME/cache/terminal``,
/tmp, POSIX ``tempfile.gettempdir()``; backend env before process env so
terminal.env overrides work. The default is real storage because tmpfs
/tmp fills under Hermes load (pruned by ``cleanup_terminal_temp_cache``).
Windows: ``%TEMP%`` often has spaces that break unquoted bash, so always
the HERMES_HOME cache dir with forward slashes (valid in bash and Python).
"""
if _IS_WINDOWS:
# Forward slashes: one string valid in bash interpolation AND Python open().
try:
from hermes_constants import get_hermes_home
cache_dir = get_hermes_home() / "cache" / "terminal"
except Exception:
cache_dir = Path(tempfile.gettempdir()) / "hermes_terminal"
cache_dir.mkdir(parents=True, exist_ok=True)
_prune_terminal_temp_once()
return str(cache_dir).replace("\\", "/")
def _posix(p: str) -> str:
return p.rstrip("/") or "/"
configured = self.env.get("TERMINAL_TEMP_DIR") or os.environ.get("TERMINAL_TEMP_DIR")
if configured and configured.startswith("/") and os.path.isdir(configured):
return _posix(configured)
for env_var in ("TMPDIR", "TMP", "TEMP"):
candidate = self.env.get(env_var) or os.environ.get(env_var)
if candidate and candidate.startswith("/"):
return _posix(candidate)
try:
from hermes_constants import get_hermes_home
cache_dir = get_hermes_home() / "cache" / "terminal"
cache_dir.mkdir(parents=True, exist_ok=True)
resolved = str(cache_dir)
if resolved.startswith("/") and os.access(resolved, os.W_OK | os.X_OK):
_prune_terminal_temp_once()
return _posix(resolved)
except Exception:
pass
if os.path.isdir("/tmp") and os.access("/tmp", os.W_OK | os.X_OK):
return "/tmp"
candidate = tempfile.gettempdir()
return _posix(candidate) if candidate.startswith("/") else "/tmp"
@staticmethod
def _quote_cwd_for_cd(cwd: str) -> str:
"""Use native paths for Python, but Git Bash-friendly paths for cd."""
return BaseEnvironment._quote_cwd_for_cd(_windows_to_msys_path(cwd))
def _quote_shell_path(self, path: str) -> str:
"""Rewrite native/mixed Windows paths before quoting for Git Bash."""
return _quote_bash_path(path)
def _recover_cwd(self) -> None:
"""Swap ``self.cwd`` for a usable directory if it vanished or is
inaccessible (e.g. a previous command ``rm -rf``'d its own cwd) —
otherwise Popen raises before bash starts and every subsequent call
fails. A benign MSYS→Windows normalization is not warned about."""
safe_cwd = _resolve_safe_cwd(self.cwd)
if safe_cwd == self.cwd:
return
normalized = _msys_to_windows_path(self.cwd) if _IS_WINDOWS else self.cwd
if safe_cwd != normalized:
logger.warning(
"LocalEnvironment cwd %r is missing on disk; "
"falling back to %r so terminal commands keep working.",
self.cwd,
safe_cwd,
)
self.cwd = safe_cwd
def _run_bash(self, cmd_string: str, *, login: bool = False,
timeout: int = 120,
stdin_data: str | None = None) -> subprocess.Popen:
bash = _find_bash()
# Login invocations (init_session's env snapshot) source the user's rc /
# custom init files so nvm/asdf/pyenv land on PATH in the snapshot.
if login:
init_files = _resolve_shell_init_files()
if init_files:
cmd_string = _prepend_shell_init(cmd_string, init_files)
args = [bash, "-l", "-c", cmd_string] if login else [bash, "-c", cmd_string]
run_env = _make_run_env(self.env)
self._recover_cwd()
_popen_kwargs = {"creationflags": windows_hide_flags()} if _IS_WINDOWS else {}
proc = subprocess.Popen(
args,
text=True,
env=run_env,
encoding="utf-8",
errors="replace",
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
stdin=subprocess.PIPE if stdin_data is not None else subprocess.DEVNULL,
start_new_session=True,
cwd=self.cwd,
**_popen_kwargs,
)
if not _IS_WINDOWS:
try:
proc._hermes_pgid = os.getpgid(proc.pid)
except ProcessLookupError:
pass
if stdin_data is not None:
_pipe_stdin(proc, stdin_data)
return proc
def _kill_process(self, proc):
"""Kill the entire process group (all children)."""
try:
if _IS_WINDOWS:
_kill_process_windows(proc)
else:
_kill_process_group_posix(proc)
except (ProcessLookupError, PermissionError, OSError):
try:
proc.kill()
except Exception:
pass
def _update_cwd(self, result: dict):
"""Update cwd from the stdout marker the base wrapper emits (``pwd -P``),
sharing the remote backends' parser instead of re-reading a temp file."""
self._extract_cwd_from_output(result)
def _extract_cwd_from_output(self, result: dict):
"""Base semantics plus: Git Bash ``pwd -P`` emits MSYS form on Windows —
normalize to native and require the dir to exist, else ``_run_bash`` would
warn every command. A stale path rolls back to the previous cwd, which
this command did not observe, so ``cwd_observed`` is dropped."""
prev_cwd = self.cwd
super()._extract_cwd_from_output(result)
if self.cwd != prev_cwd:
normalized = _msys_to_windows_path(self.cwd) if _IS_WINDOWS else self.cwd
if normalized and os.path.isdir(normalized):
self.cwd = normalized
result["cwd"] = normalized
else:
self.cwd = prev_cwd
result.pop("cwd_observed", None)
result.pop("cwd", None)
def cleanup(self):
"""Clean up temp files, including orphaned atomic-write snapshots
(``snap.tmp.<bashpid>``) a failed/interrupted mv could leave behind."""
import glob
try:
stale = glob.glob(f"{self._snapshot_path}.tmp.*")
except Exception:
stale = []
for f in (self._snapshot_path, self._cwd_file, *stale):
try:
os.unlink(f)
except OSError:
pass