The agent's terminal puts a sealed payload's launcher dir first on PATH even when the inherited PATH already lists it behind another install. The macOS removal instructions name the running .app bundle and its folder instead of a fixed Hermes.app. A manifest.json with a mistyped runtime, commands, venv or store reads as not a payload, and a missing venv or store is treated as unset, so startup no longer raises.
1140 lines
57 KiB
Python
1140 lines
57 KiB
Python
"""Local execution environment — spawn-per-call with session snapshot."""
|
|
|
|
import contextlib
|
|
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
|
|
from tools.environments.base_output import _pipe_stdin
|
|
from hermes_cli._subprocess_compat import windows_hide_flags
|
|
from tools.environments.local_env_policy import ( # noqa: F401 — _HERMES_PROVIDER_ENV_BLOCKLIST stays importable from here
|
|
_ALWAYS_STRIP_FOLDED, _ALWAYS_STRIP_KEYS, _HERMES_PROVIDER_ENV_BLOCKLIST, _HERMES_PROVIDER_ENV_FORCE_PREFIX,
|
|
_is_hermes_internal_secret, _is_provider_env_blocklisted, _is_terminal_first_party_env,
|
|
_home_adapter_secret_env, _matches_terminal_first_party_prefix, _plugin_terminal_env_strip_keys,
|
|
_registered_adapter_secret_env, _registry_adapter_secret_env,
|
|
strip_profile_gate_env)
|
|
from tools.environments.local_pythonpath import (
|
|
_build_hermes_repo_root_aliases, _strip_hermes_owned_pythonpath_and_runtime_markers)
|
|
|
|
|
|
_IS_WINDOWS = platform.system() == "Windows"
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# --- Terminal temp-cache pruning ---
|
|
# get_temp_dir() defaults to HERMES_HOME/cache/terminal (real storage, not tmpfs), so
|
|
# stale artifacts don't vanish on reboot: the gateway housekeeping loop prunes hourly
|
|
# and a once-per-process sweep covers CLI-only installs. Retention is idle-based like
|
|
# the scratch dir: an entry goes 24h after the last write anywhere inside it.
|
|
TERMINAL_TEMP_MAX_IDLE_HOURS = 24
|
|
_terminal_temp_prune_lock = threading.Lock()
|
|
_terminal_temp_pruned_once = False
|
|
# Background artifacts come in triplets (hermes_bg_<id>.log/.pid/.exit). A live
|
|
# server's .pid never changes mtime while its .log does, so age is judged per
|
|
# GROUP (newest mtime sharing a stem) to keep pid/exit files of live sessions.
|
|
_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: float = TERMINAL_TEMP_MAX_IDLE_HOURS) -> int:
|
|
"""Delete session temp artifacts idle for *max_age_hours* (no write anywhere in a
|
|
directory's subtree; the kwarg name is the ``cleanup_*_cache`` signature the gateway
|
|
housekeeping loop calls every entry with); return count.
|
|
Only the managed default dir is pruned — never a user-pointed ``terminal.temp_dir``."""
|
|
from hermes_constants_scratch import subtree_touched_since
|
|
|
|
root = _default_terminal_temp_dir()
|
|
if root is None:
|
|
return 0
|
|
cutoff = time.time() - (max_age_hours * 3600)
|
|
try:
|
|
entries = list(root.iterdir())
|
|
except OSError:
|
|
return 0
|
|
|
|
mtimes: dict[Path, float] = {}
|
|
group_newest: dict[str, float] = {}
|
|
for f in entries:
|
|
try:
|
|
mtimes[f] = mt = f.stat().st_mtime
|
|
except OSError:
|
|
continue
|
|
if m := _BG_GROUP_RE.match(f.name):
|
|
group_newest[m.group(1)] = max(group_newest.get(m.group(1), 0.0), mt)
|
|
|
|
removed = 0
|
|
for f, mt in mtimes.items():
|
|
m = _BG_GROUP_RE.match(f.name)
|
|
if m:
|
|
if group_newest[m.group(1)] >= cutoff:
|
|
continue
|
|
elif subtree_touched_since(f, cutoff):
|
|
continue
|
|
try:
|
|
shutil.rmtree(f, ignore_errors=True) if f.is_dir() else f.unlink()
|
|
removed += 1
|
|
except OSError:
|
|
continue
|
|
return removed
|
|
|
|
|
|
def _prune_terminal_temp_once() -> None:
|
|
"""Best-effort prune, at most once per process (CLI-only installs)."""
|
|
global _terminal_temp_pruned_once
|
|
with _terminal_temp_prune_lock:
|
|
if _terminal_temp_pruned_once:
|
|
return
|
|
_terminal_temp_pruned_once = True
|
|
try:
|
|
cleanup_terminal_temp_cache()
|
|
except Exception as exc:
|
|
logger.debug("Terminal temp prune failed: %s", exc)
|
|
|
|
|
|
# --- Windows / MSYS path translation ---
|
|
def _msys_to_windows_path(cwd: str) -> str:
|
|
"""``/c/Users/x`` / ``/cygdrive/c/..`` / ``/mnt/c/..`` -> native ``C:\\Users\\x`` so
|
|
``isdir``/``Popen(cwd=)`` find it. No-op off Windows, for empty input and for
|
|
multi-segment POSIX paths like ``/home/x``; idempotent on native paths."""
|
|
m = _IS_WINDOWS and cwd and re.match(r'^/(?:(?:cygdrive|mnt)/)?([a-zA-Z])(/.*)?$', cwd)
|
|
if not m:
|
|
return cwd
|
|
tail = (m.group(2) or "").replace('/', '\\')
|
|
return f"{m.group(1).upper()}:{tail or chr(92)}" # chr(92) = backslash
|
|
|
|
|
|
def _resolve_local_initial_cwd(cwd: str) -> str:
|
|
"""Resolve the initial cwd to an absolute host path. A relative ``TERMINAL_CWD``
|
|
naming the launch directory would otherwise make the wrapper ``cd`` *inside*
|
|
the project; anchor it once so ``Popen(cwd=)`` and the in-shell ``cd`` agree."""
|
|
expanded = os.path.expanduser(cwd) if cwd else os.getcwd()
|
|
if _IS_WINDOWS:
|
|
expanded = _msys_to_windows_path(expanded)
|
|
# ntpath explicitly: with _IS_WINDOWS patched on a POSIX host,
|
|
# os.path.isabs would reject ``C:\Users\x`` and mangle it below.
|
|
if ntpath.isabs(expanded):
|
|
return expanded
|
|
if os.path.isabs(expanded):
|
|
return expanded
|
|
candidate = os.path.abspath(expanded)
|
|
current = os.getcwd()
|
|
# Relative name matching the tail of the current dir: use the current dir.
|
|
if not os.path.isdir(candidate):
|
|
wanted, have = Path(expanded).parts, Path(current).parts
|
|
if wanted and len(wanted) <= len(have) and have[-len(wanted):] == wanted:
|
|
return current
|
|
return candidate
|
|
|
|
|
|
def _windows_to_msys_path(cwd: str) -> str:
|
|
"""Native ``C:\\Users\\x`` -> Git Bash ``/c/Users/x`` so ``builtin cd`` resolves
|
|
it. No-op off Windows / for non-drive paths."""
|
|
m = _IS_WINDOWS and cwd and re.match(r'^([a-zA-Z]):[\\/]*(.*)$', cwd)
|
|
if not m:
|
|
return cwd
|
|
tail = (m.group(2) or "").replace('\\', '/').lstrip('/')
|
|
return f"/{m.group(1).lower()}/{tail}"
|
|
|
|
|
|
def _bash_safe_path(path: str) -> str:
|
|
"""*path* safe to embed in a Git Bash script: ``C:\\Users\\x`` / ``C:/Users/x``
|
|
become ``/c/Users/x`` (MSYS argument conversion mangles ``C:/`` forms) and
|
|
leftover backslashes are normalized so bash does not eat ``\\U``. No-op off Windows."""
|
|
return _windows_to_msys_path(path).replace("\\", "/") if _IS_WINDOWS and path else 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
|
|
(``isdir`` alone passes ``/root`` for a non-root user; ``Popen(cwd=)`` then dies)."""
|
|
return os.path.isdir(path) and os.access(path, os.X_OK)
|
|
|
|
|
|
def _resolve_safe_cwd(cwd: str) -> str:
|
|
"""``cwd`` if enterable, else the nearest usable ancestor, else
|
|
``tempfile.gettempdir()``. MSYS paths are normalized first on Windows so a valid
|
|
``pwd -P`` result is not rejected. Lets ``_run_bash`` recover from a deleted or
|
|
inaccessible cwd instead of ``Popen`` raising and wedging every later call.
|
|
|
|
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 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 and not _cwd_usable(parent):
|
|
next_parent = os.path.dirname(parent)
|
|
if next_parent == parent:
|
|
return tempfile.gettempdir() # filesystem root itself is unusable
|
|
parent = next_parent
|
|
return parent or tempfile.gettempdir()
|
|
|
|
|
|
# --- Child-process environment construction ---
|
|
def _apply_profile_home(env: dict) -> None:
|
|
"""Bridge the context-local HERMES_HOME override, then the subprocess HOME contract."""
|
|
from hermes_constants import apply_subprocess_home_env, get_hermes_home_override
|
|
try:
|
|
if value := get_hermes_home_override():
|
|
env["HERMES_HOME"] = value
|
|
except Exception:
|
|
pass
|
|
apply_subprocess_home_env(env)
|
|
|
|
|
|
def _inject_session_context_env(env: dict) -> None:
|
|
"""Bridge gateway session ContextVars (HERMES_SESSION_*) into a child env.
|
|
Cross-session leak guard: the vars' last-writer-wins ``os.environ`` mirror may
|
|
belong to another turn on a concurrent multi-session host, so once the session
|
|
context is engaged ContextVars are authoritative — a bound value (incl. "") wins
|
|
and an _UNSET var is STRIPPED, not inherited. An unengaged CLI keeps the mirror."""
|
|
try:
|
|
from gateway.session_context import _UNSET, _VAR_MAP, session_context_engaged
|
|
except Exception:
|
|
return
|
|
_engaged = session_context_engaged()
|
|
for var_name, var in _VAR_MAP.items():
|
|
value = var.get()
|
|
if value is not _UNSET:
|
|
env[var_name] = "" if value is None else str(value)
|
|
elif _engaged:
|
|
env.pop(var_name, None)
|
|
|
|
|
|
def _filter_secret_env(
|
|
items: Mapping[str, str], out: dict, *, unwrap_force: bool,
|
|
plugin_strip: frozenset = frozenset()) -> None:
|
|
"""Copy *items* into *out*, dropping Hermes-managed secrets. ``_HERMES_FORCE_<NAME>``
|
|
unwraps to ``NAME`` when ``unwrap_force`` (caller extras / terminal env), else is
|
|
dropped. Blocklisted names survive only via env_passthrough registration or as
|
|
context-entitled first-party ``BUZZ_*`` vars; the latter are used directly, never
|
|
scope-resolved (UnscopedSecretError under multiplex)."""
|
|
try:
|
|
from tools.env_passthrough import is_env_passthrough, resolve_passthrough_value
|
|
except Exception:
|
|
is_env_passthrough, resolve_passthrough_value = (lambda _: False), (lambda _n, fb: fb)
|
|
plugin_strip_folded = frozenset(k.upper() for k in plugin_strip)
|
|
registered = _registered_adapter_secret_env()
|
|
for key, value in items.items():
|
|
if key.startswith(_HERMES_PROVIDER_ENV_FORCE_PREFIX):
|
|
if not unwrap_force:
|
|
continue
|
|
key = key[len(_HERMES_PROVIDER_ENV_FORCE_PREFIX):]
|
|
if not _is_hermes_internal_secret(key):
|
|
out[key] = value
|
|
continue
|
|
if _is_hermes_internal_secret(key) or key.upper() in plugin_strip_folded:
|
|
continue
|
|
first_party = _is_terminal_first_party_env(key)
|
|
passthrough = is_env_passthrough(key)
|
|
if _is_provider_env_blocklisted(key, registered) and not (passthrough or first_party):
|
|
continue
|
|
if passthrough and not first_party:
|
|
value = resolve_passthrough_value(key, value)
|
|
if value is not None:
|
|
out[key] = value
|
|
|
|
|
|
def _finalize_child_env(env: dict) -> dict:
|
|
"""Guards shared by every spawn surface: profile-home propagation, session-context
|
|
bridging, Hermes-owned PYTHONPATH + venv-marker strip, MSYS defaults, delegate_task
|
|
Kanban scrub. Returns the (possibly new) dict."""
|
|
_apply_profile_home(env)
|
|
_inject_session_context_env(env)
|
|
_strip_hermes_owned_pythonpath_and_runtime_markers(env)
|
|
_apply_windows_msys_bash_env_defaults(env)
|
|
from agent.delegation_context import delegated_child_subprocess_env
|
|
return delegated_child_subprocess_env(env)
|
|
|
|
|
|
def _scrubbed_env(parts, plugin_strip: frozenset, fix_path) -> dict:
|
|
"""Filter each ``(items, unwrap_force)`` in *parts* into one env, rewrite PATH via
|
|
*fix_path* (always prepending the hermes install dir so bare ``hermes`` resolves
|
|
for children of a systemd/cron-launched gateway), then apply the shared guards."""
|
|
out: dict[str, str] = {}
|
|
for items, unwrap_force in parts:
|
|
_filter_secret_env(items, out, unwrap_force=unwrap_force, plugin_strip=plugin_strip)
|
|
# Declared names the bound profile scope holds but the process env never did (a routed
|
|
# profile's own .env / sources) — the filter above can only see names already present.
|
|
# Unguarded on purpose: a scope/config failure here must be loud, not silently drop the
|
|
# declared secret again (#114209); _scrub_child_env calls it the same way.
|
|
from tools.env_passthrough import scoped_passthrough_additions
|
|
out.update((k, v) for k, v in scoped_passthrough_additions(out).items() if k not in plugin_strip)
|
|
path_key = _path_env_key(out)
|
|
# 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).
|
|
if path_key is not None:
|
|
out[path_key] = _prepend_hermes_bin_dir(fix_path(out.get(path_key, "")))
|
|
return _finalize_child_env(out)
|
|
|
|
|
|
def _sanitize_subprocess_env(base_env: dict | None, extra_env: dict | None = None) -> dict:
|
|
"""Filter Hermes-managed secrets from a subprocess environment (background/PTY
|
|
spawn path, search workers, computer-use driver, user-script runners)."""
|
|
return _scrubbed_env([(base_env or {}, False), (extra_env or {}, True)],
|
|
_plugin_terminal_env_strip_keys(), lambda p: p)
|
|
|
|
|
|
def hermes_subprocess_env(
|
|
*, inherit_credentials: bool = False, base_env: dict[str, str] | None = None
|
|
) -> dict[str, str]:
|
|
"""Sanitize a non-terminal child's environment (no skill passthrough).
|
|
|
|
Bot, GitHub and remote-compute secrets never pass through; provider/tool
|
|
credentials pass only with ``inherit_credentials=True`` for children that
|
|
need them. Callers needing one other secret should add only that key back.
|
|
``base_env`` lets an already curated environment use the same policy.
|
|
Terminal and execute_code spawns use the skill-aware sanitizer instead.
|
|
"""
|
|
env = dict(base_env) if base_env is not None else os.environ.copy()
|
|
env = _scrub_credentials(env, inherit_credentials=inherit_credentials)
|
|
env.setdefault("PYTHONUTF8", "1") # Windows UTF-8 safety for spawned processes
|
|
return _finalize_child_env(env)
|
|
|
|
|
|
def _scrub_credentials(env: dict, *, inherit_credentials: bool) -> dict:
|
|
"""Tier 1 (always) and, unless ``inherit_credentials``, Tier 2 provider/tool credentials, in place."""
|
|
# Credential names fold to uppercase for membership: on Windows the env block
|
|
# itself is case-insensitive, so a lowercase-stored ``gh_token`` IS GH_TOKEN.
|
|
home_secrets = _home_adapter_secret_env() # one manifest stamp per scrub
|
|
strip_folded = _ALWAYS_STRIP_FOLDED | {k.upper() for k in _plugin_terminal_env_strip_keys()} | home_secrets
|
|
registered = _registry_adapter_secret_env() # home_secrets already strip above
|
|
for key in list(env):
|
|
if (key.upper() in strip_folded
|
|
or (not inherit_credentials and _is_provider_env_blocklisted(key, registered))
|
|
or key.startswith(_HERMES_PROVIDER_ENV_FORCE_PREFIX)
|
|
or _is_hermes_internal_secret(key)):
|
|
del env[key]
|
|
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,
|
|
strip_launch_profile: bool = False) -> dict[str, str]:
|
|
"""Single factory for child-process envs. ``base=None`` snapshots ``os.environ``.
|
|
``scrub_secrets=True`` -> :func:`_sanitize_subprocess_env` (profile home inherent,
|
|
``inherit_profile_home`` ignored). ``scrub_secrets=False`` keeps the base
|
|
byte-for-byte (git credential flows, ``bws``/``op``); ``inherit_profile_home``
|
|
bridges HERMES_HOME + HOME and ``extra`` is applied last so caller overrides win.
|
|
``strip_launch_profile`` drops the LAUNCH profile's ``.env`` residue from the base first
|
|
(:func:`strip_launch_profile_env`; a no-op unless a routed home is active) so a child that
|
|
acts for a routed profile sees only that profile's declared names, never the launch profile's.
|
|
Under multiplex semantics it then overlays the bound secret scope (the routed profile's own
|
|
``.env`` + source values, which never enter ``os.environ``) and re-applies the managed keys,
|
|
all BEFORE the scrub, so those values pass the same scrub / passthrough rules as any other."""
|
|
env: dict[str, str] = dict(base) if base is not None else os.environ.copy()
|
|
if strip_launch_profile:
|
|
strip_launch_profile_env(env)
|
|
from agent.secret_scope import current_secret_scope, is_multiplex_active
|
|
if is_multiplex_active():
|
|
# Single-profile: the scope IS os.environ, so overlaying it would only re-sanitize
|
|
# values the child already inherits byte-identical.
|
|
env.update(current_secret_scope() or {})
|
|
# Administrator-managed values keep their precedence over the routed profile's own .env,
|
|
# exactly as they do in the launch process (``_apply_managed_env`` applies them last).
|
|
restore_managed_env(env)
|
|
if scrub_secrets:
|
|
return _sanitize_subprocess_env(env, dict(extra) if extra else None)
|
|
if inherit_profile_home:
|
|
_apply_profile_home(env)
|
|
if extra:
|
|
env.update(extra)
|
|
from agent.delegation_context import delegated_child_subprocess_env
|
|
return delegated_child_subprocess_env(env)
|
|
|
|
|
|
def served_profile_child_env(
|
|
base: "Mapping[str, str] | None" = None, *, target_home: "str | Path | None" = None,
|
|
inherit_credentials: bool = False,
|
|
) -> dict[str, str]:
|
|
"""Child env for a process that acts FOR the active (possibly served) profile: ``hermes -p X``
|
|
workers, ``key_cmd`` helpers, browser drivers. The process env is the LAUNCH profile's. When the
|
|
target is a ROUTED home (not the launch profile's — under multiplex or a Desktop/dashboard backend
|
|
serving ``?profile=`` with the flag off) the launch ``.env`` residue and bridged ``TERMINAL_*`` are
|
|
dropped (``strip_launch_profile_env``) AND every provider/tool credential is scrubbed from the base
|
|
regardless of provenance: a key systemd / Compose / the shell injected into the launch process was
|
|
never recorded in ``.env`` or a source snapshot, so a name-based strip cannot see it and the target
|
|
overlay cannot remove it. ``inherit_credentials=True`` is for children that legitimately run with
|
|
the profile's credentials (they run the agent or mint its token): the target profile's own secrets
|
|
(its ``.env`` + hydrated sources, what a standalone ``hermes -p X`` loads itself) are overlaid — never
|
|
a sibling profile's. Under multiplex with neither a target nor a bound scope the call raises
|
|
(``get_secret``'s fail-closed contract): minting with the launch environ would sign in as the wrong
|
|
profile. ``False`` keeps the provider scrub; the caller re-adds the few keys the child needs via
|
|
``get_secret``. ``target_home`` defaults to the active override; ``base`` replaces the
|
|
``hermes_subprocess_env`` snapshot."""
|
|
from agent.secret_scope import (
|
|
UnscopedSecretError, build_profile_secret_scope, current_secret_scope, is_multiplex_active)
|
|
from hermes_constants import apply_scratch_tmp_env, get_hermes_home_override
|
|
env = dict(base) if base is not None else hermes_subprocess_env(inherit_credentials=inherit_credentials)
|
|
target = str(target_home or get_hermes_home_override() or "")
|
|
if target:
|
|
env["HERMES_HOME"] = target
|
|
apply_scratch_tmp_env(env) # TMPDIR follows the served home, like HOME does
|
|
if _is_routed_home(target):
|
|
strip_launch_profile_env(env, target)
|
|
_scrub_credentials(env, inherit_credentials=False)
|
|
if inherit_credentials:
|
|
if target:
|
|
secrets = build_profile_secret_scope(Path(target))
|
|
else:
|
|
secrets = current_secret_scope()
|
|
if secrets is None and is_multiplex_active():
|
|
raise UnscopedSecretError(
|
|
"", "served_profile_child_env(inherit_credentials=True) called with no target home and "
|
|
"no profile secret scope bound while multiplexing is on; the child would inherit the "
|
|
"launch profile's credentials. Bind the profile scope (or pass target_home) at the spawn site.")
|
|
env.update((k, v) for k, v in (secrets or {}).items() if v is not None)
|
|
return env
|
|
|
|
|
|
def host_gateway_child_env(
|
|
base: "Mapping[str, str] | None" = None,
|
|
) -> dict[str, str]:
|
|
"""Child env for the host gateway: the default profile's secrets, never the launcher's.
|
|
|
|
``served_profile_child_env`` — not ``os.environ.copy()``. A profile-scoped parent
|
|
(desktop, fleet restart, detached watcher) must not donate its dotenv to the
|
|
multiplexer that owns the primary adapter map.
|
|
"""
|
|
from hermes_constants import get_default_hermes_root
|
|
return served_profile_child_env(
|
|
base=base, target_home=get_default_hermes_root(), inherit_credentials=True,
|
|
)
|
|
|
|
|
|
def _is_routed_home(target_home: "str | Path") -> bool:
|
|
"""True when ``target_home`` is not the process's own (launch) home.
|
|
|
|
Same launch-home identity as ``agent.secret_scope.serves_routed_profile()``: under a host that
|
|
mirrors the served profile into ``HERMES_HOME``, the live env var names the served home and the
|
|
launch residue would never be stripped from that profile's child env."""
|
|
from hermes_constants import get_routing_process_hermes_home
|
|
try:
|
|
return Path(target_home).resolve() != get_routing_process_hermes_home().resolve()
|
|
except OSError:
|
|
return True
|
|
|
|
|
|
def strip_launch_profile_env(env: dict, target_home: "str | Path | None" = None) -> dict:
|
|
"""Drop the LAUNCH profile's residue from a child env built for another served profile.
|
|
``os.environ`` holds the default profile's ``.env`` and its bridged ``TERMINAL_*`` settings;
|
|
the secret scrub removes credentials but not settings (``HERMES_MODEL``, ``TERMINAL_ENV``,
|
|
``HERMES_LANGUAGE``...), so a standalone ``hermes -p X`` worker and a served one saw different
|
|
envs. The child re-loads X's own ``.env`` and bridges X's config itself. ``target_home``
|
|
defaults to the active home override; no-op when there is no target or the target IS the
|
|
launch profile. The authority test is "does this task serve a routed home", not "is the
|
|
gateway-wide multiplex flag on": the Desktop/dashboard backend serves ``?profile=B`` by
|
|
installing a HERMES_HOME override without that flag."""
|
|
from agent.secret_scope import _is_global_env, load_env_file
|
|
from hermes_constants import get_hermes_home_override, get_routing_process_hermes_home
|
|
target = target_home or get_hermes_home_override()
|
|
if not target or not _is_routed_home(target):
|
|
return env
|
|
launch_home = get_routing_process_hermes_home()
|
|
from hermes_cli.config import TERMINAL_CONFIG_ENV_MAP
|
|
# Folded strip: on Windows the env block is case-insensitive, so residue
|
|
# stored under a variant casing is the same variable and must go too. The
|
|
# selection folds the same way so a lowercase ``path`` in .env is still
|
|
# recognized as a global name and left alone.
|
|
# Current file AND every key any dotenv load put into os.environ this process lifetime: a key
|
|
# removed or renamed in the launch .env after boot is still in os.environ with the old value, and
|
|
# a re-parse of the file alone no longer names it (#107695 review). External secret sources
|
|
# (vault, 1Password, ...) write their names into the same shared os.environ, and a name the
|
|
# LAUNCH profile's source supplied is not the target profile's to see; the caller's scope
|
|
# overlay puts back exactly the ones the target's own sources supply. The administrator-managed
|
|
# .env is NOT residue: its values are policy for every profile (``_apply_managed_env`` applies
|
|
# it last, with override, so it beats the user's own .env) — leave them in place.
|
|
from hermes_cli.env_loader import launch_dotenv_keys, managed_dotenv_keys, source_supplied_names
|
|
managed_names = {key.upper() for key in managed_dotenv_keys()}
|
|
residue_names = {
|
|
key.upper() for key in
|
|
set(load_env_file(launch_home / ".env")) | set(launch_dotenv_keys())
|
|
| set(TERMINAL_CONFIG_ENV_MAP.values()) | set(source_supplied_names())
|
|
if not _is_global_env(key.upper()) or key.upper().startswith("TERMINAL_")} - managed_names
|
|
for key in [k for k in env if k.upper() in residue_names]:
|
|
del env[key]
|
|
# Authorization gates are the one residue a name list cannot see: a unit-file ``Environment=``
|
|
# or an operator export never appears in the launch ``.env``, the secret scrub ignores
|
|
# non-credentials, and the target's own ``.env`` rarely defines the key to overwrite it (#113270).
|
|
return strip_profile_gate_env(env)
|
|
|
|
|
|
def restore_managed_env(env: dict) -> dict:
|
|
"""Re-apply the administrator-managed ``.env`` values over *env* — call AFTER a routed profile's scope
|
|
has been overlaid. ``_apply_managed_env`` gives those keys precedence over the user's own ``.env`` in
|
|
the launch process; a routed child must see the same precedence, or the routed user's value for a
|
|
managed key (``ORG_POLICY_FLAG=user-value``) silently wins over policy."""
|
|
from hermes_cli.env_loader import managed_dotenv_keys
|
|
for key in managed_dotenv_keys():
|
|
if key in os.environ:
|
|
env[key] = os.environ[key]
|
|
return env
|
|
|
|
|
|
# --- Shell discovery ---
|
|
def _find_bash() -> str:
|
|
"""Resolve the shell Hermes runs commands with. Owned by pm (the store
|
|
is the authority on bundled bash); this is a thin wrapper over
|
|
pm.shell() for callers that need a bash binary."""
|
|
import pm.shell
|
|
|
|
bash = pm.shell.bash()
|
|
if bash:
|
|
return bash
|
|
raise RuntimeError(
|
|
"No shell found. Hermes needs bash (Git for Windows on Windows). "
|
|
"Run `hermes pm install` or reinstall the bundle."
|
|
)
|
|
|
|
|
|
_git_bash_bin_dirs_cache: "list[str] | None" = None
|
|
|
|
|
|
def _git_bash_bin_dirs() -> list[str]:
|
|
"""Git Bash's coreutils dirs in ``/etc/profile`` order (mingw first so coreutils
|
|
beat System32 lookalikes); ``[]`` off Windows. A non-login ``bash -c`` (fallback
|
|
when ``bash -l`` is broken) never sources ``/etc/profile``, so without these
|
|
``cat``/``mktemp``/``mv`` are missing and commands exit 127."""
|
|
global _git_bash_bin_dirs_cache
|
|
if _git_bash_bin_dirs_cache is None:
|
|
_git_bash_bin_dirs_cache = _compute_git_bash_bin_dirs() if _IS_WINDOWS else []
|
|
return _git_bash_bin_dirs_cache
|
|
|
|
|
|
def _compute_git_bash_bin_dirs() -> list[str]:
|
|
try:
|
|
bash = _find_bash()
|
|
except Exception:
|
|
return []
|
|
parent = os.path.dirname(os.path.dirname(bash)) # bash in <root>\bin or <root>\usr\bin (MinGit)
|
|
root = os.path.dirname(parent) if os.path.basename(parent).lower() == "usr" else parent
|
|
subs = ("mingw64/bin", "mingw32/bin", "usr/local/bin", "usr/bin", "bin")
|
|
dirs = (os.path.join(root, *sub.split("/")) for sub in subs)
|
|
return list(dict.fromkeys(d for d in dirs if os.path.isdir(d)))
|
|
|
|
|
|
def _prepend_missing_path_entries(existing_path: str, dirs: list[str]) -> str:
|
|
"""Prepend *dirs* missing from *existing_path* (``os.pathsep``); an already-listed
|
|
dir keeps its position; unchanged input when nothing is missing."""
|
|
entries = [e for e in existing_path.split(os.pathsep) if e]
|
|
missing = [d for d in dirs if d not in entries]
|
|
return os.pathsep.join([*missing, *entries]) if missing else existing_path
|
|
|
|
|
|
def _prepend_git_bash_dirs(existing_path: str) -> str:
|
|
"""Prepend Git Bash's binary dirs if missing (no-op off Windows), so the
|
|
non-login ``bash -c`` fallback can find coreutils."""
|
|
return _prepend_missing_path_entries(existing_path, _git_bash_bin_dirs())
|
|
|
|
|
|
# POSIX-sh-family shells that understand spawn_local's ``[shell, "-lic", "set +m; …"]``
|
|
# invocation; fish, csh/tcsh, nushell, elvish, xonsh would error, so _find_shell
|
|
# falls back to bash for them.
|
|
# (#42203)
|
|
_SPAWN_COMPATIBLE_SHELLS = frozenset({"bash", "zsh", "sh", "dash", "ksh", "mksh"})
|
|
|
|
|
|
def _find_shell() -> str:
|
|
"""User's login shell for background spawning: ``$SHELL`` on POSIX when it is an
|
|
executable sh-family shell, else ``_find_bash``. macOS's system bash 3.2 under
|
|
``-l`` with stdin ``/dev/null`` sources ``~/.bash_profile``, which often
|
|
``exec /bin/zsh -l`` and drops ``-c`` — the command silently never runs."""
|
|
user_shell = "" if _IS_WINDOWS else os.environ.get("SHELL")
|
|
if (user_shell and os.path.isfile(user_shell) and os.access(user_shell, os.X_OK)
|
|
and Path(user_shell).name in _SPAWN_COMPATIBLE_SHELLS):
|
|
return user_shell
|
|
return _find_bash()
|
|
|
|
|
|
# --- PATH completion for the terminal subshell ---
|
|
|
|
# Standard PATH entries for environments with minimal PATH.
|
|
_SANE_PATH = ("/opt/homebrew/bin:/opt/homebrew/sbin:"
|
|
"/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin")
|
|
|
|
# Cached directory containing the ``hermes`` console-script.
|
|
# ``_SENTINEL`` distinguishes "not resolved yet" from a resolved ``None``.
|
|
_SENTINEL = object()
|
|
_HERMES_BIN_DIR: "str | None | object" = _SENTINEL
|
|
# True when the cached dir is a sealed payload's own launcher dir (see below).
|
|
_HERMES_BIN_DIR_IS_PAYLOAD = False
|
|
|
|
|
|
def _resolve_hermes_bin_dir() -> str | None:
|
|
"""Directory holding the ``hermes`` console-script, or None (cached). A gateway
|
|
launched by systemd/cron/a desktop launcher lacks the install dir on PATH and bare
|
|
``hermes`` exits 127. Order: a sealed payload's own launcher dir; ``which``; absolute
|
|
``sys.argv[0]`` naming a real hermes executable; ``sys.executable``'s dir if it holds
|
|
the shim."""
|
|
global _HERMES_BIN_DIR, _HERMES_BIN_DIR_IS_PAYLOAD
|
|
if _HERMES_BIN_DIR is not _SENTINEL:
|
|
return _HERMES_BIN_DIR # type: ignore[return-value]
|
|
from pm.environments import payload_command_dir
|
|
|
|
# A payload's venv also holds a `hermes`, but on Windows its redirector names the
|
|
# build machine's interpreter, so PATH order must not decide which copy children get.
|
|
payload_dir = payload_command_dir(Path(__file__).resolve().parents[2])
|
|
if payload_dir is not None and payload_dir.is_dir():
|
|
_HERMES_BIN_DIR, _HERMES_BIN_DIR_IS_PAYLOAD = str(payload_dir), True
|
|
return _HERMES_BIN_DIR
|
|
which = shutil.which("hermes")
|
|
argv0 = sys.argv[0] if sys.argv else ""
|
|
base = os.path.basename(argv0).lower()
|
|
exe_dir = os.path.dirname(sys.executable) if sys.executable else ""
|
|
shim = "hermes.exe" if _IS_WINDOWS else "hermes"
|
|
if which:
|
|
candidate = os.path.dirname(which)
|
|
elif (os.path.isabs(argv0) and (base == "hermes" or base.startswith("hermes."))
|
|
and os.path.isfile(argv0)):
|
|
candidate = os.path.dirname(argv0)
|
|
else:
|
|
candidate = exe_dir if exe_dir and os.path.isfile(os.path.join(exe_dir, shim)) else None
|
|
_HERMES_BIN_DIR = candidate if candidate and os.path.isdir(candidate) else None
|
|
_HERMES_BIN_DIR_IS_PAYLOAD = False
|
|
return _HERMES_BIN_DIR
|
|
|
|
|
|
def _prepend_hermes_bin_dir(existing_path: str) -> str:
|
|
"""Prepend the hermes install dir to ``existing_path`` if missing. A sealed payload's
|
|
launcher dir moves to the front even when already listed: a login PATH can list
|
|
another install's ``hermes`` ahead of it."""
|
|
bin_dir = _resolve_hermes_bin_dir()
|
|
if bin_dir and _HERMES_BIN_DIR_IS_PAYLOAD:
|
|
rest = [entry for entry in existing_path.split(os.pathsep) if entry and entry != bin_dir]
|
|
return os.pathsep.join([bin_dir, *rest])
|
|
return _prepend_missing_path_entries(existing_path, [bin_dir] if bin_dir else [])
|
|
|
|
|
|
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:
|
|
|
|
- the pm store's node/npm entries — 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 runtime can appear
|
|
mid-process (a lazy pm install, a first browser install).
|
|
"""
|
|
try:
|
|
import pm
|
|
from hermes_constants import get_hermes_home
|
|
|
|
env = pm.env_for("npm", base_env={"PATH": ""})
|
|
managed = [Path(d) for d in env.get("PATH", "").split(os.pathsep) if d]
|
|
candidates = [*managed, get_hermes_home() / "bin"]
|
|
return [str(d) for d in candidates if d.is_dir()]
|
|
except Exception:
|
|
return []
|
|
|
|
|
|
def _user_local_bin_entries() -> list[str]:
|
|
"""``~/.local/bin`` when it exists — the pip --user / pipx / uv-tool install
|
|
target. A backend launched by a non-interactive SSH session, systemd or a GUI
|
|
launcher inherits a PATH without it (only the login shell adds it), so CLIs
|
|
installed there were ``command not found`` from the terminal tool (#111778)."""
|
|
local_bin = Path.home() / ".local" / "bin"
|
|
try:
|
|
return [str(local_bin)] if local_bin.is_dir() else []
|
|
except OSError:
|
|
# HOME can point at a directory this process may not traverse (CI runs
|
|
# with HOME=/root as an unprivileged user); such a home has no usable
|
|
# ~/.local/bin either.
|
|
return []
|
|
|
|
|
|
def _append_missing_sane_path_entries(existing_path: str) -> str:
|
|
"""Normalised POSIX PATH with missing sane entries appended: empty entries
|
|
dropped (shells read them as cwd), duplicates collapsed (first wins), then
|
|
missing ``_SANE_PATH`` / managed-runtime / ``~/.local/bin`` dirs appended so
|
|
user entries keep precedence. Windows is a no-op passthrough (native ``;``
|
|
PATH untouched)."""
|
|
if _IS_WINDOWS:
|
|
return existing_path
|
|
# dict preserves first-occurrence order; empty entries dropped.
|
|
ordered = dict.fromkeys(entry for entry in existing_path.split(":") if entry)
|
|
ordered.update(dict.fromkeys([*_SANE_PATH.split(":"), *_managed_runtime_path_entries(),
|
|
*_user_local_bin_entries()]))
|
|
return ":".join(ordered)
|
|
|
|
|
|
def _apply_windows_msys_bash_env_defaults(env: dict) -> None:
|
|
"""Disable MSYS argument path conversion (``/FO`` -> ``C:/.../git/FO`` breaks
|
|
tasklist/schtasks/wmic/``cmd /c``). Git for Windows honors ``MSYS_NO_PATHCONV``;
|
|
MSYS2/Cygwin bash honor ``MSYS2_ARG_CONV_EXCL`` — set both; users can override.
|
|
|
|
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. Refs #56700.
|
|
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 _IS_WINDOWS:
|
|
env.setdefault("MSYS_NO_PATHCONV", "1")
|
|
env.setdefault("MSYS2_ARG_CONV_EXCL", "*")
|
|
|
|
|
|
def _path_env_key(run_env: dict) -> str | None:
|
|
"""PATH env key to update without altering Windows casing (``Path`` vs ``PATH``);
|
|
None when a Windows env has no PATH key at all."""
|
|
return next((k for k in run_env if k.upper() == "PATH"), None) if _IS_WINDOWS else "PATH"
|
|
|
|
|
|
def _make_run_env(env: dict) -> dict:
|
|
"""Build a run environment with a sane PATH and provider-var stripping. The process env is
|
|
the LAUNCH profile's; under a routed home override its ``.env`` residue is dropped first
|
|
(``strip_launch_profile_env``, a no-op for the launch profile) so the backend's own ``env``
|
|
and the served profile's declared passthrough names are what the child sees."""
|
|
run_env = _scrubbed_env(
|
|
[(dict(strip_launch_profile_env(os.environ.copy()) | env), True)],
|
|
frozenset(),
|
|
lambda p: _prepend_git_bash_dirs(_append_missing_sane_path_entries(p)),
|
|
)
|
|
# While this profile's Bot Desktop is running, its DISPLAY/XAUTHORITY/DBUS ride along so GUI
|
|
# apps the agent launches from the terminal open on the Bot Screen the user is watching, not
|
|
# on the user's own seat (#125830). published_env() is the pure read (no activity stamp — a
|
|
# plain ``ls`` must not keep the screen alive past idle_stop_minutes), and it wins over the
|
|
# login snapshot's seat DISPLAY; a user who wants their own seat uses an inline
|
|
# ``DISPLAY=:0 cmd`` prefix, which bash applies after this env. Empty (or module missing) →
|
|
# the seat env passes through untouched.
|
|
try:
|
|
from tools.bot_desktop.runtime import published_env
|
|
published = published_env()
|
|
except Exception:
|
|
published = {}
|
|
if published:
|
|
run_env.update(published)
|
|
run_env.pop("WAYLAND_DISPLAY", None) # X11 desktop; a leaked Wayland socket flips GTK/Chromium backends
|
|
return run_env
|
|
|
|
|
|
# --- Hermes venv / repo-root detection (module-level, computed once) ---
|
|
# Owned here; read lazily by tools.environments.local_pythonpath (tests patch here).
|
|
# The Electron app prepends the repo root to PYTHONPATH so the backend can ``import
|
|
# tools``; other subprocesses must not inherit it. Aliases: launchers may emit other
|
|
# spellings — the Windows gateway launcher renders Hermes-owned paths under the
|
|
# configured HERMES_HOME spelling (possibly a junction to another drive).
|
|
_hermes_repo_root: Path = Path(__file__).resolve().parents[2]
|
|
_hermes_repo_root_aliases: tuple[Path, ...] = _build_hermes_repo_root_aliases(
|
|
_hermes_repo_root, Path(__file__).absolute().parents[2], get_process_hermes_home())
|
|
_in_venv: bool = (getattr(sys, "base_prefix", sys.prefix) != sys.prefix
|
|
or hasattr(sys, "real_prefix")) # real_prefix: virtualenv<20
|
|
_hermes_site_packages: list[Path] | None = None # lazily cached by local_pythonpath
|
|
|
|
|
|
# --- Login-shell init files ---
|
|
def _read_terminal_shell_init_config() -> tuple[list[str], bool]:
|
|
"""(shell_init_files, auto_source_bashrc) from config.yaml; defaults on any
|
|
failure so terminal execution never breaks."""
|
|
try:
|
|
from hermes_cli.config import load_config
|
|
terminal_cfg = (load_config() or {}).get("terminal") or {}
|
|
files = terminal_cfg.get("shell_init_files") or []
|
|
if not isinstance(files, list):
|
|
files = []
|
|
return [str(f) for f in files if f], bool(terminal_cfg.get("auto_source_bashrc", True))
|
|
except Exception:
|
|
return [], True
|
|
|
|
|
|
def _resolve_shell_init_files() -> list[str]:
|
|
"""Files to source before the login-shell snapshot (``~``/``${VAR}`` expanded,
|
|
missing dropped). ``auto_source_bashrc`` applies only without an explicit list:
|
|
~/.profile and ~/.bash_profile first (no interactivity guard; where
|
|
n/nvm/asdf/pyenv add PATH), ~/.bashrc last (Debian's returns early when
|
|
non-interactive, but guard-less bashrcs keep working)."""
|
|
explicit, auto_bashrc = _read_terminal_shell_init_config()
|
|
candidates = explicit or (["~/.profile", "~/.bash_profile", "~/.bashrc"]
|
|
if auto_bashrc and not _IS_WINDOWS else [])
|
|
resolved: list[str] = []
|
|
for raw in candidates:
|
|
try:
|
|
path = os.path.expandvars(os.path.expanduser(raw))
|
|
if path and os.path.isfile(path):
|
|
resolved.append(path)
|
|
except Exception:
|
|
continue
|
|
return resolved
|
|
|
|
|
|
def _prepend_shell_init(cmd_string: str, files: list[str]) -> str:
|
|
"""Prepend guarded, silent ``source <file>`` lines: ``set +e`` keeps going on
|
|
errors, ``2>/dev/null`` hides noisy prompts, ``|| true`` neutralises the status."""
|
|
if not files:
|
|
return cmd_string
|
|
safe = [p.replace("'", "'\\''") for p in files]
|
|
prelude = ["set +e", *(f"[ -r '{p}' ] && . '{p}' 2>/dev/null || true" for p in safe)]
|
|
return "\n".join(prelude) + "\n" + cmd_string
|
|
|
|
|
|
# --- Process-group teardown (POSIX) ---
|
|
def _wait_for_group_exit(proc, pgid: int, timeout: float) -> bool:
|
|
"""Wait until the process group is gone, reaping the wrapper as we go (a dead
|
|
but unreaped group leader still makes ``killpg(pgid, 0)`` succeed).
|
|
POSIX-only; callers are behind the _IS_WINDOWS gate."""
|
|
deadline = time.monotonic() + timeout
|
|
while True:
|
|
try:
|
|
proc.poll()
|
|
except Exception:
|
|
pass
|
|
try:
|
|
os.killpg(pgid, 0) # windows-footgun: ok — POSIX process-group alive probe
|
|
except ProcessLookupError:
|
|
return True
|
|
except PermissionError:
|
|
pass # exists, even if we cannot signal it
|
|
if time.monotonic() >= deadline:
|
|
return False
|
|
time.sleep(0.05)
|
|
|
|
|
|
def _sweep_escaped_descendants(descendants: list, pgid: int) -> None:
|
|
"""SIGKILL snapshotted survivors that escaped the process group via ``setsid``
|
|
— after TERM→KILL so in-group members keep their grace; psutil's identity-aware
|
|
Process skips recycled PIDs. POSIX-only (see _IS_WINDOWS gate in caller)."""
|
|
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 OSError: # ProcessLookupError / PermissionError included
|
|
pass
|
|
child.kill()
|
|
except Exception:
|
|
continue
|
|
|
|
|
|
def _leader_is_ours(pgid, expected_start) -> bool:
|
|
"""The setsid group leader's PID == its PGID. Confirm it is still the process we
|
|
spawned before signalling the whole group, so a recycled PID/PGID can never take
|
|
down an unrelated process group (#43044). The baseline and the current reading
|
|
come from the same host at different times, so they go through the drift-tolerant
|
|
fingerprint comparator — same-host readings drift ~1 s on macOS (#117505), and
|
|
exact equality made the guard refuse to kill live, legitimately-owned groups.
|
|
When no baseline was captured, or the current reading is unreadable, keep the
|
|
legacy best-effort behaviour rather than refusing to kill: only a LIVE leader with
|
|
a different start time proves recycling."""
|
|
if pgid is None:
|
|
return False
|
|
if expected_start is None:
|
|
return True
|
|
from gateway.status import get_process_start_time, start_time_fingerprints_match
|
|
try:
|
|
current = get_process_start_time(pgid)
|
|
except Exception: # noqa: BLE001 — the guard must never break signalling
|
|
return True
|
|
if current is None:
|
|
# Unreadable while alive: best effort. Gone: POSIX never reuses a PGID while any
|
|
# member of the group lives, so the group (if it still exists) is ours and its
|
|
# reparented grandchildren still need the signal; an empty group is just ESRCH.
|
|
return True
|
|
try:
|
|
return start_time_fingerprints_match(expected_start, current)
|
|
except (TypeError, ValueError): # junk fingerprints: best-effort, never break signalling
|
|
return True
|
|
|
|
|
|
def _kill_process_group_posix(proc) -> None:
|
|
"""TERM the group, wait, KILL, then sweep setsid escapees. Descendants are
|
|
snapshotted BEFORE the first signal — once the wrapper dies they reparent to
|
|
init — and we wait on the group, not the wrapper, which can exit before
|
|
grandchildren under load. POSIX-only (_IS_WINDOWS handled by the caller).
|
|
PID-reuse guard (#43044): the group is only signalled while its leader's start
|
|
time still matches the spawn-time baseline — a recycled PGID is never killed."""
|
|
expected_start = getattr(proc, "_hermes_pgid_start", None)
|
|
try:
|
|
pgid = os.getpgid(proc.pid)
|
|
except ProcessLookupError:
|
|
if (pgid := getattr(proc, "_hermes_pgid", None)) is None:
|
|
raise
|
|
if not _leader_is_ours(pgid, expected_start):
|
|
# Leader exited and its PID/PGID may have been recycled onto an unrelated
|
|
# process group; signalling the stale number is unsafe. Bail out — a rare
|
|
# orphaned grandchild may leak, which is strictly preferable to killing a
|
|
# stranger. Sweep the snapshotted descendants by PID (identity-checked)
|
|
# so we still reach escapees that are verifiably ours.
|
|
descendants = []
|
|
try:
|
|
import psutil
|
|
descendants = psutil.Process(proc.pid).children(recursive=True)
|
|
except Exception:
|
|
pass
|
|
_sweep_escaped_descendants(descendants, pgid)
|
|
return
|
|
try: # psutil children snapshot; empty on any failure (must never break the kill)
|
|
import psutil
|
|
descendants = psutil.Process(proc.pid).children(recursive=True)
|
|
except Exception:
|
|
descendants = []
|
|
if pgid == os.getpgrp():
|
|
# The child shares OUR group (a spawner that skipped setsid — the Darwin gateway's
|
|
# posix_spawn shim, #107029): killpg would signal the caller itself. Tear down by PID.
|
|
_kill_known_pids(proc, descendants)
|
|
else:
|
|
try:
|
|
os.killpg(pgid, signal.SIGTERM) # windows-footgun: ok — POSIX only (see _IS_WINDOWS gate in caller)
|
|
if not _wait_for_group_exit(proc, pgid, 1.0):
|
|
if not _leader_is_ours(pgid, expected_start):
|
|
# Leader exited during the grace window; do not escalate to
|
|
# SIGKILL on a possibly-recycled PGID.
|
|
return
|
|
os.killpg(pgid, signal.SIGKILL) # windows-footgun: ok — POSIX only (see _IS_WINDOWS gate in caller)
|
|
_wait_for_group_exit(proc, pgid, 2.0)
|
|
with contextlib.suppress(subprocess.TimeoutExpired, OSError):
|
|
proc.wait(timeout=0.2)
|
|
except ProcessLookupError:
|
|
pass
|
|
except PermissionError:
|
|
# macOS answers killpg with EPERM (not ESRCH) once the group's only members are
|
|
# unreaped zombies — rg exiting between the caller's poll() and the TERM after the
|
|
# drain hit its limit (#116855). Nothing group-wide is signalable, and the error
|
|
# must not escape: the caller still owns the output it drained. Signal the known
|
|
# PIDs instead so a live child (a group we may not signal) cannot outlive us.
|
|
_kill_known_pids(proc, descendants)
|
|
_sweep_escaped_descendants(descendants, pgid)
|
|
|
|
|
|
def _kill_known_pids(proc, descendants) -> None:
|
|
"""KILL the wrapper and its snapshotted descendants by PID (idempotent on zombies)."""
|
|
for target in (proc, *descendants):
|
|
with contextlib.suppress(Exception):
|
|
target.kill()
|
|
|
|
|
|
def _kill_process_windows(proc) -> None:
|
|
"""Identity-checked terminate (start time guards against PID reuse), else kill."""
|
|
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()
|
|
with contextlib.suppress(subprocess.TimeoutExpired, OSError):
|
|
proc.wait(timeout=2.0)
|
|
|
|
|
|
class LocalEnvironment(BaseEnvironment):
|
|
"""Run commands directly on the host: every execute() spawns a fresh bash;
|
|
the session snapshot preserves env vars across calls; CWD persists via the
|
|
stdout marker."""
|
|
|
|
_sudo_nopasswd_probe_supported = True
|
|
_profile_scoped_passthrough = True
|
|
# Commands run on the Hermes host itself — controller-side platform behavior
|
|
# (macOS TCC pruning, etc.) legitimately applies here.
|
|
is_local = True
|
|
|
|
def _additional_profile_scoped_passthrough_names(self) -> tuple[str, ...]:
|
|
"""First-party ``BUZZ_*`` names present in the env, excluded from the shared
|
|
session snapshot. env_passthrough can never list them (it refuses blocklisted
|
|
names), so under a multiplexed gateway profile A's BUZZ_PRIVATE_KEY would land
|
|
in the snapshot and be sourced by profile B. Prefix-only and monotonic on
|
|
purpose: conservative even when the context-gated carve-out is inactive."""
|
|
merged = dict(os.environ | self.env)
|
|
return tuple(sorted(
|
|
name for name in merged
|
|
if isinstance(name, str) and _matches_terminal_first_party_prefix(name)))
|
|
|
|
def __init__(self, cwd: str = "", timeout: int = 60, env: dict = None):
|
|
super().__init__(cwd=_resolve_local_initial_cwd(cwd), timeout=timeout, env=env)
|
|
self.init_session()
|
|
|
|
def get_temp_dir(self) -> str:
|
|
"""Shell-safe writable temp dir. Precedence: ``TERMINAL_TEMP_DIR``, TMPDIR/TMP/TEMP
|
|
(Termux has no system temp dir), ``HERMES_HOME/cache/terminal`` (real storage: a
|
|
tmpfs system temp dir fills under Hermes load; pruned by ``cleanup_terminal_temp_cache``),
|
|
``tempfile.gettempdir()``; backend env before process env so terminal.env
|
|
overrides work. Windows: ``%TEMP%`` often has spaces that break unquoted bash,
|
|
so always the HERMES_HOME cache dir with forward slashes (bash- and Python-valid)."""
|
|
if _IS_WINDOWS:
|
|
for key in ("TERMINAL_TEMP_DIR", "TMPDIR"):
|
|
candidate = self.env.get(key) or os.environ.get(key)
|
|
if candidate and os.path.isabs(candidate) and os.path.isdir(candidate):
|
|
return Path(candidate).as_posix()
|
|
cache_dir = (_default_terminal_temp_dir()
|
|
or Path(tempfile.gettempdir()) / "hermes_terminal")
|
|
cache_dir.mkdir(parents=True, exist_ok=True)
|
|
_prune_terminal_temp_once()
|
|
return str(cache_dir).replace("\\", "/")
|
|
def _posix(p: str) -> str:
|
|
return p.rstrip("/") or "/"
|
|
for env_var in ("TERMINAL_TEMP_DIR", "TMPDIR", "TMP", "TEMP"):
|
|
candidate = self.env.get(env_var) or os.environ.get(env_var)
|
|
if candidate and candidate.startswith("/") and (
|
|
env_var != "TERMINAL_TEMP_DIR" or os.path.isdir(candidate)):
|
|
return _posix(candidate)
|
|
try:
|
|
cache_dir = _default_terminal_temp_dir()
|
|
cache_dir.mkdir(parents=True, exist_ok=True)
|
|
resolved = str(cache_dir)
|
|
if resolved.startswith("/") and os.access(resolved, os.W_OK | os.X_OK):
|
|
_prune_terminal_temp_once()
|
|
return _posix(resolved)
|
|
except Exception:
|
|
pass
|
|
# tempfile's own candidate walk already covers the system temp dir.
|
|
fallback = tempfile.gettempdir()
|
|
return _posix(fallback if fallback.startswith("/") else os.path.abspath(fallback))
|
|
|
|
@staticmethod
|
|
def _quote_cwd_for_cd(cwd: str) -> str:
|
|
"""Use native paths for Python, but Git Bash-friendly paths for cd."""
|
|
return BaseEnvironment._quote_cwd_for_cd(_windows_to_msys_path(cwd))
|
|
|
|
def _quote_shell_path(self, path: str) -> str:
|
|
"""Rewrite native/mixed Windows paths before quoting for Git Bash."""
|
|
return _quote_bash_path(path)
|
|
|
|
def _recover_cwd(self) -> None:
|
|
"""Swap ``self.cwd`` for a usable directory if it vanished or is inaccessible
|
|
(e.g. a command ``rm -rf``'d its own cwd) — otherwise Popen raises before bash
|
|
starts and every subsequent call fails. A benign MSYS→Windows normalization
|
|
is not warned about."""
|
|
# 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). 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:
|
|
return
|
|
if safe_cwd != _msys_to_windows_path(self.cwd):
|
|
logger.warning(
|
|
"LocalEnvironment cwd %r is missing on disk; "
|
|
"falling back to %r so terminal commands keep working.",
|
|
self.cwd, safe_cwd)
|
|
self.cwd = safe_cwd
|
|
|
|
def _run_bash(self, cmd_string: str, *, login: bool = False, timeout: int = 120,
|
|
stdin_data: str | None = None) -> subprocess.Popen:
|
|
bash = _find_bash()
|
|
# Login invocations (init_session's env snapshot) source the user's rc /
|
|
# custom init files so nvm/asdf/pyenv land on PATH in the snapshot.
|
|
if login:
|
|
cmd_string = _prepend_shell_init(cmd_string, _resolve_shell_init_files())
|
|
args = [bash, *(["-l"] if login else []), "-c", cmd_string]
|
|
self._recover_cwd()
|
|
proc = subprocess.Popen(
|
|
args, text=True, env=_make_run_env(self.env), encoding="utf-8", errors="replace",
|
|
stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
|
|
stdin=subprocess.PIPE if stdin_data is not None else subprocess.DEVNULL,
|
|
start_new_session=True, cwd=self.cwd,
|
|
**({"creationflags": windows_hide_flags()} if _IS_WINDOWS else {}))
|
|
if not _IS_WINDOWS:
|
|
with contextlib.suppress(ProcessLookupError):
|
|
proc._hermes_pgid = os.getpgid(proc.pid)
|
|
# Record the group leader's start-time fingerprint so _kill_process can
|
|
# detect PID/PGID recycling before signalling the group (#43044). The
|
|
# psutil fallback in get_process_start_time captures a baseline on every
|
|
# platform, macOS included.
|
|
from gateway.status import get_process_start_time
|
|
proc._hermes_pgid_start = get_process_start_time(proc.pid)
|
|
if stdin_data is not None:
|
|
_pipe_stdin(proc, stdin_data)
|
|
return proc
|
|
|
|
def _kill_process(self, proc):
|
|
"""Kill the entire process group (all children)."""
|
|
try:
|
|
(_kill_process_windows if _IS_WINDOWS else _kill_process_group_posix)(proc)
|
|
except OSError: # ProcessLookupError / PermissionError included
|
|
with contextlib.suppress(Exception):
|
|
proc.kill()
|
|
|
|
def _force_kill_process(self, proc):
|
|
"""SIGKILL the whole group with no TERM grace or wait: the caller os._exit()s next."""
|
|
if _IS_WINDOWS: # already a forced tree kill
|
|
return self._kill_process(proc)
|
|
with contextlib.suppress(OSError):
|
|
pgid = getattr(proc, "_hermes_pgid", None) or os.getpgid(proc.pid)
|
|
# PID-reuse guard (#43044): never SIGKILL a group whose leader's start time
|
|
# no longer matches the spawn-time baseline — the PGID may have been recycled
|
|
# onto an unrelated process group. Comparison is drift-tolerant (#117505);
|
|
# without a baseline keep the legacy best-effort behaviour.
|
|
if pgid != os.getpgrp() and _leader_is_ours(pgid, getattr(proc, "_hermes_pgid_start", None)):
|
|
os.killpg(pgid, signal.SIGKILL) # windows-footgun: ok — POSIX only (_IS_WINDOWS returned above)
|
|
with contextlib.suppress(OSError):
|
|
proc.kill()
|
|
|
|
def _extract_cwd_from_output(self, result: dict):
|
|
"""Base semantics plus: Git Bash ``pwd -P`` emits MSYS form on Windows —
|
|
normalize to native and require the dir to exist, else ``_run_bash`` would
|
|
warn every command. A stale path rolls back to the previous cwd, which this
|
|
command did not observe, so ``cwd_observed`` is dropped."""
|
|
prev_cwd = self.cwd
|
|
super()._extract_cwd_from_output(result)
|
|
if self.cwd != prev_cwd:
|
|
normalized = _msys_to_windows_path(self.cwd)
|
|
if normalized and os.path.isdir(normalized):
|
|
self.cwd = normalized
|
|
result["cwd"] = normalized
|
|
else:
|
|
self.cwd = prev_cwd
|
|
result.pop("cwd_observed", None)
|
|
result.pop("cwd", None)
|
|
|
|
def cleanup(self):
|
|
"""Clean up temp files, including orphaned atomic-write snapshots
|
|
(``snap.tmp.<bashpid>``) a failed/interrupted mv could leave behind."""
|
|
# See #38249.
|
|
import glob
|
|
try:
|
|
stale = glob.glob(f"{self._snapshot_path}.tmp.*")
|
|
except Exception:
|
|
stale = []
|
|
for f in (self._snapshot_path, self._cwd_file, *stale):
|
|
with contextlib.suppress(OSError):
|
|
os.unlink(f)
|