{"repo":"NousResearch/hermes-agent","public_id":"42203","display_label":"Shell detection fixes across Windows and macOS","summary":"These issues and PRs address problems with how the terminal selects shells for background and foreground processes, preferring $SHELL over hardcoded bash on macOS and Git Bash on Windows. Covers path handling, login shell behavior swallowing commands, and related test isolation concerns.","subsystem":"agent","counts":{"members":7,"open_issues":0,"open_issues_distinct":0,"open_prs":1,"closed_issues":1,"merged_prs":1,"attempt_prs":5},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":53110,"merged_at":"2026-06-26T15:41:57+00:00"},"resolution":{"state":"resolved","by":"pr-53110"},"freshness":{"state":"verified","last_verified_at":"2026-06-26T15:41:57+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_SPANNING","link_density":0.2857142857142857},"fix_edges":[{"issue_number":42203,"pr_number":53110,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true,"pr_merged_at":"2026-06-26T15:41:57+00:00"}],"members":[{"kind":"issue","number":42203,"title":"macOS: background processes (terminal background=true) silently fail — _find_shell returns bash whose login shell (-l) swallows commands","url":null,"state":"closed"},{"kind":"pr","number":42219,"title":"fix(terminal): prefer $SHELL over bash for background process spawning","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#53106","number":53106}],"category":"uncategorized","implication":"needs_llm"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":42290,"title":"fix(terminal): prefer $SHELL on macOS to avoid bash swallowing cmds (#42203)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#53110","number":53110},{"kind":"pr","value":"#42203","number":42203},{"kind":"pr","value":"#42219","number":42219}],"category":"superseded_by_pr","implication":"superseded"},"merged":false},{"kind":"pr","number":53106,"title":"fix(terminal): prefer $SHELL over bash for background process spawning (#42203)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#53110","number":53110},{"kind":"pr","value":"#42203","number":42203},{"kind":"pr","value":"#42219","number":42219}],"category":"superseded_by_pr","implication":"superseded"},"merged":false},{"kind":"pr","number":53110,"title":"fix(terminal): prefer $SHELL over bash for background process spawning (#42203)","url":null,"state":"merged","close_reason":{"category":"merged"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["tests/tools/test_find_shell.py","tools/environments/local.py"],"fix_hunks":[{"file":"tests/tools/test_find_shell.py","hunks":["@@ -0,0 +1,188 @@\n+\"\"\"Tests for _find_shell — user-login-shell preference on POSIX.\n+\n+Regression tests for #42203: on macOS, ``_find_shell`` used to return\n+``/bin/bash`` (bash 3.2) which silently swallowed background commands\n+when ``~/.bash_profile`` contained ``exec /bin/zsh -l``.\n+\"\"\"\n+\n+import os\n+import platform\n+import subprocess\n+import sys\n+from unittest.mock import patch\n+\n+import pytest\n+\n+from tools.environments.local import _find_bash, _find_shell\n+\n+\n+class TestFindShellPrefersUserShell:\n+    \"\"\"_find_shell should prefer $SHELL over bash on POSIX.\"\"\"\n+\n+    def test_returns_shell_env_when_set_and_exists(self, tmp_path):\n+        \"\"\"When $SHELL points to an existing allowlisted executable, _find_shell returns it.\"\"\"\n+        fake_zsh = tmp_path / \"zsh\"\n+        fake_zsh.touch()\n+        fake_zsh.chmod(0o755)\n+        with patch.dict(os.environ, {\"SHELL\": str(fake_zsh)}):\n+            assert _find_shell() == str(fake_zsh)\n+\n+    def test_falls_back_when_shell_not_executable(self, tmp_path):\n+        \"\"\"$SHELL exists but lacks the execute bit -> fall back to _find_bash\n+        (returning it would fail at spawn time).\"\"\"\n+        fake = tmp_path / \"zsh\"\n+        fake.touch()\n+        fake.chmod(0o644)  # not executable\n+        with patch.dict(os.environ, {\"SHELL\": str(fake)}):\n+            assert _find_shell() == _find_bash()\n+\n+    def test_falls_back_for_incompatible_shell_fish(self, tmp_path):\n+        \"\"\"#42203 regression: $SHELL=fish must NOT be returned — spawn_local's\n+        `-lic` / `set +m` syntax breaks fish, which would trade the bash-3.2\n+        swallow for a parse error on every background command. Fall back to bash.\"\"\"\n+        fake_fish = tmp_path / \"fish\"\n+        fake_fish.touch()\n+        fake_fish.chmod(0o755)\n+        with patch.dict(os.environ, {\"SHELL\": str(fake_fish)}):\n+            assert _find_shell() == _find_bash()\n+\n+    def test_falls_back_for_incompatible_shell_csh(self, tmp_path):\n+        \"\"\"$SHELL=tcsh/csh is also not -lic/set+m compatible -> fall back.\"\"\"\n+        fake = tmp_path / \"tcsh\"\n+        fake.touch()\n+        fake.chmod(0o755)\n+        with patch.dict(os.environ, {\"SHELL\": str(fake)}):\n+            assert _find_shell() == _find_bash()\n+\n+    def test_honours_allowlisted_bash_and_dash(self, tmp_path):\n+        \"\"\"Every allowlisted POSIX-sh-family shell is honoured.\"\"\"\n+        for name in (\"bash\", \"dash\", \"sh\", \"ksh\"):\n+            fake = tmp_path / name\n+            fake.touch()\n+            fake.chmod(0o755)\n+            with patch.dict(os.environ, {\"SHELL\": str(fake)}):\n+                assert _find_shell() == str(fake), name\n+\n+    def test_falls_back_to_find_bash_when_shell_unset(self):\n+        \"\"\"When $SHELL is unset, _find_shell delegates to _find_bash.\"\"\"\n+        env = {k: v for k, v in os.environ.items() if k != \"SHELL\"}\n+        with patch.dict(os.environ, env, clear=True):\n+            assert _find_shell() == _find_bash()\n+\n+    def test_falls_back_to_find_bash_when_shell_not_a_file(self, tmp_path):\n+        \"\"\"When $SHELL points to a non-existent path, _find_shell delegates.\"\"\"\n+        fake_path = str(tmp_path / \"nonexistent_shell\")\n+        with patch.dict(os.environ, {\"SHELL\": fake_path}):\n+            assert _find_shell() == _find_bash()\n+\n+    def test_falls_back_to_find_bash_when_shell_empty(self):\n+        \"\"\"When $SHELL is empty string, _find_shell delegates.\"\"\"\n+        with patch.dict(os.environ, {\"SHELL\": \"\"}):\n+            assert _find_shell() == _find_bash()\n+\n+\n+class TestFindShellWindowsBehavior:\n+    \"\"\"On Windows, _find_shell always delegates to _find_bash.\"\"\"\n+\n+    def test_windows_ignores_shell_env(self):\n+        \"\"\"On Windows, $SHELL is ignored — _find_shell delegates to _find_bash.\"\"\"\n+        with patch(\"tools.environments.local._IS_WINDOWS\", True):\n+            # Even if SHELL is set, it should be ignored on Windows\n+            with patch.dict(os.environ, {\"SHELL\": \"/usr/bin/zsh\"}):\n+                result = _find_shell()\n+                assert result == _find_bash()\n+\n+\n+class TestFindShellReturnsString:\n+    \"\"\"_find_shell must return a string, never None.\"\"\"\n+\n+    def test_returns_string(self):\n+        \"\"\"_find_shell always returns a non-empty string on any platform.\"\"\"\n+        result = _find_shell()\n+        assert isinstance(result, str)\n+        assert len(result) > 0\n+\n+\n+class TestFindBashUnchanged:\n+    \"\"\"_find_bash should be unaffected by the _find_shell change.\"\"\"\n+\n+    def test_find_bash_still_prefers_bash(self):\n+        \"\"\"_find_bash still returns bash (not $SHELL) on POSIX.\"\"\"\n+        result = _find_bash()\n+        # On any system, _find_bash should return something containing \"bash\"\n+        # or fall back to $SHELL or /bin/sh — but it should NOT prefer $SHELL\n+        # over bash the way _find_shell does.\n+        assert isinstance(result, str)\n+        assert len(result) > 0\n+\n+\n[redacted:email](\n+    not os.path.isfile(\"/bin/bash\") or sys.platform != \"darwin\",\n+    reason=\"reproduces the macOS system-bash-3.2 login-shell swallow\",\n+)\n+class TestMacosLoginShellSwallowRegression:\n+    \"\"\"E2E regression for #42203: the actual failure is that system bash 3.2,\n+    invoked as a login shell (`-lic`) with stdin=/dev/null and a\n+    ~/.bash_profile that `exec`s zsh, silently swallows the command (exit 0,\n+    no output, no side effects). Prove (a) the bug exists with /bin/bash and\n+    (b) the $SHELL (zsh) path _find_shell prefers does NOT swallow.\"\"\"\n+\n+    def _spawn_like_registry(self, shell, command, home, tmp_path):\n+        import subprocess\n+        env = dict(os.environ)\n+        env[\"HOME\"] = str(home)\n+        # Mirror process_registry.spawn_local: [shell, \"-lic\", \"set +m; <cmd>\"]\n+        # with stdin redirected to /dev/null.\n+        return subprocess.run(\n+            [shell, \"-lic\", f\"set +m; {command}\"],\n+            stdin=subprocess.DEVNULL,\n+            capture_output=True,\n+            text=True,\n+            env=env,\n+        )\n+\n+    def test_system_bash_swallows_but_zsh_does_not(self, tmp_path):\n+        # A .bash_profile that exec's zsh — the reported macOS shape.\n+        home = tmp_path / \"home\"\n+        home.mkdir()\n+        (home / \".bash_profile\").write_text(\"exec /bin/zsh -l\\n\")\n+\n+        zsh = os.environ.get(\"SHELL\") or \"/bin/zsh\"\n+        if not os.path.isfile(zsh):\n+            pytest.skip(\"no zsh available\")\n+\n+        marker_bash = tmp_path / \"bash_ran\"\n+        marker_zsh = tmp_path / \"zsh_ran\"\n+\n+        # /bin/bash login shell: command is swallowed (file NOT created).\n+        self._spawn_like_registry(\"/bin/bash\", f\"echo x > {marker_bash}\", home, tmp_path)\n+        # zsh (the $SHELL _find_shell prefers): command runs (file created).\n+        self._spawn_like_registry(zsh, f\"echo x > {marker_zsh}\", home, tmp_path)\n+\n+        # The FIX path (zsh) must run the command.\n+        assert marker_zsh.exists(), \"zsh ($SHELL) path must run the command\"\n+\n+        # Differential: when /bin/bash is the swallow-prone 3.x (macOS system\n+        # bash), the login-shell invocation must demonstrably FAIL to run the\n+        # command — that's the bug this PR routes around. Only assert the\n+        # negative when we've confirmed a 3.x bash, so the test stays valid on\n+        # boxes/CI with a newer /bin/bash that doesn't swallow.\n+        ver = subprocess.run(\n+            [\"/bin/bash\", \"--version\"], capture_output=True, text=True\n+        ).stdout\n+        if \"version 3.\" in ver:\n+            assert not marker_bash.exists(), (\n+                \"system bash 3.x login shell should swallow the command \"\n+                \"(the #42203 bug); _find_shell routes around it by preferring zsh\"\n+            )\n+\n+    def test_find_shell_selects_working_shell_on_this_box(self, tmp_path):\n+        \"\"\"_find_shell's choice must actually execute a background-style\n+        command (regression against returning a swallow-prone shell).\"\"\"\n+        shell = _find_shell()\n+        marker = tmp_path / \"ok_marker\"\n+        subprocess.run(\n+            [shell, \"-lic\", f\"set +m; echo ok > {marker}\"],\n+            stdin=subprocess.DEVNULL, capture_output=True, text=True,\n+        )\n+        assert marker.exists(), f\"_find_shell()={shell} swallowed the command\""]},{"file":"tools/environments/local.py","hunks":["@@ -288,8 +288,53 @@ def _find_bash() -> str:\n     )\n \n \n-# Backward compat — process_registry.py imports this name\n-_find_shell = _find_bash\n+# POSIX-sh-family shells that understand the ``[shell, \"-lic\", \"set +m; …\"]``\n+# invocation spawn_local uses. $SHELL values outside this set (fish, csh/tcsh,\n+# nushell, elvish, xonsh, …) would error on that syntax, so _find_shell falls\n+# back to bash for them rather than honouring $SHELL. (#42203)\n+_SPAWN_COMPATIBLE_SHELLS = frozenset({\"bash\", \"zsh\", \"sh\", \"dash\", \"ksh\", \"mksh\"})\n+\n+\n+def _find_shell() -> str:\n+    \"\"\"Find the user's login shell for background process spawning.\n+\n+    Unlike ``_find_bash`` (which always returns a bash binary for callers\n+    that explicitly need bash), this function prefers the user's configured\n+    ``$SHELL`` on POSIX so that ``spawn_local`` uses the shell the user\n+    actually logs in with.\n+\n+    On macOS Catalina+ the default login shell is zsh, but\n+    ``shutil.which(\"bash\")`` still finds the system ``/bin/bash`` (GNU bash\n+    3.2).  When bash 3.2 is invoked with ``-l`` (login) and stdin is\n+    ``/dev/null``, it sources ``~/.bash_profile`` which on many macOS setups\n+    contains ``exec /bin/zsh -l``.  That ``exec`` replaces bash with zsh but\n+    drops the ``-c`` argument, so the background command never runs — the\n+    subprocess exits 0 with no output and no side effects.\n+\n+    Preferring ``$SHELL`` (when it is a POSIX-``sh``-family shell) avoids this\n+    because zsh/bash/sh/dash/ksh handle ``-lic`` correctly even with\n+    redirected stdin.\n+\n+    Only POSIX-sh-family shells are honoured: ``spawn_local`` invokes the\n+    shell as ``[shell, \"-lic\", \"set +m; <cmd>\"]``, and that ``-lic`` bundle +\n+    ``set +m`` job-control syntax is NOT understood by fish, csh/tcsh,\n+    nushell, elvish, xonsh, etc.  Returning such a ``$SHELL`` would trade the\n+    bash-3.2 swallow for a parse error on every background command, so for any\n+    non-allowlisted shell we fall back to ``_find_bash`` (the prior behaviour).\n+\n+    On Windows, ``$SHELL`` is typically bash (Git Bash), so behaviour is\n+    unchanged — we fall through to ``_find_bash``.\n+    \"\"\"\n+    if not _IS_WINDOWS:\n+        user_shell = os.environ.get(\"SHELL\")\n+        if (\n+            user_shell\n+            and os.path.isfile(user_shell)\n+            and os.access(user_shell, os.X_OK)\n+            and Path(user_shell).name in _SPAWN_COMPATIBLE_SHELLS\n+        ):\n+            return user_shell\n+    return _find_bash()\n \n \n # Standard PATH entries for environments with minimal PATH.\n"]}]},{"kind":"pr","number":53166,"title":"fix: Windows compatibility — path handling, Git Bash detection, test isolation, and win32 skip markers","url":null,"state":"closed","close_reason":{"category":"superseded_by_pr","implication":"superseded"},"merged":false},{"kind":"pr","number":66769,"title":"fix(cron): prefer Git Bash for Windows shell scripts","url":null,"state":"open","close_reason":{"refs":[{"kind":"commit","value":"c7bf2bfedd53","number":0},{"kind":"pr","value":"#42219","number":42219},{"kind":"pr","value":"#42290","number":42290},{"kind":"pr","value":"#53106","number":53106},{"kind":"pr","value":"#53110","number":53110},{"kind":"pr","value":"#41456","number":41456},{"kind":"pr","value":"#66769","number":66769},{"kind":"pr","value":"#53166","number":53166},{"kind":"pr","value":"#42203","number":42203}],"category":"merged_via","implication":"superseded"},"merged":false}],"trust":{"default_tier":"verified","includes_suggested":false}}