diff --git a/src/runtime/native.rs b/src/runtime/native.rs index e1b1b8587..c4bdd6cae 100644 --- a/src/runtime/native.rs +++ b/src/runtime/native.rs @@ -2,11 +2,154 @@ use super::traits::RuntimeAdapter; use std::path::{Path, PathBuf}; /// Native runtime — full access, runs on Mac/Linux/Docker/Raspberry Pi -pub struct NativeRuntime; +pub struct NativeRuntime { + shell: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct ShellProgram { + kind: ShellKind, + program: PathBuf, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ShellKind { + Sh, + Bash, + Pwsh, + PowerShell, + Cmd, +} + +impl ShellKind { + fn as_str(self) -> &'static str { + match self { + ShellKind::Sh => "sh", + ShellKind::Bash => "bash", + ShellKind::Pwsh => "pwsh", + ShellKind::PowerShell => "powershell", + ShellKind::Cmd => "cmd", + } + } +} + +impl ShellProgram { + fn add_shell_args(&self, process: &mut tokio::process::Command, command: &str) { + match self.kind { + ShellKind::Sh | ShellKind::Bash => { + process.arg("-c").arg(command); + } + ShellKind::Pwsh | ShellKind::PowerShell => { + process + .arg("-NoLogo") + .arg("-NoProfile") + .arg("-NonInteractive") + .arg("-Command") + .arg(command); + } + ShellKind::Cmd => { + process.arg("/C").arg(command); + } + } + } +} + +fn detect_native_shell() -> Option { + #[cfg(target_os = "windows")] + { + let comspec = std::env::var_os("COMSPEC").map(PathBuf::from); + detect_native_shell_with(true, |name| which::which(name).ok(), comspec) + } + #[cfg(not(target_os = "windows"))] + { + detect_native_shell_with(false, |name| which::which(name).ok(), None) + } +} + +fn detect_native_shell_with( + is_windows: bool, + mut resolve: F, + comspec: Option, +) -> Option +where + F: FnMut(&str) -> Option, +{ + if is_windows { + for (name, kind) in [ + ("bash", ShellKind::Bash), + ("sh", ShellKind::Sh), + ("pwsh", ShellKind::Pwsh), + ("powershell", ShellKind::PowerShell), + ("cmd", ShellKind::Cmd), + ("cmd.exe", ShellKind::Cmd), + ] { + if let Some(program) = resolve(name) { + // Windows may expose `C:\Windows\System32\bash.exe`, a legacy + // WSL launcher that executes commands inside Linux userspace. + // That breaks native Windows commands like `ipconfig`. + if name == "bash" && is_windows_wsl_bash_launcher(&program) { + continue; + } + return Some(ShellProgram { kind, program }); + } + } + if let Some(program) = comspec { + return Some(ShellProgram { + kind: ShellKind::Cmd, + program, + }); + } + return None; + } + + for (name, kind) in [("sh", ShellKind::Sh), ("bash", ShellKind::Bash)] { + if let Some(program) = resolve(name) { + return Some(ShellProgram { kind, program }); + } + } + None +} + +fn is_windows_wsl_bash_launcher(program: &Path) -> bool { + let normalized = program + .to_string_lossy() + .replace('/', "\\") + .to_ascii_lowercase(); + normalized.ends_with("\\windows\\system32\\bash.exe") + || normalized.ends_with("\\windows\\sysnative\\bash.exe") +} + +fn missing_shell_error() -> &'static str { + #[cfg(target_os = "windows")] + { + "Native runtime could not find a usable shell (tried: bash, sh, pwsh, powershell, cmd). \ + Install Git Bash or PowerShell and ensure it is available on PATH." + } + #[cfg(not(target_os = "windows"))] + { + "Native runtime could not find a usable shell (tried: sh, bash). \ + Install a POSIX shell and ensure it is available on PATH." + } +} impl NativeRuntime { pub fn new() -> Self { - Self + Self { + shell: detect_native_shell(), + } + } + + pub(crate) fn selected_shell_kind(&self) -> Option<&'static str> { + self.shell.as_ref().map(|shell| shell.kind.as_str()) + } + + pub(crate) fn selected_shell_program(&self) -> Option<&Path> { + self.shell.as_ref().map(|shell| shell.program.as_path()) + } + + #[cfg(test)] + fn new_for_test(shell: Option) -> Self { + Self { shell } } } @@ -20,7 +163,7 @@ impl RuntimeAdapter for NativeRuntime { } fn has_shell_access(&self) -> bool { - true + self.shell.is_some() } fn has_filesystem_access(&self) -> bool { @@ -43,8 +186,14 @@ impl RuntimeAdapter for NativeRuntime { command: &str, workspace_dir: &Path, ) -> anyhow::Result { - let mut process = tokio::process::Command::new("sh"); - process.arg("-c").arg(command).current_dir(workspace_dir); + let shell = self + .shell + .as_ref() + .ok_or_else(|| anyhow::anyhow!(missing_shell_error()))?; + + let mut process = tokio::process::Command::new(&shell.program); + shell.add_shell_args(&mut process, command); + process.current_dir(workspace_dir); Ok(process) } } @@ -52,6 +201,7 @@ impl RuntimeAdapter for NativeRuntime { #[cfg(test)] mod tests { use super::*; + use std::collections::HashMap; #[test] fn native_name() { @@ -60,7 +210,10 @@ mod tests { #[test] fn native_has_shell_access() { - assert!(NativeRuntime::new().has_shell_access()); + assert_eq!( + NativeRuntime::new().has_shell_access(), + detect_native_shell().is_some() + ); } #[test] @@ -84,12 +237,173 @@ mod tests { assert!(path.to_string_lossy().contains("zeroclaw")); } + #[test] + fn detect_shell_windows_prefers_git_bash() { + let mut map = HashMap::new(); + map.insert("bash", r"C:\Program Files\Git\bin\bash.exe"); + map.insert( + "powershell", + r"C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe", + ); + map.insert("cmd", r"C:\Windows\System32\cmd.exe"); + + let shell = detect_native_shell_with( + true, + |name| map.get(name).map(PathBuf::from), + Some(PathBuf::from(r"C:\Windows\System32\cmd.exe")), + ) + .expect("windows shell should be detected"); + + assert_eq!(shell.kind, ShellKind::Bash); + } + + #[test] + fn detect_shell_windows_falls_back_to_powershell_then_cmd() { + let mut map = HashMap::new(); + map.insert( + "powershell", + r"C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe", + ); + + let shell = detect_native_shell_with( + true, + |name| map.get(name).map(PathBuf::from), + Some(PathBuf::from(r"C:\Windows\System32\cmd.exe")), + ) + .expect("windows shell should be detected"); + + assert_eq!(shell.kind, ShellKind::PowerShell); + + let cmd_shell = detect_native_shell_with( + true, + |_name| None, + Some(PathBuf::from(r"C:\Windows\System32\cmd.exe")), + ) + .expect("cmd fallback should be detected"); + assert_eq!(cmd_shell.kind, ShellKind::Cmd); + } + + #[test] + fn detect_shell_windows_skips_system32_bash_wsl_launcher() { + let mut map = HashMap::new(); + map.insert("bash", r"C:\Windows\System32\bash.exe"); + map.insert( + "powershell", + r"C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe", + ); + map.insert("cmd", r"C:\Windows\System32\cmd.exe"); + + let shell = detect_native_shell_with( + true, + |name| map.get(name).map(PathBuf::from), + Some(PathBuf::from(r"C:\Windows\System32\cmd.exe")), + ) + .expect("windows shell should be detected"); + + assert_eq!(shell.kind, ShellKind::PowerShell); + assert_eq!( + shell.program, + PathBuf::from(r"C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe") + ); + } + + #[test] + fn detect_shell_windows_uses_cmd_when_only_wsl_bash_exists() { + let mut map = HashMap::new(); + map.insert("bash", r"C:\Windows\Sysnative\bash.exe"); + + let shell = detect_native_shell_with( + true, + |name| map.get(name).map(PathBuf::from), + Some(PathBuf::from(r"C:\Windows\System32\cmd.exe")), + ) + .expect("cmd fallback should be detected"); + + assert_eq!(shell.kind, ShellKind::Cmd); + assert_eq!(shell.program, PathBuf::from(r"C:\Windows\System32\cmd.exe")); + } + + #[test] + fn wsl_launcher_detection_matches_known_paths() { + assert!(is_windows_wsl_bash_launcher(Path::new( + r"C:\Windows\System32\bash.exe" + ))); + assert!(is_windows_wsl_bash_launcher(Path::new( + r"C:\Windows\Sysnative\bash.exe" + ))); + assert!(!is_windows_wsl_bash_launcher(Path::new( + r"C:\Program Files\Git\bin\bash.exe" + ))); + } + + #[test] + fn detect_shell_unix_prefers_sh() { + let mut map = HashMap::new(); + map.insert("sh", "/bin/sh"); + map.insert("bash", "/usr/bin/bash"); + + let shell = detect_native_shell_with(false, |name| map.get(name).map(PathBuf::from), None) + .expect("unix shell should be detected"); + + assert_eq!(shell.kind, ShellKind::Sh); + } + + #[test] + fn native_without_shell_disables_shell_access() { + let runtime = NativeRuntime::new_for_test(None); + assert!(!runtime.has_shell_access()); + + let err = runtime + .build_shell_command("echo hello", Path::new(".")) + .expect_err("build should fail without available shell") + .to_string(); + assert!(err.contains("could not find a usable shell")); + } + + #[test] + fn native_builds_powershell_command() { + let runtime = NativeRuntime::new_for_test(Some(ShellProgram { + kind: ShellKind::PowerShell, + program: PathBuf::from("powershell"), + })); + + let command = runtime + .build_shell_command("Get-Location", Path::new(".")) + .expect("powershell command should build"); + let debug = format!("{command:?}"); + + assert!(debug.contains("powershell")); + assert!(debug.contains("-NoProfile")); + assert!(debug.contains("-Command")); + assert!(debug.contains("Get-Location")); + } + + #[test] + fn native_builds_cmd_command() { + let runtime = NativeRuntime::new_for_test(Some(ShellProgram { + kind: ShellKind::Cmd, + program: PathBuf::from("cmd"), + })); + + let command = runtime + .build_shell_command("echo hello", Path::new(".")) + .expect("cmd command should build"); + let debug = format!("{command:?}"); + + assert!(debug.contains("cmd")); + assert!(debug.contains("/C")); + assert!(debug.contains("echo hello")); + } + #[test] fn native_builds_shell_command() { + let runtime = NativeRuntime::new(); + if !runtime.has_shell_access() { + return; + } + let cwd = std::env::temp_dir(); - let command = NativeRuntime::new() - .build_shell_command("echo hello", &cwd) - .unwrap(); + let command = runtime.build_shell_command("echo hello", &cwd).unwrap(); let debug = format!("{command:?}"); assert!(debug.contains("echo hello")); }