use anyhow::{anyhow, Result}; #[cfg(feature = "local_tty")] use futures::future::{BoxFuture, FutureExt}; use std::{collections::HashMap, path::PathBuf, process::Stdio}; #[cfg(feature = "local_tty")] use super::model::session::LocalCommandExecutor; use super::{local_tty::shell::ShellStarter, shell::ShellType}; use crate::terminal::available_shells::AvailableShells; use crate::terminal::local_tty::shell::ShellStarterSourceOrWslName; #[cfg(feature = "local_tty")] use command::r#async::Command; use warpui::{Entity, ModelContext, SingletonEntity}; #[derive(Debug)] pub enum LocalShellState { /// The shell for a session has been loaded. Loaded(LocalShell), /// The shell state for a local session has not been loaded. /// Loading shell information for WSL is extremely expensive and can take upwards of 10s to /// load. NotLoaded, } /// State of the interactive shell environment capture. /// This is used for LSP operations that need the full interactive PATH. #[cfg(feature = "local_tty")] #[derive(Debug, Default)] pub enum InteractiveEnvState { /// Interactive PATH has not been requested yet. #[default] NotRequested, /// Interactive PATH capture is in progress. /// Stores senders that will be notified when capture completes. Pending { waiters: Vec>>, }, /// Interactive PATH capture completed. Ready(Option), } #[derive(Debug)] pub struct LocalShell { shell_type: ShellType, /// Defines the path of the shell binary, i.e. /bin/zsh shell_path: PathBuf, /// The PATH sourced from the user's shell (non-interactive, fast) path_env_var: Option, /// The PATH sourced from an interactive login shell (lazy, for LSP) #[cfg(feature = "local_tty")] interactive_env_state: InteractiveEnvState, } impl LocalShell { pub fn get_shell_type(&self) -> ShellType { self.shell_type } pub fn get_shell_path(&self) -> &PathBuf { &self.shell_path } pub fn get_path_env_var(&self) -> &Option { &self.path_env_var } } #[derive(Debug, Clone)] pub enum LocalShellStateEvent {} /// Local shell is a singleton model registered on application startup /// which asynchronously collects information about the user's default /// shell/corresponding path environment variable. It's useful in /// executing commands locally without having to initiate a session. /// /// Its usage pattern can be seen in app/src/external_secrets/mod.rs, /// where the shell_path is fetched from a caller view (in this case, /// fetch_secrets in app/src/env_vars/env_var_collection.rs) via a /// LocalShell handle, and is passed into the external secret manager /// interface. The interface then dispatches commands to execute via /// execute_command in this file. #[cfg(feature = "local_tty")] impl LocalShellState { pub fn new(ctx: &mut ModelContext) -> Self { let preferred_shell = AvailableShells::handle(ctx) .read(ctx, |shells, ctx| shells.get_user_preferred_shell(ctx)); let shell_starter_source_or_wsl_name = match ShellStarter::init(preferred_shell) { Some(shell_starter_source_or_wsl_name) => shell_starter_source_or_wsl_name, None => return LocalShellState::NotLoaded, }; let shell_starter = match shell_starter_source_or_wsl_name { ShellStarterSourceOrWslName::Source(starter_source) => match starter_source.into() { ShellStarter::Direct(starter) | ShellStarter::MSYS2(starter) => starter, ShellStarter::DockerSandbox(docker_starter) => docker_starter.direct, ShellStarter::Wsl(_) => return LocalShellState::NotLoaded, }, // TODO(CORE-3020): Implement WSL for the Local Shell model. ShellStarterSourceOrWslName::WSLName { .. } => return LocalShellState::NotLoaded, }; let shell_path = shell_starter.logical_shell_path().to_owned(); let shell_type = shell_starter.shell_type(); let clone = shell_path.clone(); let command = match shell_type { ShellType::Bash | ShellType::Zsh => "echo $PATH", ShellType::Fish => "env | grep PATH", ShellType::PowerShell => "echo $Env:PATH", }; // Execute a command to fetch the user's PATH var, and store it // in the associated field ctx.spawn( async move { execute_command(shell_type, clone, None, command).await }, |me, res, _| match me { LocalShellState::Loaded(local_shell_state) => { // Trim to remove trailing newline from `echo $PATH` output local_shell_state.path_env_var = res.ok().map(|s| s.trim().to_string()); } LocalShellState::NotLoaded => { log::warn!("Tried to execute a command on LocalShell that wasn't loaded") } }, ); Self::Loaded(LocalShell { shell_type, shell_path, path_env_var: None, interactive_env_state: InteractiveEnvState::NotRequested, }) } pub fn local_shell_info(&self) -> Option<&LocalShell> { match self { LocalShellState::Loaded(shell_state) => Some(shell_state), LocalShellState::NotLoaded => None, } } /// Returns a future that will yield the PATH from an interactive login shell. /// This lazily triggers the capture on first call and caches the result. /// Use this for LSP operations that need the full interactive environment. pub fn get_interactive_path_env_var( &mut self, ctx: &mut ModelContext, ) -> BoxFuture<'static, Option> { let LocalShellState::Loaded(local_shell) = self else { // Not loaded - return immediately with None return futures::future::ready(None).boxed(); }; match &mut local_shell.interactive_env_state { InteractiveEnvState::Ready(path) => { // Already captured - return immediately with cached value futures::future::ready(path.clone()).boxed() } InteractiveEnvState::Pending { waiters } => { // Capture in progress - add a new waiter let (tx, rx) = async_channel::bounded(1); waiters.push(tx); async move { rx.recv().await.ok().flatten() }.boxed() } InteractiveEnvState::NotRequested => { // First request - kick off the capture let (tx, rx) = async_channel::bounded(1); local_shell.interactive_env_state = InteractiveEnvState::Pending { waiters: vec![tx] }; let shell_type = local_shell.shell_type; let shell_path = local_shell.shell_path.clone(); ctx.spawn( async move { capture_interactive_shell_env(shell_type, shell_path).await }, move |me, result, _ctx| { let path = result.ok(); // Notify all waiting receivers if let LocalShellState::Loaded(local_shell) = me { if let InteractiveEnvState::Pending { waiters } = std::mem::replace( &mut local_shell.interactive_env_state, InteractiveEnvState::Ready(path.clone()), ) { for waiter in waiters { let _ = waiter.try_send(path.clone()); } } } }, ); async move { rx.recv().await.ok().flatten() }.boxed() } } } } #[cfg(feature = "local_tty")] pub async fn execute_command( shell_type: ShellType, shell_path: PathBuf, path_env_var: Option, command: &str, ) -> Result { let local_command_executor = LocalCommandExecutor::new(Some(shell_path), shell_type); // Build environment variables map. // Always include HOME to ensure the shell can expand ~ in rc files - this is critical // when Warp is launched via launchd (Finder, Dock) with a minimal environment. let mut env_vars = HashMap::new(); if let Some(home) = dirs::home_dir().and_then(|h| h.to_str().map(|s| s.to_string())) { env_vars.insert("HOME".to_owned(), home); } if let Some(path) = path_env_var { env_vars.insert("PATH".to_owned(), path); } let env_vars = if env_vars.is_empty() { None } else { Some(env_vars) }; match local_command_executor .execute_local_command_in_login_shell(command, None, env_vars) .await { Ok(result) => { let res = result.to_string()?; if result.success() { Ok(res) } else { Err(anyhow!(res)) } } Err(_) => Err(anyhow!("Error while parsing result")), } } /// Captures the PATH environment variable from an interactive login shell. /// This uses setsid() to start a new session (fully detaching from the terminal) /// and stdin(null) to prevent interactive prompts from blocking. #[cfg(feature = "local_tty")] async fn capture_interactive_shell_env( shell_type: ShellType, shell_path: PathBuf, ) -> Result { let command_str = match shell_type { ShellType::Bash | ShellType::Zsh => "echo $PATH", ShellType::Fish => "string join : $PATH", ShellType::PowerShell => "echo $Env:PATH", }; // With the `-i` flag, shells may try to set themselves as the foreground process for their // controlling terminal with `tcsetpgrp` [1]. If the Warp process itself tries to read from // stdin (for example, some Oz CLI commands have interactive inputs), it may get suspended with // a `SIGTTIN` or `SIGTTOU` signal. // // To prevent this, we run the child in a new session with no controlling terminal by using // `setsid` [2]. // // [1]: https://pubs.opengroup.org/onlinepubs/007904975/functions/tcsetpgrp.html // [2]: https://man7.org/linux/man-pages/man2/setsid.2.html #[cfg(unix)] let mut command = Command::new_with_session(&shell_path); #[cfg(not(unix))] let mut command = Command::new(&shell_path); // Add shell-specific flags for interactive login shell match shell_type { ShellType::Bash | ShellType::Zsh => { // -i: interactive (sources .zshrc/.bashrc) // -l: login shell (sources .zprofile/.bash_profile) command.args(["-i", "-l", "-c", command_str]); } ShellType::Fish => { command.args(["-i", "-l", "-c", command_str]); } ShellType::PowerShell => { // Note: we intentionally omit `-Login` here. PowerShell 5.1 // (`powershell.exe`) does not support it, and on Windows the // PATH is managed via system/user environment variables rather // than login profile scripts, so `-Login` has no practical // effect even on PowerShell 7. command.args(["-Command", command_str]); } } // Set HOME to ensure shell can expand ~ in rc files if let Some(home) = dirs::home_dir() { command.env("HOME", home); } // stdin(null) prevents any prompts from blocking let output: std::process::Output = command .stdin(Stdio::null()) .stdout(Stdio::piped()) .stderr(Stdio::piped()) .output() .await .map_err(|e| anyhow!("Failed to spawn interactive shell: {}", e))?; if !output.status.success() { let stderr = String::from_utf8_lossy(&output.stderr); log::warn!( "[LSP PATH] Interactive shell capture had non-zero exit: {}", stderr ); // Still try to use stdout even if exit was non-zero // (some shells exit non-zero but still produce valid output) } let stdout = String::from_utf8_lossy(&output.stdout); let path = stdout.trim().to_string(); if path.is_empty() { Err(anyhow!("Interactive shell returned empty PATH")) } else { Ok(path) } } impl Entity for LocalShellState { type Event = LocalShellStateEvent; } impl SingletonEntity for LocalShellState {}