"""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 _IS_WINDOWS = platform.system() == "Windows" logger = logging.getLogger(__name__) # --- Terminal temp-cache pruning ------------------------------------------- # # get_temp_dir() now defaults to HERMES_HOME/cache/terminal (real storage) # instead of tmpfs /tmp, so stale session artifacts no longer disappear on # reboot for free. Prune them ourselves: the gateway housekeeping loop calls # cleanup_terminal_temp_cache() hourly (same contract as the other # cleanup_*_cache helpers), and a once-per-process best-effort sweep covers # CLI-only installs that never run the gateway. # # Background-process artifacts come in triplets (hermes_bg_.log/.pid/ # .exit). A long-running server's .pid file never changes mtime while its # .log keeps updating — so age is judged per GROUP (newest mtime among files # sharing a stem) to avoid yanking the pid/exit files out from under a # still-live background session. TERMINAL_TEMP_MAX_AGE_HOURS = 72 _terminal_temp_prune_lock = threading.Lock() _terminal_temp_pruned_once = False _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*. Same contract as the ``cleanup_*_cache`` helpers in ``gateway.platforms.base`` — returns the number of entries removed — so the gateway housekeeping loop can prune this dir on its hourly cadence. Only prunes the managed default dir (``HERMES_HOME/cache/terminal``). User-pointed ``terminal.temp_dir`` locations are the user's to manage — we never bulk-delete inside a directory we don't own. """ root = _default_terminal_temp_dir() if root is None: return 0 cutoff = time.time() - (max_age_hours * 3600) removed = 0 try: entries = list(root.iterdir()) except OSError: return 0 # Newest mtime per hermes_bg_ group, so a live server's fresh .log # protects its stale-looking .pid/.exit siblings. group_newest: dict[str, float] = {} for f in entries: m = _BG_GROUP_RE.match(f.name) if m: try: mt = f.stat().st_mtime except OSError: continue key = m.group(1) group_newest[key] = max(group_newest.get(key, 0.0), mt) for f in entries: try: mt = f.stat().st_mtime except OSError: continue m = _BG_GROUP_RE.match(f.name) effective = group_newest.get(m.group(1), mt) if m else mt if effective >= 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) def _msys_to_windows_path(cwd: str) -> str: """Translate a Git Bash / MSYS-style POSIX path (``/c/Users/x``) to the native Windows form (``C:\\Users\\x``) so ``os.path.isdir`` and ``subprocess.Popen(..., cwd=...)`` can find it. Also accepts the Cygwin (``/cygdrive/c/...``) and WSL-mount (``/mnt/c/...``) spellings of a drive root. Multi-segment POSIX paths like ``/home/x`` or ``/tmp/foo`` are left untouched. No-ops on non-Windows hosts or for paths that aren't in MSYS form. Returns the input unchanged when no translation applies. This is idempotent — calling it on an already-Windows path returns it as-is. """ if not _IS_WINDOWS or not cwd: return cwd # Match leading "//" or exactly "/" (bare drive root), # plus /cygdrive//... and /mnt//... variants. m = re.match(r'^/(?:(?:cygdrive|mnt)/)?([a-zA-Z])(/.*)?$', cwd) if not m: return cwd # Reject /cygdrive or /mnt with no drive letter — the optional group above # already requires the letter. Multi-char first segments (/home, /tmp) # fail the single-letter capture and fall through as no-ops. 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 local backend's initial cwd to an absolute host path. ``TERMINAL_CWD`` can be populated from config.yaml before the terminal backend is created. If that value is relative and happens to match the directory Hermes was already launched from (for example ``hermes-agent`` while the process cwd is ``~/.hermes/hermes-agent``), passing it through unchanged makes the wrapper run ``cd hermes-agent`` *inside* the project and fail with a confusing nested-path error. Anchor relative local cwd values once, up front, so both ``subprocess.Popen(cwd=...)`` and the in-shell ``cd`` use the same absolute directory. """ expanded = os.path.expanduser(cwd) if cwd else os.getcwd() if _IS_WINDOWS: expanded = _msys_to_windows_path(expanded) # Use the Windows-aware check explicitly: when _IS_WINDOWS is # patched in tests on a POSIX host, os.path.isabs would reject # ``C:\Users\x`` and mangle it through the relative branch. import ntpath if ntpath.isabs(expanded): return expanded if os.path.isabs(expanded): return expanded candidate = os.path.abspath(expanded) current = os.getcwd() # Common recovery for config values like ``hermes-agent`` when Hermes was # launched from that directory already. ``os.path.abspath`` would point at # a nonexistent nested ``./hermes-agent``; use the current directory instead. if not os.path.isdir(candidate): wanted_parts = Path(expanded).parts current_parts = Path(current).parts if wanted_parts and len(wanted_parts) <= len(current_parts): if current_parts[-len(wanted_parts):] == wanted_parts: return current return candidate def _windows_to_msys_path(cwd: str) -> str: """Translate a native Windows path (``C:\\Users\\x``) to Git Bash / MSYS form (``/c/Users/x``) so ``builtin cd`` resolves it reliably. No-ops on non-Windows hosts or for paths that aren't drive-qualified native Windows paths. Returns the input unchanged when no translation applies. """ 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`` → ``/c/Users/x`` via :func:`_windows_to_msys_path`. Mixed MSYS leftovers (``/c/Users\\Alexander\\Documents``) get backslashes normalized so bash does not eat ``\\U`` and trip the ``Directory \\drivers\\etc`` failure class. No-op off Windows and for empty input. ``get_temp_dir`` already emits forward-slash ``C:/...`` forms for Python compatibility; those still need the ``/c/...`` rewrite — MSYS argument conversion treats ``C:/...`` as a Windows path and can corrupt the login-shell ``drivers\\etc`` lookup. """ if not _IS_WINDOWS or not path: return path path = _windows_to_msys_path(path) if "\\" in path: path = path.replace("\\", "/") return path 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. ``os.path.isdir`` alone is not enough: stat() on ``/root`` succeeds for a non-root user (only ``/`` needs search permission), but ``subprocess.Popen(cwd='/root')`` then dies with ``PermissionError: [Errno 13] Permission denied: '/root'``. Seen in the wild when a root-launched CLI session leaks ``/root`` into shared state that a non-root gateway/cron process later reads (#65583) — every cron job's terminal/file tool then fails on every command, forever. Checking X_OK up front lets the caller fall back instead. """ return os.path.isdir(path) and os.access(path, os.X_OK) def _resolve_safe_cwd(cwd: str) -> str: """Return ``cwd`` if it exists as a directory this process can enter, else the nearest existing accessible ancestor. Falls back to ``tempfile.gettempdir()`` only if walking up the path can't find any usable directory (effectively never on a healthy filesystem, but cheap belt-and-braces). On Windows, also normalizes Git Bash / MSYS-style POSIX paths (``/c/Users/x``) to native Windows form before the isdir check so a perfectly valid ``pwd -P`` result from bash doesn't get rejected as "missing" (see ``_msys_to_windows_path``). Used by ``_run_bash`` to recover when the configured cwd is gone — most commonly because a previous tool call deleted its own working directory (issue #17558) — or inaccessible to this user, e.g. ``/root`` leaking from a root-launched CLI session into a non-root gateway's cron jobs (issue #65583). Without this guard, ``subprocess.Popen(..., cwd=...)`` raises ``FileNotFoundError``/``PermissionError`` before bash starts, wedging every subsequent terminal call until the gateway restarts. """ 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: # Reached the filesystem root and it doesn't exist either — # genuinely nothing to fall back to except the temp dir. break parent = next_parent return tempfile.gettempdir() # Hermes-internal env vars that should NOT leak into terminal subprocesses. _HERMES_PROVIDER_ENV_FORCE_PREFIX = "_HERMES_FORCE_" # Hermes-managed AWS *inference* credentials for ``auth_type="aws_sdk"`` # providers (Bedrock). Scoped DELIBERATELY NARROW: this lists only the # Bedrock-specific bearer token, which is a Hermes inference secret exactly # analogous to ``OPENAI_API_KEY`` — nobody drives the ``aws``/``terraform``/ # ``boto3`` toolchain off it, so stripping it from terminal/execute_code # subprocesses costs no user capability. # # The GENERAL AWS credential chain (AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, # AWS_SESSION_TOKEN, AWS_PROFILE, and the config/role pointers) is INTENTIONALLY # left inheritable. Per SECURITY.md §3.2 the local terminal is the user's # trusted operator shell; the agent having the same general AWS access the # user's own shell has is the intended posture, not a leak. Hard-blocklisting # those vars would (a) regress every user who runs aws/terraform/cdk/boto3 in # the agent terminal — not just Bedrock users, since the registry is iterated # unconditionally — and (b) be unrecoverable, because env_passthrough.py # refuses to re-allow anything in this blocklist (GHSA-rhgp-j443-p4rf). See # issue #32314 discussion. _AWS_SDK_CREDENTIAL_ENV_VARS = frozenset({ "AWS_BEARER_TOKEN_BEDROCK", }) def _build_provider_env_blocklist() -> frozenset: """Derive the blocklist from provider, tool, and gateway config.""" blocked: set[str] = set() try: from hermes_cli.auth import PROVIDER_REGISTRY for pconfig in PROVIDER_REGISTRY.values(): blocked.update(pconfig.api_key_env_vars) if pconfig.auth_type == "aws_sdk": blocked.update(_AWS_SDK_CREDENTIAL_ENV_VARS) if pconfig.base_url_env_var: blocked.add(pconfig.base_url_env_var) except ImportError: pass try: from hermes_cli.config import OPTIONAL_ENV_VARS for name, metadata in OPTIONAL_ENV_VARS.items(): category = metadata.get("category") if category in {"tool", "messaging"}: blocked.add(name) elif category == "setting" and metadata.get("password"): blocked.add(name) except ImportError: pass blocked.update({ "OPENAI_BASE_URL", "OPENAI_API_KEY", "OPENAI_API_BASE", "OPENAI_ORG_ID", "OPENAI_ORGANIZATION", "OPENROUTER_API_KEY", "ANTHROPIC_BASE_URL", "ANTHROPIC_API_KEY", "ANTHROPIC_TOKEN", "LLM_MODEL", "GOOGLE_API_KEY", # Path to a GCP service-account JSON, not a bare key, so # OPTIONAL_ENV_VARS marks it password=False and the loop above skips it. "VERTEX_CREDENTIALS_PATH", "GOOGLE_APPLICATION_CREDENTIALS", "DEEPSEEK_API_KEY", "MISTRAL_API_KEY", "GROQ_API_KEY", "TOGETHER_API_KEY", "PERPLEXITY_API_KEY", "COHERE_API_KEY", "FIREWORKS_API_KEY", "XAI_API_KEY", "HELICONE_API_KEY", "PARALLEL_API_KEY", "FIRECRAWL_API_KEY", "FIRECRAWL_API_URL", "TELEGRAM_HOME_CHANNEL", "TELEGRAM_HOME_CHANNEL_NAME", "DISCORD_HOME_CHANNEL", "DISCORD_HOME_CHANNEL_NAME", "DISCORD_REQUIRE_MENTION", "DISCORD_FREE_RESPONSE_CHANNELS", "DISCORD_AUTO_THREAD", "SLACK_HOME_CHANNEL", "SLACK_HOME_CHANNEL_NAME", "SLACK_ALLOWED_USERS", "WHATSAPP_ENABLED", "WHATSAPP_MODE", "WHATSAPP_ALLOWED_USERS", "SIGNAL_HTTP_URL", "SIGNAL_ACCOUNT", "SIGNAL_ALLOWED_USERS", "SIGNAL_GROUP_ALLOWED_USERS", "SIGNAL_HOME_CHANNEL", "SIGNAL_HOME_CHANNEL_NAME", "SIGNAL_IGNORE_STORIES", "HASS_TOKEN", "HASS_URL", "EMAIL_ADDRESS", "EMAIL_PASSWORD", "EMAIL_IMAP_HOST", "EMAIL_SMTP_HOST", "EMAIL_HOME_ADDRESS", "EMAIL_HOME_ADDRESS_NAME", "HERMES_DASHBOARD_SESSION_TOKEN", "GATEWAY_ALLOWED_USERS", "GH_TOKEN", "GITHUB_APP_ID", "GITHUB_APP_PRIVATE_KEY_PATH", "GITHUB_APP_INSTALLATION_ID", "MODAL_TOKEN_ID", "MODAL_TOKEN_SECRET", "DAYTONA_API_KEY", "GATEWAY_RELAY_ID", "GATEWAY_RELAY_SECRET", "GATEWAY_RELAY_DELIVERY_KEY", "VERCEL_OIDC_TOKEN", "VERCEL_TOKEN", "VERCEL_PROJECT_ID", "VERCEL_TEAM_ID", }) # CLAUDE_CODE_OAUTH_TOKEN is deliberately NOT stripped. It is set and # owned by the user's Claude Code install (subscription OAuth), not a # Hermes-managed inference credential — Claude subscription auth is not a # working Hermes provider path. Stripping it broke agent-spawned # ``claude`` CLIs: the child fell through to the shared macOS Keychain / # ``~/.claude/.credentials.json`` store and, on auth failure, cleared it, # logging the user out of their interactive Claude sessions (#55878). # It arrives via the registry loop above (anthropic api_key_env_vars), # so remove it explicitly. blocked.discard("CLAUDE_CODE_OAUTH_TOKEN") # BUZZ_* is deliberately NOT discarded here, even for Buzz-managed agents # (BUZZ_MANAGED_AGENT set by the buzz-acp harness). This blocklist is # shared by every scrub surface — the terminal paths, execute_code, and # the :func:`hermes_subprocess_env` Tier-2 strip (browser / TUI host / # copilot-executor spawns) — so an import-time discard would leak # BUZZ_PRIVATE_KEY into non-terminal children too. The Buzz carve-out is # instead a TERMINAL-ONLY, context-gated scrub-path exemption: see # ``_TERMINAL_FIRST_PARTY_ENV_PREFIXES`` / ``_is_terminal_first_party_env`` # below (issue #78026 / #76243, PRs #78065 + #78511). return frozenset(blocked) _HERMES_PROVIDER_ENV_BLOCKLIST = _build_provider_env_blocklist() # First-party platform credentials the agent's own platform adapters need in # terminal children (e.g. the ``BUZZ_*`` vars for the Buzz messaging # platform, which drive the platform-mandated ``buzz`` CLI: BUZZ_PRIVATE_KEY, # BUZZ_AUTH_TAG, BUZZ_RELAY_URL, and the other BUZZ_* names). These are the # agent's OWN credentials — a Buzz community agent is expected to operate the # ``buzz`` CLI — so they are carved out of the terminal scrub. # # CONTEXT-GATED: the carve-out applies ONLY when this process/session is # actually operating as a Buzz agent — either the process is a Buzz-ACP # managed agent (``BUZZ_MANAGED_AGENT`` is set, only by Buzz Desktop's # buzz-acp harness; see #76243 / #78511) or the current session's platform is # ``buzz`` (the gateway's ``HERMES_SESSION_PLATFORM`` ContextVar; concurrency # safe under a multi-session host). A Telegram/CLI/cron session on a host # that also runs a Buzz gateway does NOT get BUZZ_PRIVATE_KEY in its terminal # children — blanket passthrough of a signing key to every terminal child on # the host would be wrong (maintainer triage note on #76243: don't expose the # key to unrelated shell commands). # # Scope is TERMINAL ONLY: the foreground ``_make_run_env`` and background/PTY # ``_sanitize_subprocess_env`` paths pass them through. ``_sanitize_subprocess_env`` # is also consumed by search workers (e.g. the ddgs web-search subprocess), # the computer-use driver binary, and user-script runners (bang ``!`` # commands, quick commands, cron scripts, webhook-filter scripts), so those # children receive the vars too — matching the approved background/PTY scope. # Every other surface stays sealed — execute_code scrubbing, # :func:`hermes_subprocess_env` (browser / TUI host / copilot-executor # spawns), docker children, and ``env_passthrough`` registration (skills/config # still cannot register these names). The GHSA-rhgp-j443-p4rf seal is # preserved because no registration path is opened; this is a scrub-path # exemption, not an allowlist addition. # # First-party matches use the merged env value directly — they are the # process's own env values and are never scope-resolved (a profile secret # scope under multiplex would otherwise raise UnscopedSecretError at # passthrough-resolution call sites); only skill/config passthrough names # resolve through the profile secret scope. The snapshot mechanism treats # these names like profile-scoped passthrough names (see # ``LocalEnvironment._additional_profile_scoped_passthrough_names``) so they # never persist in the shared terminal snapshot across profiles. # # Prefix-based on purpose: future ``BUZZ_*`` names added by the platform's # plugin.yaml (or a user's own credentials file) are covered without another # code change. Contrast with CLAUDE_CODE_OAUTH_TOKEN above, which is discarded # from the blocklist entirely because it is NOT a Hermes credential; these ARE # Hermes-managed first-party platform credentials, so they stay IN the # blocklist for every non-terminal surface. # # See issue #78026 (Buzz agents could not use ``buzz`` from the terminal tool) # and #76243 (Buzz Desktop managed agent wakes but cannot reply). _TERMINAL_FIRST_PARTY_ENV_PREFIXES = ("BUZZ_",) def _matches_terminal_first_party_prefix(name: str) -> bool: """Pure name check: ``name`` is one of the first-party platform credential names (``BUZZ_*``), regardless of session context. Used for the snapshot exclusion, which must stay conservative even when the carve-out itself is inactive.""" return name.startswith(_TERMINAL_FIRST_PARTY_ENV_PREFIXES) def _buzz_terminal_context_active() -> bool: """True when this process/session is operating as a Buzz agent. Two independent signals, either suffices: * ``BUZZ_MANAGED_AGENT`` in the process env — set exclusively by Buzz Desktop's buzz-acp harness when it spawns ``hermes acp`` (#76243). Gateway / CLI / cron / kanban processes never carry it. * The live session's platform is ``buzz`` — the gateway's ``HERMES_SESSION_PLATFORM`` ContextVar via :func:`gateway.session_context.get_session_env`, which is ContextVar-authoritative under a concurrent multi-session host, so a sibling Telegram/Discord session on the same gateway process resolves its OWN platform, not buzz. """ if os.environ.get("BUZZ_MANAGED_AGENT"): return True try: from gateway.session_context import get_session_env return get_session_env("HERMES_SESSION_PLATFORM", "").strip().lower() == "buzz" except Exception: return False def _is_terminal_first_party_env(name: str) -> bool: """Return True if ``name`` is a first-party platform credential that must reach terminal children (the ``BUZZ_*`` set) AND the current process/session context entitles it (Buzz-managed agent or a buzz-platform session — see :func:`_buzz_terminal_context_active`).""" if not _matches_terminal_first_party_prefix(name): return False return _buzz_terminal_context_active() # Active-virtualenv markers that must NOT leak into terminal subprocesses. # The gateway runs inside its own venv, so its process environment carries # VIRTUAL_ENV (and possibly CONDA_PREFIX). If those leak into commands the # agent runs against OTHER Python projects, tools like ``uv``/``poetry`` treat # the inherited value as the active environment and build/sync that other # project's dependencies into the Hermes venv path instead of the project's own # ``.venv`` — silently clobbering the Hermes environment (e.g. a project pinned # to a different Python version overwrites it and breaks the gateway). The # Hermes venv stays reachable via PATH (its bin dir is first), so stripping # these markers is safe and only prevents the cross-project clobber (#23473). # # PYTHONHOME is included because a gateway-inherited value redirects the # standard-library search of ANY child interpreter — including unrelated # system/venv Pythons — to the Hermes venv's stdlib, which crashes with # version-mismatch errors before a child script even imports a package # (#75018). Hermes itself treats PYTHONHOME as contamination in its own # child processes (managed_uv.py, sqlite_runtime.py), so stripping it from # subprocess envs is consistent. Users who need PYTHONHOME for a specific # child can set it explicitly in the command. # # PYTHONPATH is NOT included here — it's handled by # _strip_hermes_owned_pythonpath() which removes only Hermes-owned entries, # preserving user-set paths. _ACTIVE_VENV_MARKER_VARS = ("VIRTUAL_ENV", "CONDA_PREFIX", "PYTHONHOME") def _is_hermes_internal_secret(key: str) -> bool: """Return True for Hermes-internal secrets injected under *dynamic* names. ``_HERMES_PROVIDER_ENV_BLOCKLIST`` is name-based and derived from the provider/tool registries, but the gateway and CLI also inject secrets into ``os.environ`` at runtime under names no static registry knows about: - ``AUXILIARY__API_KEY`` / ``AUXILIARY__BASE_URL`` — per-task side-LLM credentials bridged from ``config.yaml[auxiliary]`` by ``gateway/run.py`` and ``cli.py`` (vision, web_extract, approval, compression, and any plugin-registered auxiliary task). These are separate, often higher-spend API keys plus base URLs that may point at private endpoints; a model-authored shell command must never see them. - ``GATEWAY_RELAY_*_SECRET`` / ``GATEWAY_RELAY_*_KEY`` / ``GATEWAY_RELAY_*_TOKEN`` — relay-auth material provisioned by the gateway (``GATEWAY_RELAY_SECRET``, ``GATEWAY_RELAY_DELIVERY_KEY``). These are Tier-1 gateway secrets, like the messaging bot tokens in ``_ALWAYS_STRIP_KEYS``. Non-secret ``GATEWAY_RELAY_*`` routing hints (``GATEWAY_RELAY_URL``, ``GATEWAY_RELAY_PLATFORMS``, …) are NOT matched and remain visible. ``code_execution_tool.py`` already catches these via substring matching on ``KEY`` / ``SECRET`` / ``TOKEN``; the terminal backend's narrower name-based blocklist did not, which is the leak this predicate closes. This is the single source of truth for "Hermes-internal dynamic secret" across every spawn path — the terminal ``_make_run_env`` / ``_sanitize_subprocess_env`` filters, the Docker passthrough filter, and the non-terminal :func:`hermes_subprocess_env` helper all call it, so the dynamic patterns are stripped **unconditionally** regardless of ``env_passthrough`` skill registration or ``inherit_credentials``. Nothing a model-driving CLI legitimately needs matches these patterns. """ upper = key.upper() if upper.startswith("AUXILIARY_") and ( upper.endswith("_API_KEY") or upper.endswith("_BASE_URL") ): return True if upper.startswith("GATEWAY_RELAY_") and ( upper.endswith("_SECRET") or upper.endswith("_KEY") or upper.endswith("_TOKEN") ): return True return False def _plugin_terminal_env_strip_keys() -> frozenset: """Credential env keys owned by plugin-registered terminal backends. Computed at call time (not import time) because plugins register after this module is imported. Treated as Tier-1: stripped from every spawned subprocess unconditionally, exactly like MODAL_*/DAYTONA_API_KEY in ``_ALWAYS_STRIP_KEYS``. Fail-soft to an empty set. """ try: from agent.terminal_env_registry import plugin_strip_env_keys return plugin_strip_env_keys() except Exception: return frozenset() 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 _inject_session_context_env(env: dict) -> None: """Bridge gateway session ContextVars into a subprocess environment dict. ContextVars don't propagate to child processes, so the live session vars (HERMES_SESSION_*) are bridged onto the child env here. 🔴 Cross-session leak guard. The session vars also have a process-global os.environ mirror (written last-writer-wins as a CLI/cron fallback, never cleared). Under a concurrent multi-session host (the messaging gateway, ACP adapter, API server, TUI) that global belongs to *whichever turn wrote it last* — NOT necessarily this task. A subprocess spawned from a task whose ContextVar is _UNSET (e.g. a sibling message task that never bound, or one that inherited another session's context) would otherwise inherit the FOREIGN global and act on another session's identity. So once the session-context machinery is engaged in this process (any host has called set_session_vars), the session vars are ContextVar-authoritative: - ContextVar set (incl. explicitly-empty "") → that value wins, overriding any stale snapshot/global value. - ContextVar _UNSET → STRIP the var from the child env rather than inherit the possibly-foreign process-global. In a pure single-process CLI/one-shot that never engaged the session-context system there is no concurrency to leak across, so the inherited fallback is kept. See gateway/session_context.session_context_engaged and tests/tools/test_local_env_session_leak.py. """ 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: # Explicitly bound (including "") — authoritative for this task. env[var_name] = "" if value is None else str(value) elif _engaged: # Unset for THIS task while a concurrent host is engaged: drop any # inherited global so a sibling session's value can't leak in. env.pop(var_name, None) def _sanitize_subprocess_env(base_env: dict | None, extra_env: dict | None = None) -> dict: """Filter Hermes-managed secrets from a subprocess environment.""" try: from tools.env_passthrough import ( is_env_passthrough as _is_passthrough, resolve_passthrough_value as _resolve_passthrough_value, ) except Exception: _is_passthrough = lambda _: False # noqa: E731 _resolve_passthrough_value = lambda _name, fallback: fallback # noqa: E731 sanitized: dict[str, str] = {} _plugin_strip = _plugin_terminal_env_strip_keys() for key, value in (base_env or {}).items(): if key.startswith(_HERMES_PROVIDER_ENV_FORCE_PREFIX): continue if _is_hermes_internal_secret(key): continue if 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 # First-party platform vars are the process's own env values: use them # directly, never scope-resolve (multiplex with no scope would raise # UnscopedSecretError — a regression where the script previously ran # without the var). Only skill/config passthrough names resolve. resolved = value if passthrough and not first_party: resolved = _resolve_passthrough_value(key, value) if resolved is not None: sanitized[key] = resolved for key, value in (extra_env or {}).items(): if key.startswith(_HERMES_PROVIDER_ENV_FORCE_PREFIX): real_key = key[len(_HERMES_PROVIDER_ENV_FORCE_PREFIX):] if _is_hermes_internal_secret(real_key): continue sanitized[real_key] = value elif _is_hermes_internal_secret(key): continue elif key in _plugin_strip: continue else: 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: sanitized[key] = resolved _inject_context_hermes_home(sanitized) from hermes_constants import apply_subprocess_home_env apply_subprocess_home_env(sanitized) # Same cross-session leak guard as _make_run_env, for the background/PTY # spawn path (process_registry.spawn_local builds env via this function). _inject_session_context_env(sanitized) # Filter PYTHONPATH before removing VIRTUAL_ENV: legacy Windows launchers # can run the gateway under a base interpreter while VIRTUAL_ENV identifies # the separate Hermes runtime venv. The filter validates that relationship # against the repo layout before trusting it. _strip_hermes_owned_pythonpath_and_runtime_markers(sanitized) # Keep bare ``hermes`` invocations available to child jobs even when the # gateway was launched by a service manager or cron without the console # script's directory on PATH. The terminal environment already applies # this invariant; Cron scripts use this sanitizer directly (#92998). path_key = _path_env_key(sanitized) if path_key is not None: sanitized[path_key] = _prepend_hermes_bin_dir(sanitized.get(path_key, "")) _apply_windows_msys_bash_env_defaults(sanitized) sanitized = _scrub_delegated_child_kanban_env(sanitized) return sanitized 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 # Tier-1 secrets: stripped from EVERY spawned subprocess unconditionally — # even when the caller opts into credential inheritance for a model-driving # CLI (claude / codex / gemini). These are not LLM provider credentials; no # legitimate child Hermes spawns needs them, and they are the highest-value # secrets to keep out of a compromised dependency's reach (gateway bot tokens, # GitHub auth, remote-compute tokens, dashboard session secret). The set is a # narrow subset of _HERMES_PROVIDER_ENV_BLOCKLIST; provider keys are handled by # the conditional Tier-2 strip in hermes_subprocess_env(). _ALWAYS_STRIP_KEYS: frozenset[str] = frozenset({ # GitHub auth "GH_TOKEN", "GITHUB_TOKEN", "GITHUB_APP_ID", "GITHUB_APP_PRIVATE_KEY_PATH", "GITHUB_APP_INSTALLATION_ID", # Gateway / messaging bot tokens and access control "TELEGRAM_BOT_TOKEN", "DISCORD_BOT_TOKEN", "SLACK_BOT_TOKEN", "SLACK_APP_TOKEN", "SLACK_SIGNING_SECRET", "GATEWAY_ALLOWED_USERS", "GATEWAY_ALLOW_ALL_USERS", # Gateway relay auth — the ID/secret/delivery-key triplet the gateway # provisions and persists to the 0600 .env. Stripped unconditionally on # EVERY spawn surface (terminal + model-driving CLIs) so it can't drift # between paths: _SECRET / _DELIVERY_KEY are also matched by # _is_hermes_internal_secret, but _ID has no secret suffix, so it must be # enumerated here to stay stripped on the inherit_credentials=True path # (codex / copilot), which skips the Tier-2 blocklist. "GATEWAY_RELAY_ID", "GATEWAY_RELAY_SECRET", "GATEWAY_RELAY_DELIVERY_KEY", "HASS_TOKEN", "EMAIL_PASSWORD", "HERMES_DASHBOARD_SESSION_TOKEN", # Remote-compute / infrastructure secrets "MODAL_TOKEN_ID", "MODAL_TOKEN_SECRET", "DAYTONA_API_KEY", }) def hermes_subprocess_env(*, inherit_credentials: bool = False) -> dict[str, str]: """Build a sanitized environment dict for a spawned subprocess. Centralized helper for the **non-terminal** spawn surface (browser, ACP/CLI executors, computer-use driver, dep-ensure, TUI Node host, detached gateway). Use this instead of copying ``os.environ`` directly so strip-by-default is the uniform policy across every spawn site, with a single source of truth (``_HERMES_PROVIDER_ENV_BLOCKLIST``). The terminal / execute_code path keeps using :func:`_sanitize_subprocess_env`, which is skill-aware (``env_passthrough``); this helper is for spawns that have no skill-passthrough concept. Two-tier stripping: * **Tier 1 (always):** ``_ALWAYS_STRIP_KEYS`` — gateway bot tokens, GitHub auth, and remote-compute secrets are removed regardless of ``inherit_credentials``. No child Hermes spawns legitimately needs them. * **Tier 2 (conditional):** the rest of ``_HERMES_PROVIDER_ENV_BLOCKLIST`` (LLM provider API keys, tool secrets) is removed unless the caller passes ``inherit_credentials=True``. Pass ``inherit_credentials=True`` **only** when the child legitimately needs LLM provider credentials — a user-blessed ``claude`` / ``codex`` / ``gemini`` CLI executor, or the TUI Node host that makes model calls. The flag is grep-able for audit: ``grep -rn 'inherit_credentials=True'`` lists every spawn site that still receives provider credentials. Callers that need a *specific* non-provider secret (e.g. the browser worker needs ``BROWSERBASE_API_KEY`` / ``FIRECRAWL_API_KEY``) should call with ``inherit_credentials=False`` and copy just those keys back from ``os.environ`` into the returned dict. """ env = os.environ.copy() # Tier 1 — always strip. for key in _ALWAYS_STRIP_KEYS: env.pop(key, None) for key in _plugin_terminal_env_strip_keys(): env.pop(key, None) # Internal routing hints and Hermes-internal dynamic secrets # (``AUXILIARY__API_KEY`` / ``_BASE_URL`` side-LLM credentials, # ``GATEWAY_RELAY_*`` relay-auth material) must never reach a child, # regardless of ``inherit_credentials`` — a model-driving CLI has no # legitimate use for them. See :func:`_is_hermes_internal_secret`. for key in list(env): if key.startswith(_HERMES_PROVIDER_ENV_FORCE_PREFIX): env.pop(key, None) elif _is_hermes_internal_secret(key): env.pop(key, None) if not inherit_credentials: # Tier 2 — strip provider/tool credentials unless explicitly inherited. for key in _HERMES_PROVIDER_ENV_BLOCKLIST: env.pop(key, None) # Windows UTF-8 safety for spawned processes (#31420). env.setdefault("PYTHONUTF8", "1") _inject_context_hermes_home(env) from hermes_constants import apply_subprocess_home_env apply_subprocess_home_env(env) _strip_hermes_owned_pythonpath_and_runtime_markers(env) _apply_windows_msys_bash_env_defaults(env) # Cross-session leak guard, same as the terminal spawn paths: this helper # copies os.environ, whose HERMES_SESSION_* mirror is a last-writer-wins # global under a concurrent multi-session host. A caller that re-binds the # session identity explicitly (slash_worker/ACP via --session-key argv) is # unaffected — bound ContextVars win here — but a caller that spawns without # re-binding (e.g. tui_gateway cli.exec) would otherwise inherit a FOREIGN # session's identity. Strip _UNSET session vars when engaged so that can't # happen; single uniform policy across every spawn surface. _inject_session_context_env(env) # Non-terminal subprocess helpers (browser, lazy-deps, TUI/ACP hosts, etc.) # also need the delegate_task child lineage marker. Otherwise a child # context that later imports Kanban DB code in the spawned process would # still see the parent's HERMES_HOME but lose the DB mutation guard. env = _scrub_delegated_child_kanban_env(env) return 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 building a child-process environment. Every spawn site in the codebase should build its env through this function (or :func:`hermes_subprocess_env` for the model-driving-CLI surface) instead of copying ``os.environ`` directly, so profile-home propagation (``HERMES_HOME`` / subprocess ``HOME`` contract) and the Hermes secret-scrub policy have a single owner. History: ~11 separate commits each fixed one more spawn site that missed profile-HOME or secret-scrub propagation; this factory is the fix for the class. Parameters: * ``base`` — starting environment. ``None`` (default) snapshots ``os.environ``. Pass an explicit mapping to build on a caller-prepared env instead. * ``scrub_secrets=True`` (default) — delegate to :func:`_sanitize_subprocess_env`, the long-standing owner of the scrub list (provider blocklist + ``_is_hermes_internal_secret`` dynamic patterns + kanban/venv-marker/session-context guards) **and** of ``HERMES_HOME`` / subprocess-HOME propagation. On this path profile home propagation is inherent — ``inherit_profile_home`` is ignored (always applied), exactly matching today's sanitize semantics. * ``scrub_secrets=False`` — preserve the base env content byte-for-byte (no key is removed). Use for children that intentionally receive secrets (git credential flows, ``bws``/``op`` secret CLIs) or where scrubbing could change behavior. The site is still a win: it becomes grep-able and future-fixable. * ``inherit_profile_home`` — on the non-scrub path, when True, bridge the context-local Hermes home override into ``HERMES_HOME`` and apply the subprocess HOME contract (``hermes_constants.apply_subprocess_home_env``). Pass False to keep the inherited env untouched (exact legacy ``os.environ.copy()`` behavior). * ``extra`` — applied **last** on the non-scrub path so explicit caller overrides (e.g. a session-scoped ``HERMES_HOME``) always win. On the scrub path it is forwarded as ``_sanitize_subprocess_env``'s ``extra_env`` (same force-prefix / blocklist handling as today). """ if scrub_secrets: # _sanitize_subprocess_env already performs HERMES_HOME override # bridging + apply_subprocess_home_env unconditionally; delegating # wholesale keeps one owner and zero drift. 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: _inject_context_hermes_home(env) from hermes_constants import apply_subprocess_home_env apply_subprocess_home_env(env) if extra: env.update(extra) return env 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" ) candidates: list[str] = [] custom = os.environ.get("HERMES_GIT_BASH_PATH") if custom and os.path.isfile(custom): candidates.append(custom) # Prefer our own portable Git install — a broken or partially-uninstalled # system Git (or a stale HERMES_GIT_BASH_PATH pointing at one) must not # brick the terminal. install.ps1 drops PortableGit here when needed. # # Layouts (both checked so upgrades between MinGit and PortableGit # installs work transparently): # PortableGit: %LOCALAPPDATA%\hermes\git\bin\bash.exe (primary) # MinGit: %LOCALAPPDATA%\hermes\git\usr\bin\bash.exe (legacy/32-bit fallback) _local_appdata = os.environ.get("LOCALAPPDATA", "") _hermes_portable_git = os.path.join(_local_appdata, "hermes", "git") if _local_appdata else "" if _hermes_portable_git: for candidate in ( os.path.join(_hermes_portable_git, "bin", "bash.exe"), # PortableGit (primary) os.path.join(_hermes_portable_git, "usr", "bin", "bash.exe"), # MinGit fallback ): if os.path.isfile(candidate) and candidate not in candidates: candidates.append(candidate) # Check known Git for Windows install locations before PATH lookup. # On machines with both WSL and Git for Windows, shutil.which("bash") # may return WSL's bash (which doesn't understand Windows paths and # will fail silently). Explicit Git-for-Windows paths avoid that. for candidate in ( os.path.join(os.environ.get("ProgramFiles", r"C:\Program Files"), "Git", "bin", "bash.exe"), os.path.join(os.environ.get("ProgramFiles(x86)", r"C:\Program Files (x86)"), "Git", "bin", "bash.exe"), os.path.join(_local_appdata, "Programs", "Git", "bin", "bash.exe") if _local_appdata else "", ): if candidate and os.path.isfile(candidate) and candidate not in candidates: candidates.append(candidate) found = shutil.which("bash") if found and found not in candidates: candidates.append(found) # Prefer the first candidate that can actually start. A stale # HERMES_GIT_BASH_PATH pointing at a broken Git-for-Windows install # (``Directory \\drivers\\etc does not exist``) must not win over a # healthy portable Git under %LOCALAPPDATA%\\hermes\\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 candidate in candidates if (detail := _bash_probe_details_cache.get(candidate)) ) if _mandatory_aslr_enabled() is True or _looks_like_msys_spawn_failure( probe_details ): raise RuntimeError(_git_bash_aslr_help(candidates[0], probe_details)) # Last resort for failures unrelated to the known MSYS/ASLR class: # return the first path so the caller still sees the real bash error # instead of the less useful "not found" message. 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." ) _bash_starts_cache: dict[str, bool] = {} _bash_probe_details_cache: dict[str, str] = {} _mandatory_aslr_enabled_cache: "bool | None" = None _BASH_EXTERNAL_PROGRAM_PROBE = "/usr/bin/true; /usr/bin/cat --version >/dev/null" def _looks_like_msys_spawn_failure(details: str) -> bool: """Match Git-for-Windows child-launch failures associated with ASLR.""" lowered = details.lower() return any( marker in lowered for marker in ( "dofork:", "child_copy:", "0xc0000142", "0xc0000005", ) ) def _mandatory_aslr_enabled() -> "bool | None": """Return Windows' system-wide ForceRelocateImages state when available.""" global _mandatory_aslr_enabled_cache if _mandatory_aslr_enabled_cache is not None: return _mandatory_aslr_enabled_cache try: powershell = shutil.which("powershell.exe") or "powershell.exe" result = subprocess.run( [ powershell, "-NoProfile", "-NonInteractive", "-Command", "(Get-ProcessMitigation -System).Aslr.ForceRelocateImages.ToString()", ], capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=10, creationflags=windows_hide_flags(), ) if result.returncode != 0: return None value = (result.stdout or "").strip().upper() if value == "ON": _mandatory_aslr_enabled_cache = True return True if value in {"OFF", "NOTSET"}: _mandatory_aslr_enabled_cache = False return False except Exception as exc: logger.debug("Could not query Windows Mandatory ASLR state: %s", exc) return None def _git_root_from_bash(bash: str) -> str: """Resolve Git's root from either /bin or /usr/bin bash.""" bin_dir = ntpath.dirname(ntpath.normpath(bash)) if ntpath.basename(bin_dir).lower() != "bin": return ntpath.dirname(bin_dir) parent = ntpath.dirname(bin_dir) if ntpath.basename(parent).lower() == "usr": return ntpath.dirname(parent) return parent def _git_bash_aslr_help(bash: str, details: str = "") -> str: """Build the targeted per-program Mandatory-ASLR remediation.""" git_root = _git_root_from_bash(bash) escaped_root = git_root.replace("'", "''") detail_line = f"\nGit Bash probe output: {details[:500]}" if details else "" return ( f"Git Bash at {bash} cannot launch required MSYS child processes while " "Windows Mandatory ASLR (ForceRelocateImages) is enabled, or its output " f"matches that Git-for-Windows failure class.{detail_line}\n" "Reinstalling Git will not change the Windows mitigation policy. Open " "PowerShell as Administrator and run:\n" f"$gitRoot = '{escaped_root}'\n" 'Get-Item "$gitRoot\\bin\\bash.exe", "$gitRoot\\usr\\bin\\*.exe" ' "-ErrorAction SilentlyContinue | ForEach-Object { " "Set-ProcessMitigation -Name $_.FullName -Disable ForceRelocateImages }\n" "Then restart Hermes. If the override is blocked or later re-applied, " "ask your Windows administrator to allow this per-program exception." ) def _bash_starts(bash: str) -> bool: """True if *bash* can launch external MSYS programs. Uses ``--noprofile --norc`` so a broken login post-install (``Directory \\drivers\\etc``) does not falsely condemn an otherwise usable bash. The external ``true`` and ``cat`` calls are intentional: a builtin-only ``exit 0`` probe misses Git-for-Windows fork/spawn failures under system-wide Mandatory ASLR. Cached per path for the process lifetime. """ cached = _bash_starts_cache.get(bash) if cached is not None: return cached try: result = subprocess.run( [bash, "--noprofile", "--norc", "-c", _BASH_EXTERNAL_PROGRAM_PROBE], capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=15, creationflags=windows_hide_flags() if _IS_WINDOWS else 0, ) ok = result.returncode == 0 if not ok: combined = f"{result.stdout or ''}{result.stderr or ''}" _bash_probe_details_cache[bash] = combined.strip()[:2000] logger.debug("bash probe failed for %s: %s", bash, combined.strip()[:200]) except Exception as exc: _bash_probe_details_cache[bash] = str(exc)[:2000] logger.debug("bash probe error for %s: %s", bash, exc) ok = False _bash_starts_cache[bash] = ok return ok _git_bash_bin_dirs_cache: "list[str] | None" = None def _git_bash_bin_dirs() -> list[str]: """Git Bash's coreutils/binary dirs, in ``/etc/profile`` precedence order. A non-login ``bash -c`` (the fallback used when ``bash -l`` is broken — the classic Windows ``Directory \\drivers\\etc does not exist`` failure) never sources ``/etc/profile``, so it never gets ``…\\usr\\bin`` on PATH. That directory holds every coreutil the file/terminal tools shell out to (``cat``, ``mktemp``, ``mv``, ``wc``, ``head``, ``stat``, ``chmod``, ``mkdir``, ``find`` …). Without it, ``write_file`` fails with an empty error (the failure text went to a missing binary's stderr) and terminal commands exit 127. We derive these dirs from the resolved ``bash.exe`` so the fallback shell can find coreutils regardless of the login shell. Returns ``[]`` off Windows or when bash can't be located. Dirs are returned in the order Git Bash's own ``/etc/profile`` prepends them (mingw first, then usr/bin, then bin) and only if they exist on disk. """ global _git_bash_bin_dirs_cache if _git_bash_bin_dirs_cache is not None: return _git_bash_bin_dirs_cache if not _IS_WINDOWS: _git_bash_bin_dirs_cache = [] return _git_bash_bin_dirs_cache dirs: list[str] = [] try: bash = _find_bash() except Exception: _git_bash_bin_dirs_cache = [] return _git_bash_bin_dirs_cache bin_dir = os.path.dirname(bash) # \bin or \usr\bin parent = os.path.dirname(bin_dir) # MinGit ships bash under usr\bin; PortableGit/system Git under bin. root = os.path.dirname(parent) if os.path.basename(parent).lower() == "usr" else parent # Order mirrors Git-for-Windows /etc/profile so coreutils win over the # same-named Windows System32 tools (find.exe, sort.exe) inside the shell. for candidate in ( os.path.join(root, "mingw64", "bin"), os.path.join(root, "mingw32", "bin"), os.path.join(root, "usr", "local", "bin"), os.path.join(root, "usr", "bin"), os.path.join(root, "bin"), ): if os.path.isdir(candidate) and candidate not in dirs: dirs.append(candidate) _git_bash_bin_dirs_cache = dirs return dirs def _prepend_git_bash_dirs(existing_path: str) -> str: """Prepend Git Bash's binary dirs to ``existing_path`` if missing. No-op off Windows or when the dirs can't be resolved. First-occurrence wins, so a PATH that already lists a dir keeps its position. This is what lets the non-login ``bash -c`` fallback find coreutils; in the healthy case the session snapshot re-exports the full login PATH inside the shell, so this only matters when that snapshot is absent. """ git_dirs = _git_bash_bin_dirs() if not git_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 git_dirs if d not in entries] if not missing: return existing_path return sep.join([*missing, *entries]) # POSIX-sh-family shells that understand the ``[shell, "-lic", "set +m; …"]`` # invocation spawn_local uses. $SHELL values outside this set (fish, csh/tcsh, # nushell, elvish, xonsh, …) would error on that syntax, so _find_shell falls # back to bash for them rather than honouring $SHELL. (#42203) _SPAWN_COMPATIBLE_SHELLS = frozenset({"bash", "zsh", "sh", "dash", "ksh", "mksh"}) def _find_shell() -> str: """Find the user's login shell for background process spawning. Unlike ``_find_bash`` (which always returns a bash binary for callers that explicitly need bash), this function prefers the user's configured ``$SHELL`` on POSIX so that ``spawn_local`` uses the shell the user actually logs in with. On macOS Catalina+ the default login shell is zsh, but ``shutil.which("bash")`` still finds the system ``/bin/bash`` (GNU bash 3.2). When bash 3.2 is invoked with ``-l`` (login) and stdin is ``/dev/null``, it sources ``~/.bash_profile`` which on many macOS setups contains ``exec /bin/zsh -l``. That ``exec`` replaces bash with zsh but drops the ``-c`` argument, so the background command never runs — the subprocess exits 0 with no output and no side effects. Preferring ``$SHELL`` (when it is a POSIX-``sh``-family shell) avoids this because zsh/bash/sh/dash/ksh handle ``-lic`` correctly even with redirected stdin. Only POSIX-sh-family shells are honoured: ``spawn_local`` invokes the shell as ``[shell, "-lic", "set +m; "]``, and that ``-lic`` bundle + ``set +m`` job-control syntax is NOT understood by fish, csh/tcsh, nushell, elvish, xonsh, etc. Returning such a ``$SHELL`` would trade the bash-3.2 swallow for a parse error on every background command, so for any non-allowlisted shell we fall back to ``_find_bash`` (the prior behaviour). On Windows, ``$SHELL`` is typically bash (Git Bash), so behaviour is unchanged — we fall through to ``_find_bash``. """ 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() # 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: """Return the directory holding the ``hermes`` console-script, or None. The terminal tool runs in a freshly-spawned subshell whose PATH is the agent process's PATH plus a static set of system dirs (``_SANE_PATH``). When the gateway is launched by something that does NOT source the user's shell rc — systemd, a service manager, a desktop launcher, cron — the hermes install dir (``~/.local/bin``, the venv ``bin``/``Scripts``, pipx, nix) is absent from that PATH, so plugins shelling out to bare ``hermes`` via the terminal tool hit ``command not found`` (exit 127) even though ``hermes`` works fine in the user's own interactive terminal. We resolve the install dir once (it never changes within a process) and prepend-if-missing it to the subshell PATH so bare ``hermes`` resolves regardless of how the gateway was started. Resolution order (cheap, no heavy imports): 1. ``shutil.which("hermes")`` — normal PATH-installed shim. 2. The directory of ``sys.argv[0]`` when it's an absolute path to a real ``hermes`` executable (covers nix-store / venv wrappers). 3. The directory of ``sys.executable`` — the running interpreter's venv ``bin``/``Scripts`` is where its console-scripts live. """ 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 "" if exe_dir: shim = "hermes.exe" if _IS_WINDOWS else "hermes" if 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. Cross-platform (uses ``os.pathsep``). First-occurrence wins, so a PATH that already contains the dir is returned unchanged. Returns the input unchanged when the install dir can't be resolved. """ bin_dir = _resolve_hermes_bin_dir() if not bin_dir: return existing_path sep = os.pathsep entries = [e for e in existing_path.split(sep) if e] if existing_path else [] if bin_dir in entries: return existing_path return sep.join([bin_dir, *entries]) def _managed_runtime_path_entries() -> list[str]: """Return existing Hermes-managed runtime dirs for the terminal subshell PATH. The terminal tool spawns a subshell whose PATH is the agent process's PATH plus ``_SANE_PATH``. Neither carries the runtimes Hermes installs for itself, so on a machine where Hermes provisioned its own toolchain a command the agent runs resolves a system copy instead — or nothing at all: - ``$HERMES_HOME/node`` (+ ``/bin``) — installed to satisfy the desktop and browser toolchain. ``tools/browser_tool.py`` already does this for its own subprocesses; the agent's shell deserves the same. - ``$HERMES_HOME/bin`` — the managed ``uv``. ``install.sh`` writes it there and nothing has ever put that directory on PATH, so an install whose only uv is the managed one looks uv-less to both the agent and the model. Resolved per call rather than cached in a module constant because ``get_hermes_home()`` is profile-scoped and a managed tree can appear mid-process (``heal_hermes_managed_node``, a first browser install). """ 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: """Return a normalised POSIX PATH with missing sane entries appended. On POSIX the caller-supplied PATH is rewritten (not merely appended to): empty entries and duplicate entries are dropped, preserving first-occurrence order, then each missing ``_SANE_PATH`` entry is appended once at the end so existing entries keep their precedence. Two intentional normalisations beyond the bare "add Homebrew dirs" fix: - **Empty entries are stripped.** A leading/trailing/double ``:`` encodes an empty PATH element, which POSIX shells interpret as the current working directory — a mild foot-gun in a default terminal environment. We drop these rather than carry them through. - **Duplicates are collapsed** (first occurrence wins), so a caller PATH that already contains repeats is not propagated verbatim. Hermes-managed runtime dirs are appended alongside the sane entries, not prepended: a tool the user deliberately put on their own PATH still wins, and the managed one only fills the gap where there would otherwise be nothing. For a well-formed PATH (no empties, no duplicates) the leading segment is byte-identical to the input and ordering is preserved; only the missing sane entries are appended. On Windows this is a no-op passthrough (the separator is ``;`` and the native PATH must not be touched). """ 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) # De-duplicate the caller PATH (first occurrence wins) and drop empty # entries before merging in the sane fallbacks. seen: set[str] = set() ordered_entries: list[str] = [] for entry in existing_path.split(":"): if not entry or entry in seen: continue seen.add(entry) ordered_entries.append(entry) # _SANE_PATH is a static, duplicate-free constant, so a membership check # against the caller entries is sufficient — no need to track `seen` here. for entry in sane_entries: if entry not in seen: ordered_entries.append(entry) return ":".join(ordered_entries) def _apply_windows_msys_bash_env_defaults(env: dict) -> None: """Disable MSYS argument path conversion for Git Bash subprocesses. Git Bash rewrites arguments that look like Unix paths (``/FO``, ``/TN``, ``/Create``) into ``C:/.../git/FO``-style paths, which breaks native Windows commands such as ``tasklist``, ``schtasks``, and ``wmic``. Hermes runs terminal commands through bash on Windows, so set the standard MSYS opt-out by default. Users who need conversion can override in their env. Refs #56700. ``MSYS_NO_PATHCONV`` is honored by Git for Windows bash only. MSYS2-proper and Cygwin bash (which ``_find_bash`` can still return via the final ``shutil.which`` fallback) ignore it and honor ``MSYS2_ARG_CONV_EXCL`` instead, so set both. ``*`` disables all argv conversion — the semantic equivalent of ``MSYS_NO_PATHCONV=1``. Also fixes ``cmd /c`` mangling (#56147). """ 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. Note: this is deliberately a *second* Windows guard, distinct from the early-return in ``_append_missing_sane_path_entries``. Its job is to pick the correctly-cased key (``Path`` vs ``PATH``) so completion writes back to the key the caller already used; the helper's guard makes that helper safe to call standalone (it is, e.g. in the Windows unit tests). Both are intentional. """ 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.""" try: from tools.env_passthrough import ( is_env_passthrough as _is_passthrough, resolve_passthrough_value as _resolve_passthrough_value, ) except Exception: _is_passthrough = lambda _: False # noqa: E731 _resolve_passthrough_value = lambda _name, fallback: fallback # noqa: E731 merged = dict(os.environ | env) run_env = {} for k, v in merged.items(): if k.startswith(_HERMES_PROVIDER_ENV_FORCE_PREFIX): real_key = k[len(_HERMES_PROVIDER_ENV_FORCE_PREFIX):] if _is_hermes_internal_secret(real_key): continue run_env[real_key] = v elif _is_hermes_internal_secret(k): continue else: first_party = _is_terminal_first_party_env(k) passthrough = _is_passthrough(k) if k in _HERMES_PROVIDER_ENV_BLOCKLIST and not (passthrough or first_party): continue # First-party vars use the merged env value directly (see # _sanitize_subprocess_env); only passthrough names resolve. value = v if passthrough and not first_party: value = _resolve_passthrough_value(k, v) if value is not None: run_env[k] = value 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, "")) # On Windows, ensure Git Bash's coreutils dirs (…\usr\bin etc.) are on # PATH. A non-login ``bash -c`` fallback (used when ``bash -l`` is # broken) never sources /etc/profile, so without this cat/mktemp/mv and # friends are missing and every write_file/terminal call fails (empty # error / exit 127). No-op off Windows and when a login snapshot is # healthy (the snapshot re-exports the full PATH inside the shell). new_path = _prepend_git_bash_dirs(new_path) # Ensure the hermes install dir is reachable so plugins can shell out # to bare ``hermes`` via the terminal tool even when the gateway was # launched without it on PATH (systemd, service managers, cron, etc.). run_env[path_key] = _prepend_hermes_bin_dir(new_path) _inject_context_hermes_home(run_env) from hermes_constants import apply_subprocess_home_env apply_subprocess_home_env(run_env) # Bridge ContextVar-based session vars into the subprocess env (with the # cross-session leak guard — strips _UNSET vars when a concurrent host is # engaged so a sibling session's os.environ mirror can't leak in). _inject_session_context_env(run_env) _strip_hermes_owned_pythonpath_and_runtime_markers(run_env) _apply_windows_msys_bash_env_defaults(run_env) run_env = _scrub_delegated_child_kanban_env(run_env) return run_env def _same_path(left: Path, right: Path) -> bool: """Compare path spellings with host filesystem case semantics.""" left_parts = [os.path.normcase(part) for part in left.parts] right_parts = [os.path.normcase(part) for part in right.parts] return left_parts == right_parts def _build_hermes_repo_root_aliases( resolved_root: Path, lexical_root: Path, configured_home: Path, ) -> tuple[Path, ...]: """Return exact repo-root spellings emitted by Hermes launchers. ``gateway_windows._preserve_hermes_home_path`` maps a physical path under the resolved HERMES_HOME back onto the configured HERMES_HOME spelling. Mirror that producer contract here so a junction-backed install is matched without treating arbitrary descendants of HERMES_HOME as Hermes-owned. Additionally, when the repo itself is a junction under the configured root (repo-level junction, possibly cross-drive), the single deterministic candidate / is accepted only when strict resolve proves it is the exact physical repo root. """ aliases: list[Path] = [] def add(candidate: Path) -> None: if not any(_same_path(candidate, existing) for existing in aliases): aliases.append(candidate) add(resolved_root) add(lexical_root) # Profile re-home: with --profile / sticky active_profile the configured # home becomes /profiles/. The repo root then lives beside # the profiles directory (not under the profile home), so the home- # relative mapping below cannot reach it. Derive the root spelling # lexically the same way get_default_hermes_root() does (parent of a # "profiles" component) and run the same exact-ownership mapping against # it -- this recovers the launcher's lexical root under profile re-home # while still never matching arbitrary descendants of HERMES_HOME. home_candidates = [configured_home] if configured_home.parent.name == "profiles": home_candidates.append(configured_home.parent.parent) for home in home_candidates: try: resolved_home = home.resolve() home_key = os.path.normcase(str(resolved_home)) root_key = os.path.normcase(str(resolved_root)) if os.path.commonpath([home_key, root_key]) == home_key: relative_root = os.path.relpath(str(resolved_root), str(resolved_home)) add(home / relative_root) except (OSError, ValueError): pass # Repo-level junction recovery: the repository itself may be a # junction/symlink under the configured root (e.g. D:\hermes\hermes-agent # -> C:\...\hermes-agent) while the import spelling (editable install) # resolves to the physical location. The home-relative mapping above # cannot express a cross-drive link (commonpath raises on different # drives), so prove the EXACT filesystem identity of the single # deterministic candidate -- / -- with a # strict resolve before accepting it as Hermes-owned. Fail-closed: a # missing path (strict resolve raises), a real directory that is not the # known physical root, or any unrelated spelling never becomes an alias. for home in home_candidates: repo_candidate = home / resolved_root.name try: if repo_candidate.resolve(strict=True) == resolved_root.resolve(strict=True): add(repo_candidate) except OSError: pass return tuple(aliases) # --- Hermes venv / repo-root detection (module-level, computed once) --- #: The Hermes repository root - three levels up from this file #: (``tools/environments/local.py`` -> ``tools/environments`` -> ``tools`` #: -> repo root). This is the directory the Electron app prepends to #: PYTHONPATH so the backend can do ``import tools``, ``import hermes_cli``, #: etc. Subprocesses that are NOT the Hermes backend don't need it and it #: can shadow local packages. _hermes_repo_root: Path = Path(__file__).resolve().parents[2] #: Alternate spellings of the repo root that Hermes launchers may emit. #: ``Path(__file__).resolve()`` canonicalizes symlinks/junctions, but the #: Windows gateway launcher deliberately renders Hermes-owned paths under #: the configured HERMES_HOME spelling (which may be a junction to another #: drive — see ``hermes_cli/gateway_windows.py::_preserve_hermes_home_path``). #: ``Path(__file__)`` (unresolved) keeps that spelling, so a PYTHONPATH #: entry written by the launcher still matches even though it differs #: lexically from the resolved root. _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 current interpreter is running inside a venv. On Python 3.3+ #: ``sys.base_prefix != sys.prefix`` indicates a venv (or virtualenv). #: ``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") ) #: Cached set of site-packages directories that belong to the running #: interpreter's own venv. Computed lazily (once) because ``site`` import #: and path construction are not free and this function is called on every #: subprocess spawn. _hermes_site_packages: list[Path] | None = None def _validated_runtime_venv(env: dict) -> Path | None: """Return a producer-owned runtime venv identified by VIRTUAL_ENV. A user may carry an unrelated VIRTUAL_ENV, so the variable alone is not provenance. The legacy Windows base-Python gateway producer uses the exact ``/venv`` layout and a real venv marker; require both before accepting its separate runtime venv. """ value = env.get("VIRTUAL_ENV") if not value: return None candidate = Path(value) if not any(_same_path(candidate, repo_root / "venv") for repo_root in _hermes_repo_root_aliases): return None try: if not (candidate / "pyvenv.cfg").is_file(): return None except OSError: return None return candidate def _get_hermes_site_packages(env: dict) -> list[Path]: """Return exact site-packages dirs owned by the Hermes runtime. Uses ``site.getsitepackages()`` when available for robustness (it respects ``.pth`` rewrites and platform conventions), with a manual fallback that constructs the canonical path from ``sys.prefix`` for POSIX and Windows. A validated Windows base-interpreter launch contributes its separate ``VIRTUAL_ENV/Lib/site-packages`` directory as an additional exact entry. """ global _hermes_site_packages if _hermes_site_packages is not None: result = list(_hermes_site_packages) else: result = [] if _in_venv: try: import site for sp in site.getsitepackages(): result.append(Path(sp)) except Exception: pass # Fallback: construct manually. On POSIX: # sys.prefix / lib / python{X.Y} / site-packages # On Windows: # sys.prefix / Lib / site-packages if not result: if _IS_WINDOWS: result.append(Path(sys.prefix) / "Lib" / "site-packages") else: pyver = f"python{sys.version_info[0]}.{sys.version_info[1]}" result.append(Path(sys.prefix) / "lib" / pyver / "site-packages") _hermes_site_packages = list(result) runtime_venv = _validated_runtime_venv(env) if runtime_venv is not None: runtime_site_packages = runtime_venv / "Lib" / "site-packages" if not any(_same_path(runtime_site_packages, existing) for existing in result): result.append(runtime_site_packages) return result def _strip_hermes_owned_pythonpath_and_runtime_markers(env: dict) -> None: """Strip Hermes-owned PYTHONPATH entries, then the runtime marker vars. Ordering is load-bearing: PYTHONPATH filtering must run BEFORE the markers are removed so a validated Windows base-interpreter launch (VIRTUAL_ENV -> /venv) can still prove ownership. """ _strip_hermes_owned_pythonpath(env) for _marker in _ACTIVE_VENV_MARKER_VARS: env.pop(_marker, None) def _strip_hermes_owned_pythonpath(env: dict) -> None: """Remove Hermes-owned PYTHONPATH entries from subprocess environments. Launchers prepend the Hermes repo root and the Hermes venv's site-packages so the backend can ``import tools``; leaking those into a child Python of a DIFFERENT version makes it load the backend's C extensions and crash (``numpy._core._multiarray_umath``, ``PIL._imaging``, ``cryptography``). Blanket-removing PYTHONPATH would discard legitimate user entries, so only entries proven Hermes-owned are removed: 1. The exact repo root (never direct children -- no launcher injects one, and user paths under the repo must survive). 2. The exact runtime site-packages dirs (running interpreter's venv or a validated Windows base-Python runtime venv; descendants are user paths). Everything else -- user libs, Nix plugin paths, a pythonX.Y/site-packages entry meant for a DIFFERENT child version -- is preserved byte-for-byte: ownership is decided by path provenance, never by a cross-version heuristic (#74817 follow-up). """ pp = env.get("PYTHONPATH") if not pp: return hermes_site_packages = _get_hermes_site_packages(env) kept: list[str] = [] stripped: list[str] = [] for entry in pp.split(os.pathsep): # Empty and non-normalized components are user-owned semantics. In # particular, an empty component means the current working directory. # Preserve raw spelling unless the exact component is Hermes-owned. if entry == "": kept.append(entry) continue entry_path = Path(entry) should_strip = False # --- Check 1: Hermes venv site-packages --- # Producers inject the exact directory, never a descendant. Exact # matching avoids deleting a user path nested below site-packages. for sp in hermes_site_packages: if _same_path(entry_path, sp): should_strip = True break if should_strip: stripped.append(entry) continue # --- Check 2: Hermes repo root --- # The Electron app prepends the repo root so ``import tools`` works # in the backend. Subprocesses don't need it and it can shadow # local packages of the same name. Only the EXACT root is stripped: # no launcher injects a direct child (``/tools`` etc.) as an # independent PYTHONPATH entry, and user paths that merely happen to # live under the repo directory must be preserved. Both the # resolved and unresolved (HERMES_HOME/junction) spellings count as # Hermes-owned. if not should_strip: should_strip = any( _same_path(entry_path, repo_root) for repo_root in _hermes_repo_root_aliases ) if should_strip: stripped.append(entry) else: kept.append(entry) if kept: env["PYTHONPATH"] = os.pathsep.join(kept) else: env.pop("PYTHONPATH", None) if stripped: logger.debug( "Stripped Hermes-owned entries from PYTHONPATH: %s", stripped, ) def _read_terminal_shell_init_config() -> tuple[list[str], bool]: """Return (shell_init_files, auto_source_bashrc) from config.yaml. Best-effort — returns sensible defaults on any failure so terminal execution never breaks because the config file is unreadable. """ 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]: """Resolve the list of files to source before the login-shell snapshot. Expands ``~`` and ``${VAR}`` references and drops anything that doesn't exist on disk, so a missing ``~/.bashrc`` never breaks the snapshot. The ``auto_source_bashrc`` path runs only when the user hasn't supplied an explicit list — once they have, Hermes trusts them. """ explicit, auto_bashrc = _read_terminal_shell_init_config() candidates: list[str] = [] if explicit: candidates.extend(explicit) elif auto_bashrc and not _IS_WINDOWS: # Build a login-shell-ish source list so tools like n / nvm / asdf / # pyenv that self-install into the user's shell rc land on PATH in # the captured snapshot. # # ~/.profile and ~/.bash_profile run first because they have no # interactivity guard — installers like ``n`` and ``nvm`` append # their PATH export there on most distros, and a non-interactive # ``. ~/.profile`` picks that up. # # ~/.bashrc runs last. On Debian/Ubuntu the default bashrc starts # with ``case $- in *i*) ;; *) return;; esac`` and exits early # when sourced non-interactively, which is why sourcing bashrc # alone misses nvm/n PATH additions placed below that guard. We # still include it so users who put PATH logic in bashrc (and # stripped the guard, or never had one) keep working. 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 ``source `` lines (guarded + silent) to a bash script. Each file is wrapped so a failing rc file doesn't abort the whole bootstrap: ``set +e`` keeps going on errors, ``2>/dev/null`` hides noisy prompts, and ``|| true`` neutralises the exit status. """ if not files: return cmd_string prelude_parts = ["set +e"] for path in files: # shlex.quote isn't available here without an import; the files list # comes from os.path.expanduser output so it's a concrete absolute # path. Escape single quotes defensively anyway. safe = path.replace("'", "'\\''") prelude_parts.append(f"[ -r '{safe}' ] && . '{safe}' 2>/dev/null || true") prelude = "\n".join(prelude_parts) + "\n" return prelude + cmd_string 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, ...]: """Return first-party terminal env names (``BUZZ_*``) present in the current env, so they are excluded from the shared session snapshot. The login-shell snapshot (``init_session`` ``export -p`` dump and the per-command re-dump) captures the child env, which now includes the ``BUZZ_*`` vars the terminal carve-out passes through. The exclusion set is derived from ``get_all_passthrough()`` plus backend-specific additions — and ``BUZZ_*`` can NEVER be in it, because env_passthrough refuses blocklisted names (GHSA-rhgp-j443-p4rf). Under a multiplexed gateway, profile A's BUZZ_PRIVATE_KEY would land in ``hermes-snap-.sh`` and a later command from profile B sharing this collapsed LocalEnvironment would ``source`` it: a cross-profile nsec leak that defeats profile isolation. Treating these names like profile-scoped passthrough names keeps them out of the dump and save/restores the current profile's value (or unsets the name) per command in ``_wrap_command``. The set is monotonic for the environment lifetime: once a name is seen it stays excluded, so a later profile that lacks the var still gets the unset-guard. """ merged = dict(os.environ | self.env) return tuple( sorted( name for name in merged # Prefix-only on purpose: the snapshot exclusion stays # conservative even when the context-gated carve-out is # inactive (the var then never reaches the child env anyway, # but a monotonic exclusion is a cheap extra guard). 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: """Return a shell-safe writable temp dir for local execution. Termux does not provide /tmp by default, but exposes a POSIX TMPDIR. Prefer POSIX-style env vars when available, keep using /tmp on regular Unix systems, and only fall back to tempfile.gettempdir() when it also resolves to a POSIX path. Check the environment configured for this backend first so callers can override the temp root explicitly (for example via terminal.temp_dir, terminal.env, or a custom TMPDIR), then fall back to the host process environment. **Default (no override set):** a dedicated cache dir under ``HERMES_HOME`` (``~/.hermes/cache/terminal``) rather than ``/tmp``. On several distros (Arch and friends) ``/tmp`` is a small RAM-backed tmpfs, and Hermes session artifacts — background-process logs, code-execution sandboxes, spilled tool results — can fill it under load. Real storage is the safer default; stale artifacts are pruned by ``cleanup_terminal_temp_cache`` (gateway housekeeping + a once-per-process best-effort sweep) since we no longer get tmpfs reboot wipes for free. **Windows:** hardcoded ``/tmp`` is wrong in two ways — native Python can't open the path, and the Windows default temp (``%TEMP%``) often contains spaces (``C:\\Users\\Some Name\\AppData\\Local\\Temp``) that break unquoted bash interpolations. Use a dedicated cache dir under ``HERMES_HOME`` instead — single-word path, guaranteed to exist, same string resolves in both Git Bash and native Python. """ if _IS_WINDOWS: # Derive a Windows-safe temp dir under HERMES_HOME. Using # forward slashes makes the same string work unchanged in bash # command interpolations AND in Python ``open()`` — Windows # accepts forward slashes in filesystem paths, and we control # the path so we can guarantee no spaces. 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() # Force forward slashes so the same string serves both contexts. return str(cache_dir).replace("\\", "/") # Explicit temp-dir override from terminal.temp_dir (TERMINAL_TEMP_DIR). # Honored ahead of the generic TMPDIR so users can redirect Hermes' temp # root to real storage when /tmp is a small tmpfs. 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 configured.rstrip("/") or "/" 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 candidate.rstrip("/") or "/" # Default: HERMES_HOME/cache/terminal — real storage, mirroring the # Windows branch above. /tmp is only a last-resort fallback now # because RAM-backed tmpfs /tmp fills up under Hermes load. 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 resolved.rstrip("/") or "/" except Exception: pass if os.path.isdir("/tmp") and os.access("/tmp", os.W_OK | os.X_OK): return "/tmp" candidate = tempfile.gettempdir() if candidate.startswith("/"): return candidate.rstrip("/") or "/" return "/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 _run_bash(self, cmd_string: str, *, login: bool = False, timeout: int = 120, stdin_data: str | None = None) -> subprocess.Popen: bash = _find_bash() # For login-shell invocations (used by init_session to build the # environment snapshot), prepend sources for the user's bashrc / # custom init files so tools registered outside bash_profile # (nvm, asdf, pyenv, …) end up on PATH in the captured snapshot. # Non-login invocations are already sourcing the snapshot and # don't need this. 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) # Recover when the cwd has been deleted out from under us — usually by # a previous tool call that ran ``rm -rf`` on its own working dir # (issue #17558). Popen would otherwise raise FileNotFoundError on # the cwd before bash starts, wedging every subsequent call until the # gateway restarts. # # On Windows, ``_resolve_safe_cwd`` also normalises Git Bash-style # POSIX paths (``/c/Users/...``) to native form so a perfectly valid # ``pwd -P`` result from bash isn't mistakenly treated as "missing" # and spammed as a warning on every command. safe_cwd = _resolve_safe_cwd(self.cwd) if safe_cwd != self.cwd: # MSYS → Windows translation alone shouldn't surface as a warning # (it's a benign normalization, not a recovery). Only warn when # the directory really doesn't exist on disk. 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 _popen_cwd = self.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=_popen_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).""" def _group_alive(pgid: int) -> bool: try: # POSIX-only: _IS_WINDOWS is handled before this helper is used. os.killpg(pgid, 0) # windows-footgun: ok — POSIX process-group alive probe return True except ProcessLookupError: return False except PermissionError: # The group exists, even if this process cannot signal it. return True def _wait_for_group_exit(pgid: int, timeout: float) -> bool: deadline = time.monotonic() + timeout while time.monotonic() < deadline: # Reap the wrapper promptly. A dead but unreaped group leader # still makes killpg(pgid, 0) report the group as alive. 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) try: if _IS_WINDOWS: 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 else: try: pgid = os.getpgid(proc.pid) except ProcessLookupError: pgid = getattr(proc, "_hermes_pgid", None) if pgid is None: raise # Snapshot the descendant set BEFORE the first signal: once # the wrapper dies its children reparent to init and a parent # walk finds nothing (same rationale as agent/deadline.py # kill_process_tree). A descendant that called ``setsid`` # escapes the process group entirely and would survive the # group-kill below — the #71148 class, terminal flavor # (issue #84967's local sibling). The snapshot must never # break the kill path, so any failure just yields an empty # sweep set. descendants: list = [] try: import psutil descendants = psutil.Process(proc.pid).children(recursive=True) except Exception: descendants = [] def _sweep_escaped_descendants() -> None: """SIGKILL snapshotted survivors outside the (dead) group. Runs after the TERM→KILL group escalation so in-group members keep their SIGTERM grace window; only escapees (own setsid sessions) are force-killed. psutil's identity-aware Process means recycled PIDs are skipped. POSIX-only: reached solely from the non-_IS_WINDOWS branch above (the win32 path returns earlier). """ 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 try: os.killpg(pgid, signal.SIGTERM) # windows-footgun: ok — POSIX process-group SIGTERM (guarded by _IS_WINDOWS above) except ProcessLookupError: _sweep_escaped_descendants() return # Wait on the process group, not just the shell wrapper. Under # load the wrapper can exit before grandchildren do; returning # at that point leaves orphaned process-group members behind. if _wait_for_group_exit(pgid, 1.0): _sweep_escaped_descendants() return try: # POSIX-only: _IS_WINDOWS is handled by the outer branch. os.killpg(pgid, signal.SIGKILL) # windows-footgun: ok — POSIX process-group SIGKILL except ProcessLookupError: _sweep_escaped_descendants() return _wait_for_group_exit(pgid, 2.0) try: proc.wait(timeout=0.2) except (subprocess.TimeoutExpired, OSError): pass _sweep_escaped_descendants() except (ProcessLookupError, PermissionError, OSError): try: proc.kill() except Exception: pass def _update_cwd(self, result: dict): """Update cwd from the stdout marker emitted by the wrapped command. The base command wrapper already appends ``pwd -P`` to stdout inside a session-specific marker, so the local backend can share the same parser as remote backends instead of re-reading the temp file it just wrote. ``_extract_cwd_from_output`` keeps the local Windows normalization and stale-path rollback semantics intact. """ self._extract_cwd_from_output(result) def _extract_cwd_from_output(self, result: dict): """Same semantics as the base class, but on Windows the value emitted by ``pwd -P`` inside Git Bash is in MSYS form (``/c/Users/x``). Normalize to native Windows form and validate the directory exists before assigning to ``self.cwd`` — otherwise ``_run_bash``'s safe-cwd recovery would warn on every subsequent command. Always defers to the base class for stripping the marker text from ``result["output"]`` so output formatting is identical. """ # Snapshot pre-existing cwd, defer to base for parsing + marker # stripping, then validate / normalize whatever it assigned. 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: # Stale / non-existent path — keep previous cwd; _run_bash # will resolve a safe fallback on the next call if needed. # The rollback restores a value this command did not observe, # so it is not attributable to this command's session either. self.cwd = prev_cwd result.pop("cwd_observed", None) result.pop("cwd", None) def cleanup(self): """Clean up temp files.""" for f in (self._snapshot_path, self._cwd_file): try: os.unlink(f) except OSError: pass # Remove any orphaned atomic-write temp snapshots (snap.tmp.) # a failed/interrupted mv could have left behind (#38249). try: import glob for tmp in glob.glob(f"{self._snapshot_path}.tmp.*"): try: os.unlink(tmp) except OSError: pass except Exception: pass