use std::collections::HashMap; use std::future::Future; use std::path::{Path, PathBuf}; use std::sync::{Arc, Mutex}; use std::time::Duration; #[cfg(any(not(feature = "local_fs"), target_family = "wasm"))] use ai::index::full_source_code_embedding::manager::{ CodebaseIndexManager, CodebaseIndexManagerEvent, }; #[cfg(all(feature = "local_fs", not(target_family = "wasm")))] use ai::index::local_project_index::{ LocalIndexStatus, LocalProjectIndexEvent, LocalProjectIndexManager, }; use futures::channel::oneshot; use futures::future::join_all; use galaxy_cli::agent::Harness; use galaxy_completer::completer::CommandExitStatus; use galaxy_core::command::ExitCode; use galaxy_core::{safe_info, safe_warn}; use galaxyui::r#async::FutureExt; use galaxyui::{ModelContext, ModelSpawner, SingletonEntity}; use repo_metadata::repositories::{DetectedRepositories, RepoDetectionSource}; use super::terminal::TerminalDriver; use super::AgentDriverError; use crate::ai::agent_sdk::setup_observability::{SetupClientEventReporter, SetupStep}; use crate::ai::cloud_environments::{AmbientAgentEnvironment, SourceRepo}; use crate::terminal::model::session::command_executor::shell_escape_single_quotes; use crate::terminal::shell::ShellType; const CODEBASE_INDEX_SYNC_TIMEOUT: Duration = Duration::from_secs(60); #[derive(Debug, thiserror::Error)] pub enum PrepareEnvironmentError { #[error("Invalid runtime state - please file a bug report.")] InvalidRuntimeState, #[error("Failed to clone {repo_name}")] CloneRepo { repo_name: String }, #[error("Failed to run setup command: {command}")] SetupCommand { command: String }, #[error("Failed to change directory into {repo_name}")] ChangeDirectory { repo_name: String }, #[error("Terminal driver error while preparing environment: {source}")] TerminalDriver { source: AgentDriverError }, } /// Prepare a cloud agent environment within a terminal session. This will: /// 1. Clone all repositories, skipping any that are already cloned. /// 2. Begin codebase indexing for all repositories (Oz harness only). /// 3. Run any setup commands. /// 4. If there is only one repository, navigate into it. /// /// `is_sandbox` tells the preparer that `working_dir` only exists inside a /// Docker sandbox container and therefore the host filesystem can't be used /// for repo detection or indexing. This is an explicit signal from the /// caller rather than a path-prefix inference, so non-sandbox callers that /// happen to pass a path like `/home/agent/...` don't silently flip into /// sandbox-only mode. pub fn prepare_environment( environment: AmbientAgentEnvironment, working_dir: PathBuf, is_sandbox: bool, harness: Harness, setup_events: SetupClientEventReporter, ctx: &mut ModelContext, ) -> impl Future> { let spawner = ctx.spawner(); async move { let source_repos = environment.effective_repos(); let setup_commands = environment.setup_commands; // Only index the codebase for the Oz harness; third-party harnesses (e.g. Claude) // have their own methods for navigating a codebase. let should_index_codebase = harness == Harness::Oz; let should_subscribe_to_index_updates = should_index_codebase && !source_repos.is_empty(); let repo_channels = Arc::new(Mutex::new(HashMap::>::new())); if should_subscribe_to_index_updates { subscribe_to_codebase_index_events(&spawner, Arc::clone(&repo_channels)).await?; } let result = prepare_environment_impl( &spawner, working_dir.as_path(), is_sandbox, &source_repos, setup_commands, should_index_codebase, Arc::clone(&repo_channels), setup_events, ) .await; if should_subscribe_to_index_updates && result.is_err() { let _ = spawner .spawn(|_, ctx| { #[cfg(all(feature = "local_fs", not(target_family = "wasm")))] ctx.unsubscribe_from_model(&LocalProjectIndexManager::handle(ctx)); #[cfg(any(not(feature = "local_fs"), target_family = "wasm"))] ctx.unsubscribe_from_model(&CodebaseIndexManager::handle(ctx)); }) .await; } result } } #[allow(clippy::too_many_arguments)] async fn prepare_environment_impl( spawner: &ModelSpawner, working_dir: &Path, is_sandbox: bool, source_repos: &[SourceRepo], setup_commands: Vec, should_index_codebase: bool, repo_channels: Arc>>>, setup_events: SetupClientEventReporter, ) -> Result<(), PrepareEnvironmentError> { let working_dir_string = working_dir.to_string_lossy().to_string(); // Position the session in `working_dir` before running any probes / clones. // Routed through the silent executor so we don't add a user-visible `cd` // block to the blocklist — in the common case (cloud agents) the session // is already cd'd here by its startup dir, so this is a no-op re-cd and // shouldn't appear in the user's terminal history. if !cd_in_terminal_silent(working_dir_string.clone(), spawner).await? { return Err(PrepareEnvironmentError::ChangeDirectory { repo_name: working_dir_string, }); } let mut codebase_context_receivers = Vec::new(); if !source_repos.is_empty() { setup_events .record_result(SetupStep::EnvironmentRepoClone, async { clone_repos(source_repos, working_dir, spawner).await?; for repo in source_repos { register_cloned_repo(repo, working_dir, is_sandbox, spawner).await?; if !is_sandbox && should_index_codebase { let receiver = index_repo_codebase( &repo.repo, working_dir, Arc::clone(&repo_channels), spawner, ) .await?; if let Some(receiver) = receiver { codebase_context_receivers.push(receiver); } } } Ok::<(), PrepareEnvironmentError>(()) }) .await?; if should_index_codebase { record_codebase_indexing( setup_events.clone(), spawner.clone(), codebase_context_receivers, ); } } let has_setup_commands = !setup_commands.is_empty(); if has_setup_commands { setup_events .record_result(SetupStep::EnvironmentSetupCommands, async { // Set CI=true so setup commands run in a CI-like environment. This should help us run // non-interactive versions of setup commands, as many command line tools recognize the CI // environment variable. execute_command("export CI=true".to_string(), spawner).await?; for command in setup_commands { let command_for_error = command.clone(); safe_info!( safe: ("Running setup command"), full: ("Running setup command: {command}") ); let exit_code = execute_command(command, spawner).await?; if exit_code != 0.into() { return Err(PrepareEnvironmentError::SetupCommand { command: command_for_error, }); } let working_dir_string = working_dir.to_string_lossy().to_string(); if let Err(error) = cd_in_terminal(working_dir_string, spawner).await { log::warn!( "Failed to reset working directory after setup command: {error}" ); } safe_info!( safe: ("Successfully completed setup command"), full: ("Successfully completed setup command: {command_for_error}") ); } // Unset CI after setup commands complete so the agent session // does not run with CI=true. execute_command("unset CI".to_string(), spawner).await?; Ok::<(), PrepareEnvironmentError>(()) }) .await?; } else if should_index_codebase && source_repos.is_empty() { let _ = spawner .spawn(|_, ctx| { #[cfg(all(feature = "local_fs", not(target_family = "wasm")))] ctx.unsubscribe_from_model(&LocalProjectIndexManager::handle(ctx)); #[cfg(any(not(feature = "local_fs"), target_family = "wasm"))] ctx.unsubscribe_from_model(&CodebaseIndexManager::handle(ctx)); }) .await; } if should_index_codebase && source_repos.is_empty() { log::info!("No repositories to index for codebase context"); } // If there's only one repo in the environment, start the agent in that repo. // This way, it doesn't have to locate the correct repo to work on. if let Some(repo_name) = single_repo_name(source_repos) { safe_info!( safe: ("Changing directory into single repository"), full: ("Changing directory into single repository: {repo_name}") ); let exit_code = cd_in_terminal(repo_name.clone(), spawner).await?; if exit_code != 0.into() { return Err(PrepareEnvironmentError::ChangeDirectory { repo_name }); } } Ok(()) } fn record_codebase_indexing( setup_events: SetupClientEventReporter, spawner: ModelSpawner, codebase_context_receivers: Vec>, ) { if codebase_context_receivers.is_empty() { setup_events.record_value_detached(SetupStep::EnvironmentCodebaseIndexing, async move { let _ = spawner .spawn(|_, ctx| { #[cfg(all(feature = "local_fs", not(target_family = "wasm")))] ctx.unsubscribe_from_model(&LocalProjectIndexManager::handle(ctx)); #[cfg(any(not(feature = "local_fs"), target_family = "wasm"))] ctx.unsubscribe_from_model(&CodebaseIndexManager::handle(ctx)); }) .await; }); return; } setup_events.record_value_detached(SetupStep::EnvironmentCodebaseIndexing, async move { let repos_indexed = join_all(codebase_context_receivers); if repos_indexed .with_timeout(CODEBASE_INDEX_SYNC_TIMEOUT) .await .is_err() { log::warn!( "Timed out waiting for codebase index sync; continuing without guaranteed codebase context", ); } let _ = spawner .spawn(|_, ctx| { #[cfg(all(feature = "local_fs", not(target_family = "wasm")))] ctx.unsubscribe_from_model(&LocalProjectIndexManager::handle(ctx)); #[cfg(any(not(feature = "local_fs"), target_family = "wasm"))] ctx.unsubscribe_from_model(&CodebaseIndexManager::handle(ctx)); }) .await; }); } fn build_parallel_clone_command(repos: &[SourceRepo], shell_type: ShellType) -> String { let mut script = String::from( r#"set +e failed=0 pids="" tmp_dir="$(mktemp -d "${TMPDIR:-/tmp}/warp-clone-logs.XXXXXX")" cleanup_clone_logs() { rm -rf "$tmp_dir" } trap cleanup_clone_logs EXIT clone_repo() { repo_name="$1" repo_url="$2" target="$3" if [ -d "$target" ]; then printf '%s\n' "Repository directory $target already exists, skipping clone..." return 0 fi printf '%s\n' "Cloning repository $repo_name..." git clone --filter=tree:0 "$repo_url" "$target" } "#, ); let mut log_outputs = String::new(); for (index, repo) in repos.iter().enumerate() { let repo_name = format!("{}/{}", repo.owner, repo.repo); let repo_url = repo.https_clone_url(); let escaped_repo_name = shell_escape_single_quotes(&repo_name, ShellType::Bash); let escaped_repo_url = shell_escape_single_quotes(&repo_url, ShellType::Bash); let escaped_target = shell_escape_single_quotes(&repo.repo, ShellType::Bash); let log_var = format!("log_file_{index}"); script.push_str(&format!( "{log_var}=\"$tmp_dir/repo-{index}.log\"\n\ clone_repo '{escaped_repo_name}' '{escaped_repo_url}' '{escaped_target}' >\"${log_var}\" 2>&1 &\n" )); script.push_str("pids=\"$pids $!\"\n"); log_outputs.push_str(&format!( "printf '%s\\n' '===== {escaped_repo_name} ====='\n\ if [ -s \"${log_var}\" ]; then\n\ \tcat \"${log_var}\"\n\ else\n\ \tprintf '%s\\n' '(no output)'\n\ fi\n" )); } script.push_str( r#"for pid in $pids; do if ! wait "$pid"; then failed=1 fi done "#, ); script.push_str(&log_outputs); script.push_str( r#" exit "$failed" "#, ); let escaped_script = shell_escape_single_quotes(&script, shell_type); format!("sh -c '{escaped_script}'") } /// Clone all source repositories to `{working_dir}/{repo.repo}` if they do not already exist. /// Multiple repositories are cloned in parallel to reduce environment setup time. pub(super) async fn clone_repos( repos: &[SourceRepo], working_dir: &Path, spawner: &ModelSpawner, ) -> Result<(), PrepareEnvironmentError> { match repos { [] => Ok(()), [repo] => clone_repo(repo, working_dir, spawner).await, repos => { let shell_type = spawner .spawn(|driver, ctx| { driver .active_session_shell_type(ctx) .unwrap_or(ShellType::Bash) }) .await .unwrap_or(ShellType::Bash); let repo_names = repos .iter() .map(|repo| format!("{}/{}", repo.owner, repo.repo)) .collect::>(); safe_info!( safe: ("Cloning repositories via terminal"), full: ("Cloning repositories via terminal: {}", repo_names.join(", ")) ); let command = build_parallel_clone_command(repos, shell_type); let exit_code = execute_command(command, spawner).await?; if exit_code != 0.into() { return Err(PrepareEnvironmentError::CloneRepo { repo_name: repo_names.join(", "), }); } safe_info!( safe: ("Successfully cloned repositories"), full: ("Successfully cloned repositories: {}", repo_names.join(", ")) ); Ok(()) } } } /// Clone a source repository to `{working_dir}/{repo.repo}` if it does not already exist. /// This only performs the clone -- it does NOT register the repo with `DetectedRepositories`. #[tracing::instrument(skip_all, err, fields(tags.cloud_agent = true, repo = %repo))] pub(super) async fn clone_repo( repo: &SourceRepo, working_dir: &Path, spawner: &ModelSpawner, ) -> Result<(), PrepareEnvironmentError> { let repo_name = format!("{}/{}", repo.owner, repo.repo); let repo_url = repo.https_clone_url(); // Get the session's shell type for proper escaping, falling back to Bash // when the session is not yet bootstrapped or the spawn fails. let shell_type = spawner .spawn(|driver, ctx| { driver .active_session_shell_type(ctx) .unwrap_or(ShellType::Bash) }) .await .unwrap_or(ShellType::Bash); let escaped_url = shell_escape_single_quotes(&repo_url, shell_type); // We do a partial clone here to speed up environment setup time. let command = format!("git clone --filter=tree:0 '{escaped_url}'"); let repo_dir = working_dir.join(&repo.repo); // Always ask the session whether the repo dir already exists, rather // than stat'ing from the host. The session knows about sandbox-only // paths, and this goes through the silent executor so `test -d` is // not added to the user-visible blocklist. Pass the absolute path // explicitly so the probe doesn't rely on the session's CWD. let dir_exists = terminal_directory_exists(&repo_dir.to_string_lossy(), spawner).await?; if dir_exists { safe_warn!( safe: ("We already have a directory with the same repository name in the terminal working directory, skipping clone..."), full: ( "We already have a directory with the name {} in the terminal working directory, skipping clone...", repo.repo) ); } else { safe_info!( safe: ("Cloning repository via terminal"), full: ("Cloning repository via terminal: {repo_name}") ); let exit_code = execute_command(command, spawner).await?; if exit_code != 0.into() { return Err(PrepareEnvironmentError::CloneRepo { repo_name: repo_name.clone(), }); } safe_info!( safe: ("Successfully cloned repository"), full: ("Successfully cloned: {repo_name}") ); } Ok(()) } /// Register a cloned source repository with `DetectedRepositories` so that the /// skill watcher and other repo-aware subsystems can discover it. #[tracing::instrument(skip_all, err, fields(tags.cloud_agent = true, repo = %repo, is_sandbox = is_sandbox))] pub(super) async fn register_cloned_repo( repo: &SourceRepo, working_dir: &Path, is_sandbox: bool, spawner: &ModelSpawner, ) -> Result<(), PrepareEnvironmentError> { let repo_dir = working_dir.join(&repo.repo); // Register the repo with DetectedRepositories so that the skill watcher // and other repo-aware subsystems can discover it before the first query. // // TODO(advait): When the remote code server lands for Docker sandboxes, // sandbox-only working directories will be reachable from the host and // we should register + index them here too (likely via a remote-aware // path instead of `detect_possible_local_git_repo`/`index_directory`, which // both assume a local filesystem). For now, skip so we don't try to // stat paths that only exist inside the sandbox. if is_sandbox { safe_info!( safe: ("Skipping local repo detection for sandbox-only working directory"), full: ( "Skipping local repo detection and indexing for sandbox-only working directory {}", working_dir.display() ) ); } else { let repo_dir_str = repo_dir.to_string_lossy().to_string(); let detect_future = spawner .spawn(move |_, ctx| { DetectedRepositories::handle(ctx).update(ctx, |repos, ctx| { repos.detect_possible_local_git_repo( &repo_dir_str, RepoDetectionSource::CloudEnvironmentPrep, ctx, ) }) }) .await .map_err(|_| PrepareEnvironmentError::InvalidRuntimeState)?; // Await detection so the repo is registered in DirectoryWatcher // before the agent's first query. if detect_future.await.is_none() { safe_warn!( safe: ("Repository detection returned no path"), full: ("Repository detection returned no path for {}", repo_dir.display()) ); } } Ok(()) } #[cfg(any(not(feature = "local_fs"), target_family = "wasm"))] async fn subscribe_to_codebase_index_events( spawner: &ModelSpawner, repo_channels: Arc>>>, ) -> Result<(), PrepareEnvironmentError> { spawner .spawn(move |_, ctx| { let repo_channels = Arc::clone(&repo_channels); ctx.subscribe_to_model( &CodebaseIndexManager::handle(ctx), move |_, _, event, ctx| { if !matches!(event, CodebaseIndexManagerEvent::SyncStateUpdated { .. }) { return; } let manager = CodebaseIndexManager::as_ref(ctx); let mut repos_to_notify = Vec::new(); let mut channels = repo_channels .lock() .expect("repo channel map lock should not be poisoned"); for repo in channels.keys() { let Some(status) = manager.get_codebase_index_status_for_path(repo, ctx) else { continue; }; if status.has_synced_version() || (!status.has_pending() && status.last_sync_successful() == Some(false)) { repos_to_notify.push(repo.clone()); } } for repo in repos_to_notify { if let Some(tx) = channels.remove(&repo) { let _ = tx.send(()); } } }, ); }) .await .map_err(|_| PrepareEnvironmentError::InvalidRuntimeState) } #[cfg(all(feature = "local_fs", not(target_family = "wasm")))] async fn subscribe_to_codebase_index_events( spawner: &ModelSpawner, repo_channels: Arc>>>, ) -> Result<(), PrepareEnvironmentError> { spawner .spawn(move |_, ctx| { let repo_channels = Arc::clone(&repo_channels); ctx.subscribe_to_model( &LocalProjectIndexManager::handle(ctx), move |_, _, event, _ctx| { if !matches!(event, LocalProjectIndexEvent::StatusChanged { .. }) { return; } let mut repos_to_notify = Vec::new(); let mut channels = repo_channels .lock() .expect("repo channel map lock should not be poisoned"); for repo in channels.keys() { // The manager event identifies the changed root, while the channel map can // contain nested paths. Re-check each path so a root-level completion wakes // the exact environment request that owns it. if LocalProjectIndexManager::as_ref(_ctx).is_ready_for_path(repo) || matches!( LocalProjectIndexManager::as_ref(_ctx).status_for_path(repo), Some((_, LocalIndexStatus::Failed { .. })) ) { repos_to_notify.push(repo.clone()); } } for repo in repos_to_notify { if let Some(tx) = channels.remove(&repo) { let _ = tx.send(()); } } }, ); }) .await .map_err(|_| PrepareEnvironmentError::InvalidRuntimeState) } #[cfg(any(not(feature = "local_fs"), target_family = "wasm"))] #[tracing::instrument(skip_all, err, fields(tags.cloud_agent = true, repo = %repo_name))] async fn index_repo_codebase( repo_name: &str, working_dir: &Path, repo_channels: Arc>>>, spawner: &ModelSpawner, ) -> Result>, PrepareEnvironmentError> { let repo_path = working_dir.join(repo_name); safe_info!( safe: ("Trying to index repository for codebase context"), full: ("Trying to index {:?} for codebase context", repo_path) ); let repo_path_for_spawn = repo_path.clone(); spawner .spawn(move |_, ctx| { CodebaseIndexManager::handle(ctx).update(ctx, |manager, ctx| { manager.index_directory(repo_path_for_spawn.clone(), ctx); }); let status = CodebaseIndexManager::as_ref(ctx) .get_codebase_index_status_for_path(&repo_path_for_spawn, ctx); match status { Some(status) if status.has_synced_version() => { safe_info!( safe: ("Not waiting on codebase index for repository; we have one already"), full: ("Not waiting on codebase index for {:?}, we have one already", repo_path_for_spawn) ); None } _ => { safe_info!( safe: ("Waiting on codebase index for repository"), full: ("Waiting on codebase index for {:?}", repo_path_for_spawn) ); let (tx, rx) = oneshot::channel::<()>(); repo_channels .lock() .expect("repo channel map lock should not be poisoned") .insert(repo_path_for_spawn, tx); Some(rx) } } }) .await .map_err(|_| PrepareEnvironmentError::InvalidRuntimeState) } #[cfg(all(feature = "local_fs", not(target_family = "wasm")))] #[tracing::instrument(skip_all, err, fields(tags.cloud_agent = true, repo = %repo_name))] async fn index_repo_codebase( repo_name: &str, working_dir: &Path, repo_channels: Arc>>>, spawner: &ModelSpawner, ) -> Result>, PrepareEnvironmentError> { let repo_path = working_dir.join(repo_name); safe_info!( safe: ("Trying to index repository for codebase context"), full: ("Trying to index {:?} for codebase context", repo_path) ); let repo_path_for_spawn = repo_path.clone(); spawner .spawn(move |_, ctx| { let (tx, rx) = oneshot::channel::<()>(); repo_channels .lock() .expect("repo channel map lock should not be poisoned") .insert(repo_path_for_spawn.clone(), tx); let indexing_result = LocalProjectIndexManager::handle(ctx) .update(ctx, |manager, ctx| { manager.index_directory(repo_path_for_spawn.clone(), ctx) }); if let Err(error) = indexing_result { log::warn!("Failed to start local project indexing: {error:#}"); repo_channels .lock() .expect("repo channel map lock should not be poisoned") .remove(&repo_path_for_spawn); return None; } if LocalProjectIndexManager::as_ref(ctx).is_ready_for_path(&repo_path_for_spawn) { repo_channels .lock() .expect("repo channel map lock should not be poisoned") .remove(&repo_path_for_spawn); None } else { Some(rx) } }) .await .map_err(|_| PrepareEnvironmentError::InvalidRuntimeState) } /// Execute a command in the context of a terminal session. async fn execute_command( command: String, spawner: &ModelSpawner, ) -> Result { spawner .spawn(move |terminal_driver, ctx| terminal_driver.execute_command(&command, ctx)) .await .map_err(|_| PrepareEnvironmentError::InvalidRuntimeState)? .map_err(|error| match error { AgentDriverError::InvalidRuntimeState => PrepareEnvironmentError::InvalidRuntimeState, source => PrepareEnvironmentError::TerminalDriver { source }, })? .await .map_err(|error| match error { AgentDriverError::InvalidRuntimeState => PrepareEnvironmentError::InvalidRuntimeState, source => PrepareEnvironmentError::TerminalDriver { source }, })? .await .map_err(|error| match error { AgentDriverError::InvalidRuntimeState => PrepareEnvironmentError::InvalidRuntimeState, source => PrepareEnvironmentError::TerminalDriver { source }, }) } /// Change the current directory in the context of a terminal session (using `cd {dir}`). async fn cd_in_terminal( target: String, spawner: &ModelSpawner, ) -> Result { spawner .spawn(move |terminal_driver, ctx| terminal_driver.cd(&target, ctx)) .await .map_err(|_| PrepareEnvironmentError::InvalidRuntimeState)? .map_err(|error| match error { AgentDriverError::InvalidRuntimeState => PrepareEnvironmentError::InvalidRuntimeState, source => PrepareEnvironmentError::TerminalDriver { source }, })? .await .map_err(|error| match error { AgentDriverError::InvalidRuntimeState => PrepareEnvironmentError::InvalidRuntimeState, source => PrepareEnvironmentError::TerminalDriver { source }, })? .await .map_err(|error| match error { AgentDriverError::InvalidRuntimeState => PrepareEnvironmentError::InvalidRuntimeState, source => PrepareEnvironmentError::TerminalDriver { source }, }) } fn single_repo_name(repos: &[SourceRepo]) -> Option { if repos.len() != 1 { return None; } Some(repos[0].repo.clone()) } /// Change the active terminal session's working directory via `cd `, /// silently. /// /// Thin wrapper around [`TerminalDriver::cd_silent`] so the call stays /// consistent with the other `*_in_terminal` / `terminal_*` helpers in this /// module. Uses the same [`ShellFamily::shell_escape`] logic as the visible /// [`TerminalDriver::cd`] path, so it's safe across bash/zsh/fish/pwsh host /// shells. /// /// Returns `true` if the `cd` exited successfully. async fn cd_in_terminal_silent( target: String, spawner: &ModelSpawner, ) -> Result { let output = spawner .spawn(move |driver, ctx| driver.cd_silent(&target, ctx)) .await .map_err(|_| PrepareEnvironmentError::InvalidRuntimeState)? .await .map_err(|error| match error { AgentDriverError::InvalidRuntimeState => PrepareEnvironmentError::InvalidRuntimeState, source => PrepareEnvironmentError::TerminalDriver { source }, })?; Ok(output.status == CommandExitStatus::Success) } /// Returns whether the given path resolves to an existing directory from the /// perspective of the active terminal session. /// /// Runs `test -d ` through the session's in-band command executor, so /// the check is invisible in the user-facing blocklist and works for paths /// that only exist inside a remote/sandbox filesystem. The path is escaped /// using the *session's* actual shell type (bash/zsh use the `'"'"'` trick, /// fish uses a backslash, PowerShell doubles the quote) rather than assuming /// bash. /// /// Prefer passing an absolute path: relative paths resolve against the /// session's current working directory, which couples the caller to /// whatever `cd` state the session happens to be in. /// /// TODO(advait): `test -d ...` itself is POSIX-only. When we support /// environment prep on Windows host shells (PowerShell / cmd.exe), also /// branch on `ShellType` to emit the appropriate probe (e.g. /// `Test-Path -PathType Container ` for PowerShell). async fn terminal_directory_exists( path: &str, spawner: &ModelSpawner, ) -> Result { let path = path.to_owned(); let output = spawner .spawn(move |driver, ctx| { // Fall back to Bash if the session's shell type isn't known yet // (e.g. pre-bootstrap). Bash-style escaping is a safe default for // every POSIX shell we currently support. let shell_type = driver .active_session_shell_type(ctx) .unwrap_or(ShellType::Bash); let escaped = shell_escape_single_quotes(&path, shell_type); let command = format!("test -d '{escaped}'"); driver.execute_silent_command(command, ctx) }) .await .map_err(|_| PrepareEnvironmentError::InvalidRuntimeState)? .await .map_err(|error| match error { AgentDriverError::InvalidRuntimeState => PrepareEnvironmentError::InvalidRuntimeState, source => PrepareEnvironmentError::TerminalDriver { source }, })?; Ok(output.status == CommandExitStatus::Success) } #[cfg(test)] #[path = "environment_tests.rs"] mod tests;