diff --git a/crates/goose/src/agents/platform_extensions/developer/shell.rs b/crates/goose/src/agents/platform_extensions/developer/shell.rs index a53d7d02..8ac856eb 100644 --- a/crates/goose/src/agents/platform_extensions/developer/shell.rs +++ b/crates/goose/src/agents/platform_extensions/developer/shell.rs @@ -14,6 +14,79 @@ use tokio_stream::{wrappers::SplitStream, StreamExt}; use crate::subprocess::SubprocessExt; +/// Check if the current process is running inside a Flatpak sandbox. +/// +/// When inside Flatpak, shell commands must be wrapped with `flatpak-spawn --host` +/// to execute on the host system rather than inside the sandbox. +#[cfg(not(windows))] +fn is_flatpak() -> bool { + std::path::Path::new("/.flatpak-info").exists() +} + +#[cfg(not(windows))] +const FLATPAK_HOST_ARGS: [&str; 2] = ["--host", "--watch-bus"]; + +#[cfg(not(windows))] +fn flatpak_spawn_command() -> tokio::process::Command { + let mut command = tokio::process::Command::new("flatpak-spawn"); + command.args(FLATPAK_HOST_ARGS); + command +} + +#[cfg(not(windows))] +fn flatpak_spawn_process() -> std::process::Command { + let mut command = std::process::Command::new("flatpak-spawn"); + command.args(FLATPAK_HOST_ARGS); + command +} + +/// Resolve the preferred Unix shell, respecting GOOSE_SHELL. +/// +/// Returns `(shell_path, is_user_configured)` — the boolean is true when +/// `GOOSE_SHELL` was explicitly set, which matters in Flatpak mode: an +/// explicit path is passed through as-is (the user likely intends a host +/// path), whereas auto-detected defaults are reduced to their basename so +/// the host's PATH resolves the correct binary. +/// +/// In Flatpak mode without an explicit GOOSE_SHELL, we skip sandbox +/// filesystem checks (e.g. `/bin/bash` existing inside the sandbox tells +/// us nothing about the host) and default to `"bash"` directly. +#[cfg(not(windows))] +fn unix_shell() -> (String, bool) { + match std::env::var("GOOSE_SHELL") { + Ok(shell) => (shell, true), + Err(_) => { + let shell = if is_flatpak() { + // Don't inspect sandbox paths — they don't reflect the host. + // Default to "bash" (ubiquitous on Flatpak-capable Linux hosts). + "bash".to_string() + } else if PathBuf::from("/bin/bash").is_file() { + "/bin/bash".to_string() + } else { + std::env::var("SHELL").unwrap_or_else(|_| "sh".to_string()) + }; + (shell, false) + } + } +} + +/// Return the shell reference to pass to `flatpak-spawn --host`. +/// +/// If the user explicitly configured GOOSE_SHELL, honour the full path +/// (it likely refers to a host binary, e.g. a Nix-profile shell). +/// Otherwise strip to basename so the host's default PATH resolves it. +#[cfg(not(windows))] +fn flatpak_shell_arg(shell: &str, is_user_configured: bool) -> &str { + if is_user_configured { + shell + } else { + std::path::Path::new(shell) + .file_name() + .and_then(|s| s.to_str()) + .unwrap_or("bash") + } +} + const OUTPUT_LIMIT_LINES: usize = 2000; pub const OUTPUT_LIMIT_BYTES: usize = 50_000; const OUTPUT_PREVIEW_LINES: usize = 50; @@ -69,21 +142,31 @@ pub struct ShellOutput { /// source the user's profile and recover the full PATH. #[cfg(not(windows))] fn resolve_login_shell_path() -> Option { - let shell = std::env::var("GOOSE_SHELL").unwrap_or_else(|_| { - if PathBuf::from("/bin/bash").is_file() { - "/bin/bash".to_string() - } else { - std::env::var("SHELL").unwrap_or_else(|_| "sh".to_string()) - } - }); + let (shell, is_user_configured) = unix_shell(); - let mut child = std::process::Command::new(&shell) - .args(["-l", "-i", "-c", "echo $PATH"]) - .stdin(Stdio::null()) - .stdout(Stdio::piped()) - .stderr(Stdio::null()) - .spawn() - .ok()?; + let mut child = if is_flatpak() { + flatpak_spawn_process() + .args([ + flatpak_shell_arg(&shell, is_user_configured), + "-l", + "-i", + "-c", + "echo $PATH", + ]) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .spawn() + .ok()? + } else { + std::process::Command::new(&shell) + .args(["-l", "-i", "-c", "echo $PATH"]) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .spawn() + .ok()? + }; let mut stdout = child.stdout.take()?; let (tx, rx) = std::sync::mpsc::channel(); @@ -309,14 +392,7 @@ async fn run_command( working_dir: Option<&std::path::Path>, login_path: Option<&str>, ) -> Result { - let mut command = build_shell_command(command_line); - if let Some(path) = working_dir { - command.current_dir(path); - } - - if let Some(path) = login_path { - command.env("PATH", path); - } + let mut command = build_shell_command(command_line, working_dir, login_path); command.stdout(Stdio::piped()); command.stderr(Stdio::piped()); @@ -401,7 +477,11 @@ async fn run_command( }) } -fn build_shell_command(command_line: &str) -> tokio::process::Command { +fn build_shell_command( + command_line: &str, + working_dir: Option<&std::path::Path>, + login_path: Option<&str>, +) -> tokio::process::Command { #[cfg(windows)] let mut command = { let shell = std::env::var("GOOSE_SHELL").unwrap_or_else(|_| "cmd".to_string()); @@ -423,21 +503,45 @@ fn build_shell_command(command_line: &str) -> tokio::process::Command { command.args(["-c", command_line]); } } + if let Some(path) = working_dir { + command.current_dir(path); + } + if let Some(path) = login_path { + command.env("PATH", path); + } command }; #[cfg(not(windows))] let mut command = { - let shell = std::env::var("GOOSE_SHELL").unwrap_or_else(|_| { - if PathBuf::from("/bin/bash").is_file() { - "/bin/bash".to_string() - } else { - std::env::var("SHELL").unwrap_or_else(|_| "sh".to_string()) + let (shell, is_user_configured) = unix_shell(); + + if is_flatpak() { + let mut command = flatpak_spawn_command(); + if let Some(dir) = working_dir { + command.arg(format!("--directory={}", dir.display())); } - }); - let mut command = tokio::process::Command::new(shell); - command.arg("-c").arg(command_line); - command + if let Some(path) = login_path { + command.arg(format!("--env=PATH={}", path)); + } + // If GOOSE_SHELL was explicitly set, honour the full path (likely a host + // binary). Otherwise use basename so the host's PATH resolves it. + command + .arg(flatpak_shell_arg(&shell, is_user_configured)) + .arg("-c") + .arg(command_line); + command + } else { + let mut command = tokio::process::Command::new(shell); + command.arg("-c").arg(command_line); + if let Some(path) = working_dir { + command.current_dir(path); + } + if let Some(path) = login_path { + command.env("PATH", path); + } + command + } }; command.set_no_window();