Files
hermes-agent/pm/shell.py
ethernet f0667b7048 fix(pm): find conventional bash without a PATH entry
The fallback required a prior PATH hit, so a normal Git for Windows
installation could not satisfy an empty-PATH terminal. Check the
existing conventional locations independently. Keep the staged tool
and usable PATH precedence unchanged.

The native test calls the actual local resolver and executes the
selected shell with an empty PATH and an unusable WindowsApps alias.
The focused shell gate passed. Other hosts retain their existing path.
2026-09-09 20:43:35 -04:00

86 lines
2.9 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. Provisioned PATH: shutil.which("bash") — the store dirs are on the
process PATH after pm.activate() ran.
3. Conventional Git for Windows under Program Files, even with an empty PATH.
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 os
import shutil
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
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")
# An MSIX-packaged bash (WindowsApps alias) only spawns inside its
# package's context — from an arbitrary process (dev checkout, test
# child, emulated-python CreateProcess) it fails with WinError 5.
# Prefer a conventional install when one exists.
if on_path and "windowsapps" not in on_path.lower():
return on_path
if os.name == "nt":
programfiles = os.environ.get("ProgramFiles", r"C:\Program Files")
for rel in (("Git", "bin", "bash.exe"), ("Git", "usr", "bin", "bash.exe")):
cand = os.path.join(programfiles, *rel)
if os.path.isfile(cand):
return cand
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