Codex Handbook
core/src/environment_selection.rs 593 lines
use std::collections::HashSet;use std::sync::Arc;use arc_swap::ArcSwap;use codex_exec_server::EnvironmentManager;use codex_exec_server::ExecutorFileSystem;use codex_protocol::error::CodexErr;use codex_protocol::error::Result as CodexResult;use codex_protocol::protocol::TurnEnvironmentSelection;use codex_utils_absolute_path::AbsolutePathBuf;use codex_utils_path_uri::PathUri;use futures::FutureExt;use futures::future::BoxFuture;use futures::future::Shared;use crate::session::turn_context::TurnEnvironment;use crate::shell::Shell;use crate::shell_snapshot::ShellSnapshot;pub(crate) fn default_thread_environment_selections(    environment_manager: &EnvironmentManager,    cwd: &AbsolutePathBuf,) -> Vec<TurnEnvironmentSelection> {    environment_manager        .default_environment_ids()        .into_iter()        .map(|environment_id| TurnEnvironmentSelection {            environment_id,            cwd: PathUri::from_abs_path(cwd),        })        .collect()}type SnapshotTask = Shared<BoxFuture<'static, TurnEnvironmentSnapshot>>;pub(crate) struct ThreadEnvironments {    environment_manager: Arc<EnvironmentManager>,    local_shell: Shell,    shell_snapshot: ShellSnapshot,    snapshot_task: ArcSwap<SnapshotTask>,}impl ThreadEnvironments {    pub(crate) fn new(        environment_manager: Arc<EnvironmentManager>,        local_shell: Shell,        shell_snapshot: ShellSnapshot,        current: TurnEnvironmentSnapshot,    ) -> Self {        Self {            environment_manager,            local_shell,            shell_snapshot,            snapshot_task: ArcSwap::from_pointee(futures::future::ready(current).boxed().shared()),        }    }    pub(crate) fn update_selections(&self, environments: &[TurnEnvironmentSelection]) {        let previous = self            .snapshot_task            .load()            .peek()            .cloned()            .unwrap_or_default();        let environment_manager = Arc::clone(&self.environment_manager);        let local_shell = self.local_shell.clone();        let shell_snapshot = self.shell_snapshot.clone();        let environments = environments.to_vec();        let (snapshot_task, snapshot) = async move {            Self::resolve_snapshot(                environment_manager,                local_shell,                shell_snapshot,                previous,                environments,            )            .await        }        .remote_handle();        self.snapshot_task            .store(Arc::new(snapshot.boxed().shared()));        drop(tokio::spawn(snapshot_task));    }    async fn resolve_snapshot(        environment_manager: Arc<EnvironmentManager>,        local_shell: Shell,        shell_snapshot: ShellSnapshot,        current: TurnEnvironmentSnapshot,        environments: Vec<TurnEnvironmentSelection>,    ) -> TurnEnvironmentSnapshot {        let mut seen_environment_ids = HashSet::with_capacity(environments.len());        let mut turn_environments = Vec::with_capacity(environments.len());        for selected_environment in &environments {            if !seen_environment_ids.insert(selected_environment.environment_id.as_str()) {                continue;            }            let turn_environment = match current.turn_environments.iter().find(|environment| {                environment.environment_id == selected_environment.environment_id                    && environment.cwd() == &selected_environment.cwd            }) {                Some(environment) => environment.clone(),                None => match Self::resolve_selection(                    &environment_manager,                    &local_shell,                    &shell_snapshot,                    selected_environment,                )                .await                {                    Ok(environment) => environment,                    Err(err) => {                        tracing::warn!(                            "skipping unresolved turn environment `{}`: {err}",                            selected_environment.environment_id                        );                        continue;                    }                },            };            turn_environments.push(turn_environment);        }        TurnEnvironmentSnapshot { turn_environments }    }    async fn resolve_selection(        environment_manager: &EnvironmentManager,        local_shell: &Shell,        shell_snapshot: &ShellSnapshot,        selected_environment: &TurnEnvironmentSelection,    ) -> CodexResult<TurnEnvironment> {        let environment_id = selected_environment.environment_id.clone();        let environment = environment_manager            .get_environment(&environment_id)            .ok_or_else(|| {                CodexErr::InvalidRequest(format!("unknown turn environment id `{environment_id}`"))            })?;        let shell = if environment.is_remote() {            match environment.info().await {                Ok(info) => match Shell::from_environment_shell_info(info.shell) {                    Ok(shell) => Some(shell),                    Err(err) => {                        tracing::warn!(                            "failed to resolve shell for environment `{environment_id}`: {err}"                        );                        None                    }                },                Err(err) => {                    tracing::warn!("failed to get info for environment `{environment_id}`: {err}");                    None                }            }        } else {            Some(local_shell.clone())        };        let mut turn_environment = TurnEnvironment::new(            environment_id,            environment,            selected_environment.cwd.clone(),            shell,        );        let task = shell_snapshot            .clone()            .build(turn_environment.clone())            .boxed()            .shared();        drop(tokio::spawn(task.clone()));        turn_environment.shell_snapshot = task;        Ok(turn_environment)    }    pub(crate) async fn snapshot(&self) -> TurnEnvironmentSnapshot {        self.snapshot_task.load_full().as_ref().clone().await    }    pub(crate) fn environment_manager(&self) -> Arc<EnvironmentManager> {        Arc::clone(&self.environment_manager)    }}#[derive(Clone, Debug, Default)]pub(crate) struct TurnEnvironmentSnapshot {    pub(crate) turn_environments: Vec<TurnEnvironment>,}impl TurnEnvironmentSnapshot {    pub(crate) fn primary(&self) -> Option<&TurnEnvironment> {        self.turn_environments.first()    }    pub(crate) fn local(&self) -> Option<&TurnEnvironment> {        self.turn_environments            .iter()            .find(|environment| !environment.environment.is_remote())    }    #[cfg(test)]    pub(crate) fn primary_environment(&self) -> Option<Arc<codex_exec_server::Environment>> {        self.primary()            .map(|environment| Arc::clone(&environment.environment))    }    pub(crate) fn to_selections(&self) -> Vec<TurnEnvironmentSelection> {        self.turn_environments            .iter()            .map(TurnEnvironment::selection)            .collect()    }    pub(crate) fn primary_filesystem(&self) -> Option<Arc<dyn ExecutorFileSystem>> {        self.primary()            .map(|environment| environment.environment.get_filesystem())    }    pub(crate) fn single_local_environment(&self) -> Option<&TurnEnvironment> {        let [environment] = self.turn_environments.as_slice() else {            return None;        };        (!environment.environment.is_remote()).then_some(environment)    }    pub(crate) fn single_local_environment_cwd(&self) -> Option<AbsolutePathBuf> {        // TODO(anp): Migrate local-environment consumers to PathUri so this compatibility        // conversion can be removed.        self.single_local_environment()?.cwd().to_abs_path().ok()    }}#[cfg(test)]mod tests {    use codex_exec_server::Environment;    use codex_exec_server::ExecServerRuntimePaths;    use codex_exec_server::LOCAL_ENVIRONMENT_ID;    use codex_exec_server::REMOTE_ENVIRONMENT_ID;    use codex_protocol::protocol::TurnEnvironmentSelection;    use codex_utils_absolute_path::AbsolutePathBuf;    use codex_utils_path_uri::PathUri;    use pretty_assertions::assert_eq;    use super::*;    async fn resolve_turn_environments(        environment_manager: Arc<EnvironmentManager>,        selections: &[TurnEnvironmentSelection],    ) -> Arc<ThreadEnvironments> {        let turn_environments = Arc::new(ThreadEnvironments::new(            environment_manager,            crate::shell::default_user_shell(),            ShellSnapshot::disabled(),            TurnEnvironmentSnapshot::default(),        ));        turn_environments.update_selections(selections);        turn_environments.snapshot().await;        turn_environments    }    fn test_runtime_paths() -> ExecServerRuntimePaths {        ExecServerRuntimePaths::new(            std::env::current_exe().expect("current exe"),            /*codex_linux_sandbox_exe*/ None,        )        .expect("runtime paths")    }    #[tokio::test]    async fn default_thread_environment_selections_use_manager_default_id() {        let cwd = AbsolutePathBuf::current_dir().expect("cwd");        let cwd_uri = PathUri::from_abs_path(&cwd);        let manager = EnvironmentManager::create_for_tests(            Some("ws://127.0.0.1:8765".to_string()),            Some(test_runtime_paths()),        )        .await;        assert_eq!(            default_thread_environment_selections(&manager, &cwd),            vec![TurnEnvironmentSelection {                environment_id: REMOTE_ENVIRONMENT_ID.to_string(),                cwd: cwd_uri,            }]        );    }    #[tokio::test]    async fn toml_default_thread_environment_selections_include_local_and_remote() {        let temp_dir = tempfile::tempdir().expect("tempdir");        std::fs::write(            temp_dir.path().join("environments.toml"),            r#"[[environments]]id = "remote"url = "ws://127.0.0.1:8765""#,        )        .expect("write environments.toml");        let cwd = AbsolutePathBuf::current_dir().expect("cwd");        let cwd_uri = PathUri::from_abs_path(&cwd);        let manager =            EnvironmentManager::from_codex_home(temp_dir.path(), Some(test_runtime_paths()))                .await                .expect("environment manager");        assert_eq!(            default_thread_environment_selections(&manager, &cwd),            vec![                TurnEnvironmentSelection {                    environment_id: LOCAL_ENVIRONMENT_ID.to_string(),                    cwd: cwd_uri.clone(),                },                TurnEnvironmentSelection {                    environment_id: REMOTE_ENVIRONMENT_ID.to_string(),                    cwd: cwd_uri,                },            ]        );    }    #[tokio::test]    async fn default_thread_environment_selections_empty_when_default_disabled() {        let cwd = AbsolutePathBuf::current_dir().expect("cwd");        let manager = EnvironmentManager::without_environments();        assert_eq!(            default_thread_environment_selections(&manager, &cwd),            Vec::<TurnEnvironmentSelection>::new()        );    }    #[tokio::test]    async fn local_environment_uses_configured_shell() {        let cwd = AbsolutePathBuf::current_dir().expect("cwd");        let local_shell = Shell {            shell_type: crate::shell::ShellType::Zsh,            shell_path: std::path::PathBuf::from("/configured/zsh"),        };        let turn_environments = ThreadEnvironments::new(            Arc::new(EnvironmentManager::default_for_tests()),            local_shell.clone(),            ShellSnapshot::disabled(),            TurnEnvironmentSnapshot::default(),        );        turn_environments.update_selections(&[TurnEnvironmentSelection {            environment_id: LOCAL_ENVIRONMENT_ID.to_string(),            cwd: PathUri::from_abs_path(&cwd),        }]);        let snapshot = turn_environments.snapshot().await;        assert_eq!(            snapshot                .primary()                .and_then(|environment| environment.shell.as_ref()),            Some(&local_shell)        );    }    #[tokio::test]    async fn resolve_environment_selections_keeps_first_duplicate_id() {        let cwd = AbsolutePathBuf::current_dir().expect("cwd");        let cwd_uri = PathUri::from_abs_path(&cwd);        let manager = Arc::new(EnvironmentManager::default_for_tests());        let first = TurnEnvironmentSelection {            environment_id: LOCAL_ENVIRONMENT_ID.to_string(),            cwd: cwd_uri.clone(),        };        let resolved = resolve_turn_environments(            manager,            &[                first.clone(),                TurnEnvironmentSelection {                    environment_id: LOCAL_ENVIRONMENT_ID.to_string(),                    cwd: cwd_uri.join("other").expect("other cwd URI"),                },            ],        )        .await;        assert_eq!(resolved.snapshot().await.to_selections(), vec![first]);    }    #[tokio::test]    async fn resolved_environment_selections_use_first_selection_as_primary() {        let cwd = AbsolutePathBuf::current_dir().expect("cwd");        let selected_cwd = cwd.join("selected");        let selected_cwd_uri = PathUri::from_abs_path(&selected_cwd);        let manager = Arc::new(EnvironmentManager::default_for_tests());        let resolved = resolve_turn_environments(            Arc::clone(&manager),            &[TurnEnvironmentSelection {                environment_id: "local".to_string(),                cwd: selected_cwd_uri,            }],        )        .await;        let resolved = resolved.snapshot().await;        assert_eq!(            resolved                .primary()                .expect("primary environment")                .environment_id,            "local"        );        assert_eq!(            resolved.primary().expect("primary environment").shell,            Some(                Shell::from_environment_shell_info(                    manager                        .get_environment("local")                        .expect("local environment")                        .info()                        .await                        .expect("local environment info")                        .shell                )                .expect("resolved shell")            )        );    }    #[tokio::test]    async fn unresolved_environment_selections_are_skipped() {        let cwd = AbsolutePathBuf::current_dir().expect("cwd");        let cwd_uri = PathUri::from_abs_path(&cwd);        let manager = Arc::new(EnvironmentManager::default_for_tests());        let local = TurnEnvironmentSelection {            environment_id: LOCAL_ENVIRONMENT_ID.to_string(),            cwd: cwd_uri.clone(),        };        let resolved = resolve_turn_environments(            manager,            &[                TurnEnvironmentSelection {                    environment_id: "missing".to_string(),                    cwd: cwd_uri,                },                local.clone(),            ],        )        .await;        assert_eq!(resolved.snapshot().await.to_selections(), vec![local]);    }    #[tokio::test]    async fn latest_environment_update_wins_while_previous_resolution_is_pending() {        let listener = tokio::net::TcpListener::bind("127.0.0.1:0")            .await            .expect("bind websocket listener");        let manager = Arc::new(            EnvironmentManager::create_for_tests_with_local(                Some(format!(                    "ws://{}",                    listener.local_addr().expect("listener address")                )),                test_runtime_paths(),            )            .await,        );        let cwd = AbsolutePathBuf::current_dir().expect("cwd");        let turn_environments = Arc::new(ThreadEnvironments::new(            manager,            crate::shell::default_user_shell(),            ShellSnapshot::disabled(),            TurnEnvironmentSnapshot::default(),        ));        turn_environments.update_selections(&[TurnEnvironmentSelection {            environment_id: REMOTE_ENVIRONMENT_ID.to_string(),            cwd: PathUri::from_abs_path(&cwd),        }]);        let (_connection, _) =            tokio::time::timeout(std::time::Duration::from_secs(5), listener.accept())                .await                .expect("remote resolution should connect")                .expect("accept remote resolution connection");        let local = TurnEnvironmentSelection {            environment_id: LOCAL_ENVIRONMENT_ID.to_string(),            cwd: PathUri::from_abs_path(&cwd),        };        turn_environments.update_selections(std::slice::from_ref(&local));        let snapshot = tokio::time::timeout(            std::time::Duration::from_secs(5),            turn_environments.snapshot(),        )        .await        .expect("latest environment resolution should complete");        assert_eq!(snapshot.to_selections(), vec![local]);    }    #[tokio::test]    async fn matching_environment_id_and_cwd_reuse_resolved_environment() {        let cwd = AbsolutePathBuf::current_dir().expect("cwd");        let manager = Arc::new(            EnvironmentManager::create_for_tests(                Some("ws://127.0.0.1:8765".to_string()),                Some(test_runtime_paths()),            )            .await,        );        let selection = TurnEnvironmentSelection {            environment_id: REMOTE_ENVIRONMENT_ID.to_string(),            cwd: PathUri::from_abs_path(&cwd),        };        let initial =            resolve_turn_environments(Arc::clone(&manager), std::slice::from_ref(&selection)).await;        manager            .upsert_environment(                REMOTE_ENVIRONMENT_ID.to_string(),                "ws://127.0.0.1:9876".to_string(),            )            .expect("replace environment");        let initial_snapshot = initial.snapshot().await;        initial.update_selections(std::slice::from_ref(&selection));        let reused_snapshot = initial.snapshot().await;        initial.update_selections(&[TurnEnvironmentSelection {            cwd: PathUri::from_abs_path(&cwd.join("changed")),            ..selection        }]);        let changed_snapshot = initial.snapshot().await;        assert!(Arc::ptr_eq(            &initial_snapshot                .primary()                .expect("initial environment")                .environment,            &reused_snapshot                .primary()                .expect("reused environment")                .environment,        ));        assert!(!Arc::ptr_eq(            &reused_snapshot                .primary()                .expect("reused environment")                .environment,            &changed_snapshot                .primary()                .expect("changed environment")                .environment,        ));    }    #[tokio::test]    async fn single_local_environment_cwd_requires_exactly_one_local_environment() {        let cwd = AbsolutePathBuf::current_dir().expect("cwd");        let cwd_uri = PathUri::from_abs_path(&cwd);        let local_manager = Arc::new(EnvironmentManager::default_for_tests());        let local = resolve_turn_environments(            Arc::clone(&local_manager),            &[TurnEnvironmentSelection {                environment_id: LOCAL_ENVIRONMENT_ID.to_string(),                cwd: cwd_uri.clone(),            }],        )        .await;        let local = local.snapshot().await;        let remote_environment = Arc::new(            Environment::create_for_tests(Some("ws://127.0.0.1:8765".to_string()))                .expect("remote environment"),        );        let remote = TurnEnvironmentSnapshot {            turn_environments: vec![TurnEnvironment::new(                REMOTE_ENVIRONMENT_ID.to_string(),                remote_environment.clone(),                cwd_uri.clone(),                /*shell*/ None,            )],        };        let multiple = TurnEnvironmentSnapshot {            turn_environments: vec![                local.primary().expect("local environment").clone(),                TurnEnvironment::new(                    REMOTE_ENVIRONMENT_ID.to_string(),                    remote_environment,                    cwd_uri,                    /*shell*/ None,                ),            ],        };        assert_eq!(local.single_local_environment_cwd(), Some(cwd));        assert_eq!(remote.single_local_environment_cwd(), None);        assert_eq!(multiple.single_local_environment_cwd(), None);    }}