Skip to main content

agentmux_srv/backend/
posix_shell.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Locate the POSIX `sh` that `shellexec` (the `!cmd` composer prefix) runs
5//! its one-shot `sh -c` commands under.
6//!
7//! On Unix this is just `sh`. On Windows it was also just `sh`, which only
8//! resolves when srv's PATH happens to contain a Git Bash tool directory —
9//! true for a dev build started from a Git Bash terminal, false for an
10//! installed/portable build started from Explorer. Git for Windows' installer
11//! puts only `Git\cmd` on the system PATH by default, and that directory has
12//! `git.exe` but no `sh.exe`, so every `!cmd` failed with
13//! `shellexec: spawn failed: program not found`.
14//!
15//! Two traps shape the Windows search order:
16//!
17//! * `Git\usr\bin\sh.exe` (the real binary) cannot run `ls` unless its own
18//!   directory is already on PATH. `Git\bin\sh.exe` is Git's launcher: it
19//!   prepends `/mingw64/bin:/usr/bin` itself, so the fallbacks below point at
20//!   `bin\sh.exe`, never `usr\bin\sh.exe`.
21//! * `C:\Windows\System32\bash.exe` is WSL. A PATH search for `bash` would run
22//!   `!cmd` inside a Linux VM with different paths, so the PATH search looks
23//!   for `sh.exe` only (WSL ships no `sh.exe`).
24
25use std::ffi::OsString;
26use std::path::{Path, PathBuf};
27
28/// Explicit override: a path to a bash/sh executable. Same variable
29/// agentmux-bashwrap honours for its own bash lookup.
30#[cfg_attr(not(windows), allow(dead_code))]
31pub const SHELL_OVERRIDE_ENV: &str = "AGENTMUX_BASH";
32
33/// Resolve the program `shellexec` should spawn as `<program> -c <cmd>`.
34pub fn resolve_posix_shell() -> Result<PathBuf, String> {
35    #[cfg(windows)]
36    {
37        resolve_windows(&|k| std::env::var_os(k), &|p| p.is_file()).ok_or_else(|| {
38            format!(
39                "no POSIX shell found — `!` commands need Git for Windows \
40                 (https://git-scm.com/download/win), or set {SHELL_OVERRIDE_ENV} \
41                 to a bash.exe/sh.exe"
42            )
43        })
44    }
45    #[cfg(not(windows))]
46    {
47        Ok(PathBuf::from("sh"))
48    }
49}
50
51/// Windows search order, first hit wins:
52///
53/// 1. `AGENTMUX_BASH`, if it names an existing file.
54/// 2. `sh.exe` on PATH — the case that already worked (srv started from Git
55///    Bash, or a PATH that includes a Git tool directory). Its directory is on
56///    PATH, so the tools it needs are too.
57/// 3. The Git install that owns the `git.exe` on PATH → its `bin\sh.exe`.
58///    Covers a Git installed anywhere, as long as `git` itself works.
59/// 4. Standard Git install roots (machine-wide and per-user) → `bin\sh.exe`.
60///
61/// Relative PATH entries are skipped: resolving `.` against srv's cwd would
62/// let a directory's contents choose the shell.
63///
64/// Platform-neutral (takes its environment and filesystem as parameters, and
65/// splits PATH on `;` itself) so the order is unit-tested on every CI host.
66#[cfg_attr(not(windows), allow(dead_code))]
67pub(crate) fn resolve_windows(
68    env: &dyn Fn(&str) -> Option<OsString>,
69    is_file: &dyn Fn(&Path) -> bool,
70) -> Option<PathBuf> {
71    if let Some(p) = env(SHELL_OVERRIDE_ENV)
72        .filter(|v| !v.is_empty())
73        .map(PathBuf::from)
74    {
75        if is_file(&p) {
76            return Some(p);
77        }
78        tracing::warn!(path = %p.display(), "{SHELL_OVERRIDE_ENV} is not a file; ignoring");
79    }
80
81    let path_dirs: Vec<PathBuf> = env("PATH")
82        .map(|v| {
83            v.to_string_lossy()
84                .split(';')
85                .map(|s| s.trim().trim_matches('"'))
86                .filter(|s| !s.is_empty())
87                .map(PathBuf::from)
88                .filter(|p| is_windows_absolute(p))
89                .collect()
90        })
91        .unwrap_or_default();
92
93    if let Some(sh) = path_dirs
94        .iter()
95        .map(|d| d.join("sh.exe"))
96        .find(|p| is_file(p))
97    {
98        return Some(sh);
99    }
100
101    // git.exe lives at <root>\cmd, <root>\bin or <root>\mingw64\bin.
102    for git in path_dirs
103        .iter()
104        .map(|d| d.join("git.exe"))
105        .filter(|p| is_file(p))
106    {
107        for root in git.ancestors().skip(2).take(3) {
108            let sh = git_launcher(root);
109            if is_file(&sh) {
110                return Some(sh);
111            }
112        }
113    }
114
115    let program_roots = ["ProgramW6432", "ProgramFiles", "ProgramFiles(x86)"]
116        .into_iter()
117        .filter_map(|k| env(k).map(PathBuf::from));
118    let per_user = env("LOCALAPPDATA").map(|d| PathBuf::from(d).join("Programs"));
119    program_roots
120        .chain(per_user)
121        .map(|base| git_launcher(&base.join("Git")))
122        .find(|p| is_file(p))
123}
124
125/// Git for Windows' `sh` launcher under an install root.
126#[cfg_attr(not(windows), allow(dead_code))]
127fn git_launcher(root: &Path) -> PathBuf {
128    root.join("bin").join("sh.exe")
129}
130
131/// `C:\…` / `\\server\…`. Checked by hand rather than `Path::is_absolute`
132/// so the rule is the same when the tests run on a Unix CI host. A bare
133/// `\foo` is drive-relative on Windows, so it doesn't count.
134#[cfg_attr(not(windows), allow(dead_code))]
135fn is_windows_absolute(p: &Path) -> bool {
136    let s = p.to_string_lossy();
137    let b = s.as_bytes();
138    (b.len() >= 3 && b[0].is_ascii_alphabetic() && b[1] == b':' && (b[2] == b'\\' || b[2] == b'/'))
139        || s.starts_with(r"\\")
140}
141
142#[cfg(test)]
143mod tests {
144    use super::*;
145    use std::collections::{HashMap, HashSet};
146
147    /// Paths are built with `join`, never backslash literals, so `ancestors()`
148    /// splits them the same way on a Unix CI host as on Windows.
149    fn git_root() -> PathBuf {
150        PathBuf::from("C:/").join("Program Files").join("Git")
151    }
152
153    fn run(env: &[(&str, String)], files: &[PathBuf]) -> Option<PathBuf> {
154        let env: HashMap<String, OsString> = env
155            .iter()
156            .map(|(k, v)| (k.to_string(), OsString::from(v)))
157            .collect();
158        let files: HashSet<PathBuf> = files.iter().cloned().collect();
159        resolve_windows(&|k| env.get(k).cloned(), &|p| files.contains(p))
160    }
161
162    fn path_of(dirs: &[PathBuf]) -> String {
163        dirs.iter()
164            .map(|d| d.to_string_lossy().into_owned())
165            .collect::<Vec<_>>()
166            .join(";")
167    }
168
169    /// The reported bug: an Explorer-launched build sees only `Git\cmd` on
170    /// PATH. Must resolve to the launcher, not fail and not `usr\bin\sh.exe`.
171    #[test]
172    fn git_cmd_only_on_path_resolves_to_bin_launcher() {
173        let root = git_root();
174        let files = [
175            root.join("cmd").join("git.exe"),
176            root.join("bin").join("sh.exe"),
177            root.join("usr").join("bin").join("sh.exe"),
178        ];
179        let env = [(
180            "PATH",
181            path_of(&[PathBuf::from("C:/Windows/system32"), root.join("cmd")]),
182        )];
183        assert_eq!(run(&env, &files), Some(root.join("bin").join("sh.exe")));
184    }
185
186    #[test]
187    fn git_on_path_via_mingw64_bin_resolves_to_install_root() {
188        let root = PathBuf::from("D:/").join("tools").join("Git");
189        let mingw = root.join("mingw64").join("bin");
190        let files = [mingw.join("git.exe"), root.join("bin").join("sh.exe")];
191        assert_eq!(
192            run(&[("PATH", path_of(&[mingw]))], &files),
193            Some(root.join("bin").join("sh.exe"))
194        );
195    }
196
197    /// Today's working case (srv started from Git Bash) keeps its shell.
198    #[test]
199    fn sh_on_path_wins_over_git_derivation() {
200        let root = git_root();
201        let usr_bin = root.join("usr").join("bin");
202        let files = [
203            usr_bin.join("sh.exe"),
204            root.join("cmd").join("git.exe"),
205            root.join("bin").join("sh.exe"),
206        ];
207        let env = [("PATH", path_of(&[usr_bin.clone(), root.join("cmd")]))];
208        assert_eq!(run(&env, &files), Some(usr_bin.join("sh.exe")));
209    }
210
211    /// WSL's System32\bash.exe must never be picked up by the PATH search.
212    #[test]
213    fn wsl_bash_on_path_is_not_used() {
214        let sys32 = PathBuf::from("C:/").join("Windows").join("System32");
215        let files = [sys32.join("bash.exe")];
216        assert_eq!(run(&[("PATH", path_of(&[sys32]))], &files), None);
217    }
218
219    #[test]
220    fn falls_back_to_program_files_when_git_not_on_path() {
221        let pf = PathBuf::from("C:/").join("Program Files");
222        let files = [pf.join("Git").join("bin").join("sh.exe")];
223        let env = [
224            ("PATH", String::from("C:/Windows")),
225            ("ProgramFiles", pf.to_string_lossy().into_owned()),
226        ];
227        assert_eq!(
228            run(&env, &files),
229            Some(pf.join("Git").join("bin").join("sh.exe"))
230        );
231    }
232
233    #[test]
234    fn falls_back_to_per_user_install() {
235        let local = PathBuf::from("C:/")
236            .join("Users")
237            .join("u")
238            .join("AppData")
239            .join("Local");
240        let sh = local
241            .join("Programs")
242            .join("Git")
243            .join("bin")
244            .join("sh.exe");
245        let env = [("LOCALAPPDATA", local.to_string_lossy().into_owned())];
246        assert_eq!(run(&env, &[sh.clone()]), Some(sh));
247    }
248
249    #[test]
250    fn override_wins_when_it_exists_and_is_ignored_when_it_does_not() {
251        let root = git_root();
252        let custom = PathBuf::from("E:/")
253            .join("msys64")
254            .join("usr")
255            .join("bin")
256            .join("bash.exe");
257        let launcher = root.join("bin").join("sh.exe");
258        let path = ("PATH", path_of(&[root.join("cmd")]));
259        let files = [
260            custom.clone(),
261            root.join("cmd").join("git.exe"),
262            launcher.clone(),
263        ];
264        let env = [
265            (SHELL_OVERRIDE_ENV, custom.to_string_lossy().into_owned()),
266            path.clone(),
267        ];
268        assert_eq!(run(&env, &files), Some(custom.clone()));
269
270        // A stale override falls through to the normal search.
271        let files = [root.join("cmd").join("git.exe"), launcher.clone()];
272        assert_eq!(run(&env, &files), Some(launcher));
273    }
274
275    /// A relative PATH entry (`.`) must not let srv's cwd choose the shell.
276    #[test]
277    fn relative_path_entries_are_skipped() {
278        let files = [PathBuf::from(".").join("sh.exe")];
279        assert_eq!(run(&[("PATH", String::from("."))], &files), None);
280    }
281
282    #[test]
283    fn quoted_path_entries_are_honoured() {
284        let root = git_root();
285        let files = [
286            root.join("cmd").join("git.exe"),
287            root.join("bin").join("sh.exe"),
288        ];
289        let env = [(
290            "PATH",
291            format!("\"{}\"", root.join("cmd").to_string_lossy()),
292        )];
293        assert_eq!(run(&env, &files), Some(root.join("bin").join("sh.exe")));
294    }
295
296    #[test]
297    fn nothing_found_is_none() {
298        assert_eq!(run(&[("PATH", String::from("C:/Windows"))], &[]), None);
299    }
300}