Files

2357 lines
100 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
_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_<id>.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_<id> 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 "/<single letter>/" or exactly "/<letter>" (bare drive root),
# plus /cygdrive/<letter>/... and /mnt/<letter>/... 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_<TASK>_API_KEY`` / ``AUXILIARY_<TASK>_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_<TASK>_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 <root>/bin or <root>/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) # <root>\bin or <root>\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; <cmd>"]``, 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 <root>/<repo dirname> 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 <root>/profiles/<name>. 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 -- <lexical root>/<repo dirname> -- 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
``<Hermes repo>/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 -> <repo>/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 (``<repo>/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 <file>`` 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-<id>.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.<bashpid>)
# 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