Files
hermes-agent/tests/tools/test_find_shell.py
ethernet 30da5d0a89 test: run os-specific tests on their real host, not a faked one
many tests patched sys.platform or a module's _IS_WINDOWS flag, then
ran on linux ci. the patch selects the branch under test, but the host
does not have the behavior the branch exists for. the test proves the
patch, not the platform. some gated assertions never ran on any host.

this commit adds three markers: linux_only, macos_only, windows_only.
a conftest hook skips a marked test on the other hosts, with a clear
reason. no test fakes a host now. two documented fakes remain
(android/termux, freebsd) because no ci runner exists for them.

each fake site got one of four treatments:
- gate it: the real host supplies the platform; mocks cover real
  dependencies only, never host identity
- patch the module's own probe when the subject is the probe's consumer
- assert against the real host when the fake stood in for any non-x host
- delete the patch when it set the value the host already has

bare skipif(sys.platform != ...) guards became markers too. the lane
model skips these on linux and never imports them on windows, so they
ran on no host. platform parametrize tables are now one marked test
per os.

running on real hosts found real errors: a chrome-sandbox failure in
test_gui_command that main hides, and two windows failures fixed here.
the agents.md testing section now documents the policy.
2026-08-09 22:09:49 -04:00

275 lines
11 KiB
Python

