Widening secret_source_names() to include skipped_existing names silently changed tools/mcp_tool_config.py::_build_safe_env, an untouched consumer that forwards every returned name into MCP stdio child envs. That consumer wants only names a source actually APPLIED (pre-stack semantics), so secret_source_names() goes back to tuple(_SECRET_SOURCES). The routed-child scrub in strip_launch_profile_env is the one site that must also see names a source supplied but lost to a pre-existing process value, so it reads the new source_supplied_names() accessor instead. tools/mcp_tool_config.py is byte-identical to origin/main.
880 lines
41 KiB
Python
880 lines
41 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 (
|
|
_ALWAYS_STRIP_KEYS, _HERMES_PROVIDER_ENV_BLOCKLIST, _HERMES_PROVIDER_ENV_FORCE_PREFIX,
|
|
_is_hermes_internal_secret, _is_terminal_first_party_env,
|
|
_matches_terminal_first_party_prefix, _plugin_terminal_env_strip_keys)
|
|
from tools.environments.local_gitbash_probe import (
|
|
_bash_probe_details_cache, _bash_starts, _git_bash_aslr_help,
|
|
_looks_like_msys_spawn_failure, _mandatory_aslr_enabled)
|
|
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.
|
|
TERMINAL_TEMP_MAX_AGE_HOURS = 72
|
|
_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: int = TERMINAL_TEMP_MAX_AGE_HOURS) -> int:
|
|
"""Delete session temp artifacts older than *max_age_hours*; return count.
|
|
Only the managed default dir is pruned — never a user-pointed ``terminal.temp_dir``."""
|
|
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 (group_newest[m.group(1)] if m else mt) >= 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)
|
|
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 in plugin_strip:
|
|
continue
|
|
first_party = _is_terminal_first_party_env(key)
|
|
passthrough = is_env_passthrough(key)
|
|
if key in _HERMES_PROVIDER_ENV_BLOCKLIST 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)
|
|
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) -> dict[str, str]:
|
|
"""Sanitized env for the **non-terminal** spawn surface (browser, ACP/CLI executors,
|
|
computer-use driver, TUI Node host). Tier 1 (``_ALWAYS_STRIP_KEYS``, plugin keys,
|
|
force-prefixed hints, dynamic internal secrets) is always removed; Tier 2 (the
|
|
provider/tool blocklist) unless ``inherit_credentials`` — pass that **only** for
|
|
children that legitimately need LLM credentials (user-blessed claude/codex/gemini
|
|
CLI, TUI Node host). Terminal/execute_code use ``_sanitize_subprocess_env``."""
|
|
env = os.environ.copy()
|
|
strip = _ALWAYS_STRIP_KEYS | _plugin_terminal_env_strip_keys()
|
|
if not inherit_credentials:
|
|
strip |= _HERMES_PROVIDER_ENV_BLOCKLIST
|
|
for key in list(env):
|
|
if (key in strip or key.startswith(_HERMES_PROVIDER_ENV_FORCE_PREFIX)
|
|
or _is_hermes_internal_secret(key)):
|
|
del env[key]
|
|
env.setdefault("PYTHONUTF8", "1") # Windows UTF-8 safety for spawned processes
|
|
return _finalize_child_env(env)
|
|
|
|
|
|
def build_subprocess_env(
|
|
base: "Mapping[str, str] | None" = None, *, inherit_profile_home: bool = True,
|
|
scrub_secrets: bool = True, extra: "Mapping[str, str] | None" = None) -> dict[str, str]:
|
|
"""Single factory for child-process 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."""
|
|
env: dict[str, str] = dict(base) if base is not None else os.environ.copy()
|
|
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 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 outside multiplex or when the target IS the
|
|
launch profile."""
|
|
from agent.secret_scope import _is_global_env, is_multiplex_active, load_env_file
|
|
from hermes_constants import get_hermes_home_override, get_process_hermes_home
|
|
target = target_home or get_hermes_home_override()
|
|
if not is_multiplex_active() or not target:
|
|
return env
|
|
launch_home = get_process_hermes_home()
|
|
if Path(target).resolve() == launch_home.resolve():
|
|
return env
|
|
from hermes_cli.config import TERMINAL_CONFIG_ENV_MAP
|
|
from hermes_cli.env_loader import launch_dotenv_keys, managed_dotenv_keys, source_supplied_names
|
|
# 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.
|
|
residue = set(load_env_file(launch_home / ".env")) | set(launch_dotenv_keys()) | set(TERMINAL_CONFIG_ENV_MAP.values())
|
|
residue |= set(source_supplied_names())
|
|
residue -= set(managed_dotenv_keys())
|
|
for key in residue:
|
|
if not _is_global_env(key) or key.startswith("TERMINAL_"):
|
|
env.pop(key, None)
|
|
return 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 _windows_bash_candidates(custom: "str | None") -> list[str]:
|
|
"""Ordered bash.exe candidates on Windows: HERMES_GIT_BASH_PATH, our portable Git
|
|
under %LOCALAPPDATA%\\hermes\\git (PortableGit ``bin`` and MinGit ``usr\\bin``),
|
|
known Git-for-Windows dirs, then PATH last — ``shutil.which`` may return WSL's
|
|
bash, which fails silently on Windows paths."""
|
|
getenv = os.environ.get
|
|
lad = getenv("LOCALAPPDATA", "")
|
|
roots = [
|
|
lad and os.path.join(lad, "hermes", "git", "bin"),
|
|
lad and os.path.join(lad, "hermes", "git", "usr", "bin"),
|
|
os.path.join(getenv("ProgramFiles", r"C:\Program Files"), "Git", "bin"),
|
|
os.path.join(getenv("ProgramFiles(x86)", r"C:\Program Files (x86)"), "Git", "bin"),
|
|
lad and os.path.join(lad, "Programs", "Git", "bin"),
|
|
]
|
|
raw = [custom or "", *(os.path.join(r, "bash.exe") for r in roots if r)]
|
|
candidates = list(dict.fromkeys(c for c in raw if c and os.path.isfile(c)))
|
|
found = shutil.which("bash")
|
|
if found and found not in candidates:
|
|
candidates.append(found)
|
|
return candidates
|
|
|
|
|
|
def _find_bash() -> str:
|
|
"""Find bash for command execution."""
|
|
if not _IS_WINDOWS:
|
|
return (shutil.which("bash")
|
|
or next((p for p in ("/usr/bin/bash", "/bin/bash") if os.path.isfile(p)), None)
|
|
or os.environ.get("SHELL") or "/bin/sh")
|
|
custom = os.environ.get("HERMES_GIT_BASH_PATH")
|
|
candidates = _windows_bash_candidates(custom)
|
|
# First candidate that can actually start wins: a stale HERMES_GIT_BASH_PATH
|
|
# pointing at a broken install must not beat a healthy portable Git.
|
|
for candidate in candidates:
|
|
if _bash_starts(candidate):
|
|
if candidate != custom and custom and os.path.isfile(custom):
|
|
logger.warning(
|
|
"HERMES_GIT_BASH_PATH=%s fails to start; using %s instead", custom, candidate)
|
|
return candidate
|
|
if candidates:
|
|
probe_details = "\n".join(
|
|
detail for c in candidates if (detail := _bash_probe_details_cache.get(c)))
|
|
if _mandatory_aslr_enabled() is True or _looks_like_msys_spawn_failure(probe_details):
|
|
raise RuntimeError(_git_bash_aslr_help(candidates[0], probe_details))
|
|
# Unknown failure class: return the first path so the caller sees the
|
|
# real bash error instead of a less useful "not found".
|
|
return candidates[0]
|
|
raise RuntimeError(
|
|
"Git Bash not found. Hermes Agent requires Git for Windows on Windows.\n"
|
|
"Install it from: https://git-scm.com/download/win\n"
|
|
"Or set HERMES_GIT_BASH_PATH to your bash.exe location.")
|
|
|
|
|
|
_git_bash_bin_dirs_cache: "list[str] | None" = None
|
|
|
|
|
|
def _git_bash_bin_dirs() -> list[str]:
|
|
"""Git Bash's coreutils dirs in ``/etc/profile`` order (mingw first so coreutils
|
|
beat System32 lookalikes); ``[]`` off Windows. A non-login ``bash -c`` (fallback
|
|
when ``bash -l`` is broken) never sources ``/etc/profile``, so without these
|
|
``cat``/``mktemp``/``mv`` are missing 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
|
|
|
|
|
|
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: ``which``; absolute ``sys.argv[0]`` naming a real
|
|
hermes executable; ``sys.executable``'s dir if it holds the shim."""
|
|
global _HERMES_BIN_DIR
|
|
if _HERMES_BIN_DIR is not _SENTINEL:
|
|
return _HERMES_BIN_DIR # type: ignore[return-value]
|
|
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
|
|
return _HERMES_BIN_DIR
|
|
|
|
|
|
def _prepend_hermes_bin_dir(existing_path: str) -> str:
|
|
"""Prepend the hermes install dir to ``existing_path`` if missing."""
|
|
bin_dir = _resolve_hermes_bin_dir()
|
|
return _prepend_missing_path_entries(existing_path, [bin_dir] if bin_dir else [])
|
|
|
|
|
|
def _managed_runtime_path_entries() -> list[str]:
|
|
"""Existing Hermes-managed runtime dirs: ``$HERMES_HOME/node`` (+``/bin``) and
|
|
``$HERMES_HOME/bin`` (managed ``uv``). Per call, not cached: home is
|
|
profile-scoped and a managed tree can appear mid-process."""
|
|
try:
|
|
from hermes_constants import get_hermes_home, iter_hermes_node_dirs
|
|
return [str(d) for d in (*iter_hermes_node_dirs(), get_hermes_home() / "bin") if d.is_dir()]
|
|
except Exception:
|
|
return []
|
|
|
|
|
|
def _append_missing_sane_path_entries(existing_path: str) -> str:
|
|
"""Normalised POSIX PATH with missing sane entries appended: empty entries
|
|
dropped (shells read them as cwd), duplicates collapsed (first wins), then
|
|
missing ``_SANE_PATH`` / managed-runtime dirs appended so user entries keep
|
|
precedence. Windows is a no-op passthrough (native ``;`` PATH untouched)."""
|
|
if _IS_WINDOWS:
|
|
return existing_path
|
|
# 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()]))
|
|
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."""
|
|
return _scrubbed_env([(dict(os.environ | env), True)], frozenset(),
|
|
lambda p: _prepend_git_bash_dirs(_append_missing_sane_path_entries(p)))
|
|
|
|
|
|
# --- 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 _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)."""
|
|
try:
|
|
pgid = os.getpgid(proc.pid)
|
|
except ProcessLookupError:
|
|
if (pgid := getattr(proc, "_hermes_pgid", None)) is None:
|
|
raise
|
|
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 = []
|
|
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):
|
|
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
|
|
_sweep_escaped_descendants(descendants, pgid)
|
|
|
|
|
|
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 /tmp), ``HERMES_HOME/cache/terminal`` (real storage: tmpfs /tmp
|
|
fills under Hermes load; pruned by ``cleanup_terminal_temp_cache``), /tmp,
|
|
``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:
|
|
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
|
|
if os.path.isdir("/tmp") and os.access("/tmp", os.W_OK | os.X_OK):
|
|
return "/tmp"
|
|
fallback = tempfile.gettempdir()
|
|
return _posix(fallback) if fallback.startswith("/") else "/tmp"
|
|
|
|
@staticmethod
|
|
def _quote_cwd_for_cd(cwd: str) -> str:
|
|
"""Use native paths for Python, but Git Bash-friendly paths for cd."""
|
|
return BaseEnvironment._quote_cwd_for_cd(_windows_to_msys_path(cwd))
|
|
|
|
def _quote_shell_path(self, path: str) -> str:
|
|
"""Rewrite native/mixed Windows paths before quoting for Git Bash."""
|
|
return _quote_bash_path(path)
|
|
|
|
def _recover_cwd(self) -> None:
|
|
"""Swap ``self.cwd`` for a usable directory if it vanished or is inaccessible
|
|
(e.g. a 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)
|
|
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 _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)
|