use std::{ collections::HashMap, future::Future, path::{Path, PathBuf}, sync::{Arc, Mutex}, time::Duration, }; use crate::ai::cloud_environments::{AmbientAgentEnvironment, GithubRepo}; use crate::terminal::model::session::command_executor::shell_escape_single_quotes; use crate::terminal::shell::ShellType; use ai::index::full_source_code_embedding::manager::{ CodebaseIndexManager, CodebaseIndexManagerEvent, }; use futures::{channel::oneshot, future::join_all}; use repo_metadata::repositories::{DetectedRepositories, RepoDetectionSource}; use galaxy_completer::completer::CommandExitStatus; use galaxy_core::{command::ExitCode, safe_info, safe_warn}; use galaxyui::{r#async::FutureExt, ModelContext, ModelSpawner, SingletonEntity}; use super::{terminal::TerminalDriver, AgentDriverError}; use galaxy_cli::agent::Harness; 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, ctx: &mut ModelContext, ) -> impl Future> { let spawner = ctx.spawner(); async move { let AmbientAgentEnvironment { github_repos, setup_commands, .. } = environment; // 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 && !github_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, &github_repos, setup_commands, should_index_codebase, Arc::clone(&repo_channels), ) .await; if should_subscribe_to_index_updates { let _ = spawner .spawn(|_, ctx| { ctx.unsubscribe_from_model(&CodebaseIndexManager::handle(ctx)); }) .await; } result } } async fn prepare_environment_impl( spawner: &ModelSpawner, working_dir: &Path, is_sandbox: bool, github_repos: &[GithubRepo], setup_commands: Vec, should_index_codebase: bool, repo_channels: Arc>>>, ) -> 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(); for repo in github_repos { let repo_name = format!("{}/{}", repo.owner, repo.repo); let repo_url = format!("https://github.com/{repo_name}.git"); // We do a partial clone here to speed up environment setup time. let command = format!("git clone --filter=tree:0 {repo_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}") ); } // 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_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_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()) ); } if 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); } } } } let has_setup_commands = !setup_commands.is_empty(); if has_setup_commands { // 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}") ); } if has_setup_commands { // Unset CI after setup commands complete so the agent session // does not run with CI=true. execute_command("unset CI".to_string(), spawner).await?; } if !github_repos.is_empty() { // Wait for codebase indexing for all repositories after running setup commands. // We skip this if running in Docker sandboxes since they don't have a cache volume. // We also skip this in Namespace to reduce startup time. #[cfg(not(target_family = "wasm"))] let should_wait_for_indexing = !matches!( galaxy_isolation_platform::detect(), Some( galaxy_isolation_platform::IsolationPlatformType::DockerSandbox | galaxy_isolation_platform::IsolationPlatformType::Namespace ) ); #[cfg(target_family = "wasm")] let should_wait_for_indexing = true; if should_wait_for_indexing { 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", ); } } else { drop(codebase_context_receivers); log::info!("Not waiting for codebase index sync"); } } // 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(github_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(()) } 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() { repos_to_notify.push(repo.clone()); continue; } if !status.has_pending() && status.last_sync_successful() == Some(false) { safe_warn!( safe: ("Codebase index sync failed for a repo; unblocking environment setup"), full: ("Codebase index sync failed for {repo:?}; unblocking environment setup") ); 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) } 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) } /// 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: &[GithubRepo]) -> 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;