Files
hermes-agent/pm/shell.py
ethernet 3917f11d79 fix(pm): skip System32/WindowsApps bash.exe, prefer Program Files Git
pm.shell.bash() returned shutil.which("bash") first on Windows. On most
machines that is C:\Windows\System32\bash.exe, the WSL launcher stub
(exits 1, "no installed distributions"), so a source install without the
staged git package lost its shell — the #116818 regression main fixed in
0abc3b04. The System32 guard survived only in local._windows_bash_candidates,
which had no callers after _find_bash collapsed onto pm.shell.

Move the ladder into pm.shell as a pure function (Program Files Git first,
then PATH minus the System32/WindowsApps stubs) so it is testable on any
host, and delete the dead copy in tools.environments.local.
2026-09-21 18:36:17 -04:00

108 lines
3.8 KiB
Python

"""pm.shell(): the one place Hermes resolves the shell it runs commands with.
Owned by pm because the shell is a bundled tool on Windows (Git for Windows
carries bash.exe), and the store is the authority on whether it exists.
Callers that need bash (the terminal backend, `_find_bash`) call this instead
of hunting fixed locations.
Resolution order:
1. Windows: the git Package's staged bash (via facts.json) — the store
structurally guarantees it in a bundle; no hunt.
2. Windows: conventional Git for Windows under Program Files, then PATH —
minus C:\Windows\System32\bash.exe (the WSL launcher stub, first on PATH
on most machines; #116818) and WindowsApps\bash.exe (an MSIX alias that
only spawns inside its package). See windows_bash_candidates().
3. Provisioned PATH: shutil.which("bash") — the store dirs are on the
process PATH after pm.activate() ran.
4. POSIX fallback table for non-bundle / daemon-launch PATH edge cases
(/usr/bin/bash, /bin/bash, $SHELL, /bin/sh). A systemd/cron-launched
gateway may have a minimal PATH and macOS /bin/bash is not on PATH by
default, so `which` alone is not enough there.
"""
from __future__ import annotations
import ntpath
import os
import shutil
from collections.abc import Mapping
from pm import paths
from pm.lock import Facts
def _staged_bash() -> str | None:
"""The git Package's bash.exe under the store (Windows bundles only)."""
import platform
if platform.system() != "Windows":
return None
facts_path = paths.facts_path()
if not facts_path.is_file():
return None
try:
facts = Facts(facts_path)
fact = facts.get("git")
if fact is None or "entry" not in fact:
return None
entry = paths.store_root() / fact["entry"]
for candidate in (
entry / "usr" / "bin" / "bash.exe",
entry / "bin" / "bash.exe",
):
if candidate.is_file():
return str(candidate)
except Exception:
return None
return None
# PATH dirs whose bash.exe is not a shell: System32 holds the WSL launcher
# stub (prints "no installed distributions", exits 1) and WindowsApps holds
# MSIX execution aliases that fail with WinError 5 from an arbitrary process.
_WINDOWS_BASH_STUB_DIRS = ("system32", "windowsapps")
def windows_bash_candidates(on_path: str | None, env: Mapping[str, str]) -> list[str]:
"""Ordered bash.exe candidates for a Windows host, as pure data: the
conventional Git for Windows dirs first, then ``on_path`` (the
``shutil.which("bash")`` result) unless it is a stub. Program Files beats
PATH because System32 precedes Git on most PATHs (#116818)."""
programfiles = env.get("ProgramFiles", r"C:\Program Files")
candidates = [
ntpath.join(programfiles, "Git", "bin", "bash.exe"),
ntpath.join(programfiles, "Git", "usr", "bin", "bash.exe"),
]
if on_path:
norm = ntpath.normpath(on_path).lower()
if not any(stub in norm for stub in _WINDOWS_BASH_STUB_DIRS):
candidates.append(on_path)
return candidates
def bash() -> str | None:
"""Resolve the bash binary to use, or None if none is available."""
staged = _staged_bash()
if staged:
return staged
on_path = shutil.which("bash")
if os.name == "nt":
return next(
(c for c in windows_bash_candidates(on_path, os.environ) if os.path.isfile(c)),
None,
)
if on_path:
return on_path
# POSIX fallbacks for minimal-PATH daemon launches / macOS /bin/bash.
for candidate in (
"/usr/bin/bash",
"/bin/bash",
os.environ.get("SHELL"),
"/bin/sh",
):
if candidate and os.path.isfile(candidate) and os.access(candidate, os.X_OK):
return candidate
return None