"""Tests for _find_shell — user-login-shell preference on POSIX.
Regression tests for #42203: on macOS, ``_find_shell`` used to return
``/bin/bash`` (bash 3.2) which silently swallowed background commands
when ``~/.bash_profile`` contained ``exec /bin/zsh -l``.
"""
import os
import platform
import subprocess
from unittest.mock import patch
import pytest
from tools.environments.local import _find_bash, _find_shell
class TestFindShellPrefersUserShell:
"""_find_shell should prefer $SHELL over bash on POSIX."""
def test_returns_shell_env_when_set_and_exists(self, tmp_path):
"""When $SHELL points to an existing allowlisted executable, _find_shell returns it."""
fake_zsh = tmp_path / "zsh"
fake_zsh.touch()
fake_zsh.chmod(0o755)
with patch.dict(os.environ, {"SHELL": str(fake_zsh)}):
assert _find_shell() == str(fake_zsh)
def test_falls_back_when_shell_not_executable(self, tmp_path):
"""$SHELL exists but lacks the execute bit -> fall back to _find_bash
(returning it would fail at spawn time)."""
fake = tmp_path / "zsh"
fake.touch()
fake.chmod(0o644) # not executable
with patch.dict(os.environ, {"SHELL": str(fake)}):
assert _find_shell() == _find_bash()
def test_falls_back_for_incompatible_shell_fish(self, tmp_path):
"""#42203 regression: $SHELL=fish must NOT be returned — spawn_local's
`-lic` / `set +m` syntax breaks fish, which would trade the bash-3.2
swallow for a parse error on every background command. Fall back to bash."""
fake_fish = tmp_path / "fish"
fake_fish.touch()
fake_fish.chmod(0o755)
with patch.dict(os.environ, {"SHELL": str(fake_fish)}):
assert _find_shell() == _find_bash()
def test_honours_allowlisted_bash_and_dash(self, tmp_path):
"""Every allowlisted POSIX-sh-family shell is honoured."""
for name in ("bash", "dash", "sh", "ksh"):
fake = tmp_path / name
fake.touch()
fake.chmod(0o755)
with patch.dict(os.environ, {"SHELL": str(fake)}):
assert _find_shell() == str(fake), name
def test_falls_back_to_find_bash_when_shell_empty(self):
"""When $SHELL is empty string, _find_shell delegates."""
with patch.dict(os.environ, {"SHELL": ""}):
assert _find_shell() == _find_bash()
class TestFindShellWindowsBehavior:
"""On Windows, _find_shell always delegates to _find_bash."""
@pytest.mark.windows_only
def test_windows_ignores_shell_env(self):
"""On Windows, $SHELL is ignored — _find_shell delegates to _find_bash.
Windows-only: faking ``_IS_WINDOWS`` selected the branch but left
``_find_bash`` resolving a POSIX bash, so the equality proved nothing
about Git-Bash resolution on the real host.
"""
# Even if SHELL is set, it should be ignored on Windows
with patch.dict(os.environ, {"SHELL": "/usr/bin/zsh"}):
result = _find_shell()
assert result == _find_bash()
class TestFindShellReturnsString:
"""_find_shell must return a string, never None."""
def test_returns_string(self):
"""_find_shell always returns a non-empty string on any platform."""
result = _find_shell()
assert isinstance(result, str)
assert len(result) > 0
class TestFindBashUnchanged:
"""_find_bash should be unaffected by the _find_shell change."""
def test_find_bash_still_prefers_bash(self):
"""_find_bash still returns bash (not $SHELL) on POSIX."""
result = _find_bash()
# On any system, _find_bash should return something containing "bash"
# or fall back to $SHELL or /bin/sh — but it should NOT prefer $SHELL
# over bash the way _find_shell does.
assert isinstance(result, str)
assert len(result) > 0
class TestFindBashSkipsBrokenCustomPath:
"""Stale HERMES_GIT_BASH_PATH must not brick Windows terminal startup."""
@pytest.mark.windows_only
def test_falls_through_to_portable_when_custom_fails_probe(self, tmp_path, monkeypatch):
"""Windows-only: the candidate ladder (HERMES_GIT_BASH_PATH →
%LOCALAPPDATA%\\hermes\\git → Program Files) only exists in
``_find_bash``'s Windows branch."""
import tools.environments.local as local_mod
local_mod._bash_starts_cache.clear()
broken = tmp_path / "broken" / "bash.exe"
broken.parent.mkdir()
broken.write_text("", encoding="utf-8")
portable = tmp_path / "hermes" / "git" / "bin" / "bash.exe"
portable.parent.mkdir(parents=True)
portable.write_text("", encoding="utf-8")
monkeypatch.setenv("HERMES_GIT_BASH_PATH", str(broken))
monkeypatch.setenv("LOCALAPPDATA", str(tmp_path))
def fake_starts(path: str) -> bool:
return path == str(portable)
monkeypatch.setattr(local_mod, "_bash_starts", fake_starts)
assert _find_bash() == str(portable)
class TestGitBashExternalProgramProbe:
"""The Windows health check must exercise MSYS child-process creation."""
def test_probe_runs_external_msys_programs(self, monkeypatch):
"""``_bash_starts`` builds the same external-program probe argv on
every host, so this stays on the Linux runner with ``subprocess.run``
mocked — no platform faking needed."""
import tools.environments.local as local_mod
local_mod._bash_starts_cache.clear()
local_mod._bash_probe_details_cache.clear()
calls = []
def fake_run(argv, **kwargs):
calls.append((argv, kwargs))
return subprocess.CompletedProcess(argv, 0, stdout="", stderr="")
monkeypatch.setattr(local_mod.subprocess, "run", fake_run)
assert local_mod._bash_starts(r"C:\Git\bin\bash.exe") is True
assert calls[0][0][-1] == "/usr/bin/true; /usr/bin/cat --version >/dev/null"
@pytest.mark.windows_only
def test_aslr_failure_surfaces_targeted_windows_command(
self, tmp_path, monkeypatch
):
"""Windows-only: the Mandatory-ASLR diagnostic is raised from
``_find_bash``'s Windows candidate ladder and names PowerShell's
``Set-ProcessMitigation`` — unreachable off Windows."""
import tools.environments.local as local_mod
local_mod._bash_starts_cache.clear()
local_mod._bash_probe_details_cache.clear()
portable = tmp_path / "hermes" / "git" / "bin" / "bash.exe"
portable.parent.mkdir(parents=True)
portable.write_text("", encoding="utf-8")
monkeypatch.setenv("HERMES_GIT_BASH_PATH", "")
monkeypatch.setenv("LOCALAPPDATA", str(tmp_path))
monkeypatch.setenv("ProgramFiles", str(tmp_path / "empty-program-files"))
monkeypatch.delenv("ProgramFiles(x86)", raising=False)
monkeypatch.setattr(local_mod.shutil, "which", lambda _name: None)
monkeypatch.setattr(local_mod, "_mandatory_aslr_enabled", lambda: True)
def failed_probe(path: str) -> bool:
local_mod._bash_probe_details_cache[path] = (
"dofork: child -1 - forked process died unexpectedly"
)
return False
monkeypatch.setattr(local_mod, "_bash_starts", failed_probe)
with pytest.raises(RuntimeError) as exc_info:
local_mod._find_bash()
message = str(exc_info.value)
assert "Mandatory ASLR" in message
assert "Reinstalling Git will not change" in message
assert "Set-ProcessMitigation" in message
assert str(tmp_path / "hermes" / "git") in message
@pytest.mark.macos_only
@pytest.mark.skipif(
not os.path.isfile("/bin/bash"),
reason="reproduces the macOS system-bash-3.2 login-shell swallow",
)
class TestMacosLoginShellSwallowRegression:
"""E2E regression for #42203: the actual failure is that system bash 3.2,
invoked as a login shell (`-lic`) with stdin=/dev/null and a
~/.bash_profile that `exec`s zsh, silently swallows the command (exit 0,
no output, no side effects). Prove (a) the bug exists with /bin/bash and
(b) the zsh path _find_shell prefers does NOT swallow."""
def _spawn_like_registry(self, shell, command, home, tmp_path):
import subprocess
env = dict(os.environ)
env["HOME"] = str(home)
# Mirror process_registry.spawn_local: [shell, "-lic", "set +m; <cmd>"]
# with stdin redirected to /dev/null.
return subprocess.run(
[shell, "-lic", f"set +m; {command}"],
stdin=subprocess.DEVNULL,
capture_output=True,
text=True,
env=env,
)
def test_system_bash_swallows_but_zsh_does_not(self, tmp_path):
# A .bash_profile that exec's zsh — the reported macOS shape.
home = tmp_path / "home"
home.mkdir()
(home / ".bash_profile").write_text("exec /bin/zsh -l\n")
# Use /bin/zsh explicitly rather than $SHELL. The reported bug is
# specifically "system bash 3.2 swallows, zsh does not", and $SHELL is
# not zsh everywhere this runs: GitHub's macOS runner exports
# SHELL=/bin/bash, which silently turned the control arm into a SECOND
# bash arm. It then swallowed (correctly, per the bug!) and the
# assertion read as "the fix path is broken" when nothing was broken.
# /bin/zsh is the macOS default login shell since Catalina and is
# present on every supported version.
zsh = "/bin/zsh"
if not os.path.isfile(zsh):
pytest.skip("no zsh available")
marker_bash = tmp_path / "bash_ran"
marker_zsh = tmp_path / "zsh_ran"
# /bin/bash login shell: command is swallowed (file NOT created).
self._spawn_like_registry("/bin/bash", f"echo x > {marker_bash}", home, tmp_path)
# zsh (what _find_shell prefers when $SHELL is zsh): command runs.
self._spawn_like_registry(zsh, f"echo x > {marker_zsh}", home, tmp_path)
# The FIX path (zsh) must run the command.
assert marker_zsh.exists(), "zsh path must run the command"
# Differential: when /bin/bash is the swallow-prone 3.x (macOS system
# bash), the login-shell invocation must demonstrably FAIL to run the
# command — that's the bug this PR routes around. Only assert the
# negative when we've confirmed a 3.x bash, so the test stays valid on
# boxes/CI with a newer /bin/bash that doesn't swallow.
ver = subprocess.run(
["/bin/bash", "--version"], capture_output=True, text=True
).stdout
if "version 3." in ver:
assert not marker_bash.exists(), (
"system bash 3.x login shell should swallow the command "
"(the #42203 bug); _find_shell routes around it by preferring zsh"
)
def test_find_shell_selects_working_shell_on_this_box(self, tmp_path):
"""_find_shell's choice must actually execute a background-style
command (regression against returning a swallow-prone shell)."""
shell = _find_shell()
marker = tmp_path / "ok_marker"
subprocess.run(
[shell, "-lic", f"set +m; echo ok > {marker}"],
stdin=subprocess.DEVNULL, capture_output=True, text=True,
)
assert marker.exists(), f"_find_shell()={shell} swallowed the command"