diff --git a/codex-rs/core/src/environment_selection.rs b/codex-rs/core/src/environment_selection.rs index 89808c27ee..1bc1325891 100644 --- a/codex-rs/core/src/environment_selection.rs +++ b/codex-rs/core/src/environment_selection.rs @@ -17,9 +17,17 @@ pub(crate) fn default_thread_environment_selections( environment_manager .default_environment_ids() .into_iter() - .map(|environment_id| TurnEnvironmentSelection { - environment_id, - cwd: cwd.clone(), + .map(|environment_id| { + let environment = environment_manager + .get_environment(&environment_id) + .expect("default environment id should resolve"); + TurnEnvironmentSelection { + environment_id, + cwd: environment + .default_cwd() + .cloned() + .unwrap_or_else(|| cwd.clone()), + } }) .collect() } @@ -122,13 +130,17 @@ mod tests { #[tokio::test] async fn toml_default_thread_environment_selections_include_local_and_remote() { let temp_dir = tempfile::tempdir().expect("tempdir"); + let default_cwd = AbsolutePathBuf::from_absolute_path("/workspace").expect("cwd"); std::fs::write( temp_dir.path().join("environments.toml"), - r#" + format!( + r#" [[environments]] id = "remote" url = "ws://127.0.0.1:8765" -"#, +default_cwd = "{default_cwd}" +"# + ), ) .expect("write environments.toml"); let cwd = AbsolutePathBuf::current_dir().expect("cwd"); @@ -145,7 +157,7 @@ url = "ws://127.0.0.1:8765" }, TurnEnvironmentSelection { environment_id: REMOTE_ENVIRONMENT_ID.to_string(), - cwd, + cwd: default_cwd, }, ] ); diff --git a/codex-rs/core/src/thread_manager_tests.rs b/codex-rs/core/src/thread_manager_tests.rs index 9fedff9470..b9a1c6ea83 100644 --- a/codex-rs/core/src/thread_manager_tests.rs +++ b/codex-rs/core/src/thread_manager_tests.rs @@ -351,17 +351,22 @@ async fn start_thread_uses_all_default_environments_from_codex_home() { let mut config = test_config().await; config.codex_home = temp_dir.path().join("codex-home").abs(); config.cwd = config.codex_home.abs(); + let local_cwd = config.cwd.display().to_string(); + let dev_cwd = "/home/dev-user/code/codex"; std::fs::create_dir_all(&config.codex_home).expect("create codex home"); std::fs::write( config.codex_home.join("environments.toml"), - r#" + format!( + r#" default = "dev" [[environments]] id = "dev" program = "ssh" args = ["dev", "cd /tmp && true"] -"#, +default_cwd = "{dev_cwd}" +"# + ), ) .expect("write environments.toml"); @@ -416,15 +421,14 @@ args = ["dev", "cd /tmp && true"] }) .expect("environment context prompt item"); assert!(environment_context.contains("")); - let cwd = thread.session_configured.cwd.display().to_string(); let dev_entry = format!( r#" - {cwd} + {dev_cwd} "# ); let local_entry = format!( r#" - {cwd} + {local_cwd} "# ); let dev_position = environment_context diff --git a/codex-rs/core/tests/suite/remote_env.rs b/codex-rs/core/tests/suite/remote_env.rs index 0bd449188c..4f193160d6 100644 --- a/codex-rs/core/tests/suite/remote_env.rs +++ b/codex-rs/core/tests/suite/remote_env.rs @@ -2,8 +2,10 @@ use anyhow::Context; use anyhow::Result; use codex_exec_server::CopyOptions; use codex_exec_server::CreateDirectoryOptions; +use codex_exec_server::ExecParams; use codex_exec_server::FileSystemSandboxContext; use codex_exec_server::LOCAL_ENVIRONMENT_ID; +use codex_exec_server::ProcessId; use codex_exec_server::REMOTE_ENVIRONMENT_ID; use codex_exec_server::RemoveOptions; use codex_features::Feature; @@ -84,6 +86,74 @@ async fn remote_test_env_can_connect_and_use_filesystem() -> Result<()> { Ok(()) } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn environments_toml_default_cwd_runs_in_docker_remote_environment() -> Result<()> { + let Some(_remote_env) = get_remote_test_env() else { + return Ok(()); + }; + + let codex_home = TempDir::new()?; + let default_cwd = remote_test_file_path().join("default-cwd").abs(); + let default_cwd_display = default_cwd.as_path().display(); + remote_exec(&format!("mkdir -p {}", default_cwd.as_path().display()))?; + std::fs::write( + codex_home.path().join("environments.toml"), + format!( + r#" +default = "docker" + +[[environments]] +id = "docker" +url = "{}" +default_cwd = "{default_cwd_display}" +"#, + std::env::var("CODEX_TEST_REMOTE_EXEC_SERVER_URL")? + ), + )?; + let environment_manager = codex_exec_server::EnvironmentManager::from_codex_home( + codex_home.path(), + codex_exec_server::ExecServerRuntimePaths::new( + std::env::current_exe()?, + /*codex_linux_sandbox_exe*/ None, + )?, + ) + .await?; + let environment = environment_manager + .default_environment() + .context("default environment should resolve")?; + assert_eq!(environment.default_cwd(), Some(&default_cwd)); + + let started = environment + .get_exec_backend() + .start(ExecParams { + process_id: ProcessId::from("default-cwd-pwd"), + argv: vec!["pwd".to_string()], + cwd: default_cwd.to_path_buf(), + env_policy: None, + env: Default::default(), + tty: false, + pipe_stdin: false, + arg0: None, + }) + .await?; + let response = started + .process + .read(/*after_seq*/ None, None, Some(1_000)) + .await?; + let stdout = response + .chunks + .into_iter() + .flat_map(|chunk| chunk.chunk.0) + .collect::>(); + + assert_eq!( + String::from_utf8(stdout)?, + format!("{default_cwd_display}\n") + ); + + Ok(()) +} + fn absolute_path(path: PathBuf) -> AbsolutePathBuf { match AbsolutePathBuf::try_from(path) { Ok(path) => path, diff --git a/codex-rs/exec-server/src/environment.rs b/codex-rs/exec-server/src/environment.rs index 7e4a3fb056..952ff2f3a7 100644 --- a/codex-rs/exec-server/src/environment.rs +++ b/codex-rs/exec-server/src/environment.rs @@ -2,6 +2,8 @@ use std::collections::HashMap; use std::sync::Arc; use std::sync::RwLock; +use codex_utils_absolute_path::AbsolutePathBuf; + use crate::ExecServerError; use crate::ExecServerRuntimePaths; use crate::ExecutorFileSystem; @@ -289,6 +291,7 @@ impl EnvironmentManager { /// paths used by filesystem helpers. #[derive(Clone)] pub struct Environment { + default_cwd: Option, exec_server_url: Option, remote_transport: Option, exec_backend: Arc, @@ -301,6 +304,7 @@ impl Environment { /// Builds a test-only local environment without configured sandbox helper paths. pub fn default_for_tests() -> Self { Self { + default_cwd: None, exec_server_url: None, remote_transport: None, exec_backend: Arc::new(LocalProcess::default()), @@ -357,6 +361,7 @@ impl Environment { pub(crate) fn local(local_runtime_paths: ExecServerRuntimePaths) -> Self { Self { + default_cwd: None, exec_server_url: None, remote_transport: None, exec_backend: Arc::new(LocalProcess::default()), @@ -381,6 +386,18 @@ impl Environment { pub(crate) fn remote_with_transport( remote_transport: ExecServerTransportParams, local_runtime_paths: Option, + ) -> Self { + Self::remote_with_transport_and_default_cwd( + remote_transport, + local_runtime_paths, + /*default_cwd*/ None, + ) + } + + pub(crate) fn remote_with_transport_and_default_cwd( + remote_transport: ExecServerTransportParams, + local_runtime_paths: Option, + default_cwd: Option, ) -> Self { let exec_server_url = match &remote_transport { ExecServerTransportParams::WebSocketUrl { @@ -395,6 +412,7 @@ impl Environment { Arc::new(RemoteFileSystem::new(client.clone())); Self { + default_cwd, exec_server_url, remote_transport: Some(remote_transport), exec_backend, @@ -408,6 +426,10 @@ impl Environment { self.remote_transport.is_some() } + pub fn default_cwd(&self) -> Option<&AbsolutePathBuf> { + self.default_cwd.as_ref() + } + /// Returns the remote exec-server URL when this environment is remote. pub fn exec_server_url(&self) -> Option<&str> { self.exec_server_url.as_deref() diff --git a/codex-rs/exec-server/src/environment_toml.rs b/codex-rs/exec-server/src/environment_toml.rs index 90f4c78262..317703676f 100644 --- a/codex-rs/exec-server/src/environment_toml.rs +++ b/codex-rs/exec-server/src/environment_toml.rs @@ -5,6 +5,7 @@ use std::path::PathBuf; use std::time::Duration; use async_trait::async_trait; +use codex_utils_absolute_path::AbsolutePathBuf; use serde::Deserialize; use tokio_tungstenite::tungstenite::client::IntoClientRequest; @@ -41,6 +42,7 @@ struct EnvironmentToml { args: Option>, env: Option>, cwd: Option, + default_cwd: Option, #[serde(default, with = "option_duration_secs")] connect_timeout_sec: Option, #[serde(default, with = "option_duration_secs")] @@ -50,7 +52,7 @@ struct EnvironmentToml { #[derive(Clone, Debug, PartialEq, Eq)] struct TomlEnvironmentProvider { default: EnvironmentDefault, - environments: Vec<(String, ExecServerTransportParams)>, + environments: Vec<(String, ExecServerTransportParams, Option)>, } impl TomlEnvironmentProvider { @@ -66,13 +68,13 @@ impl TomlEnvironmentProvider { let mut ids = HashSet::from([LOCAL_ENVIRONMENT_ID.to_string()]); let mut environments = Vec::with_capacity(config.environments.len()); for item in config.environments { - let (id, transport) = parse_environment_toml(item, config_dir)?; + let (id, transport, default_cwd) = parse_environment_toml(item, config_dir)?; if !ids.insert(id.clone()) { return Err(ExecServerError::Protocol(format!( "environment id `{id}` is duplicated" ))); } - environments.push((id, transport)); + environments.push((id, transport, default_cwd)); } let default = normalize_default_environment_id(config.default.as_deref(), &ids)?; Ok(Self { @@ -86,12 +88,13 @@ impl TomlEnvironmentProvider { impl EnvironmentProvider for TomlEnvironmentProvider { async fn snapshot(&self) -> Result { let mut environments = Vec::with_capacity(self.environments.len()); - for (id, transport_params) in &self.environments { + for (id, transport_params, default_cwd) in &self.environments { environments.push(( id.clone(), - Environment::remote_with_transport( + Environment::remote_with_transport_and_default_cwd( transport_params.clone(), /*local_runtime_paths*/ None, + default_cwd.clone(), ), )); } @@ -107,7 +110,7 @@ impl EnvironmentProvider for TomlEnvironmentProvider { fn parse_environment_toml( item: EnvironmentToml, config_dir: Option<&Path>, -) -> Result<(String, ExecServerTransportParams), ExecServerError> { +) -> Result<(String, ExecServerTransportParams, Option), ExecServerError> { let EnvironmentToml { id, url, @@ -115,6 +118,7 @@ fn parse_environment_toml( args, env, cwd, + default_cwd, connect_timeout_sec, initialize_timeout_sec, } = item; @@ -168,7 +172,7 @@ fn parse_environment_toml( } }; - Ok((id, transport_params)) + Ok((id, transport_params, default_cwd)) } fn normalize_stdio_cwd( @@ -565,6 +569,26 @@ mod tests { ); } + #[tokio::test] + async fn toml_provider_adds_configured_default_cwd_to_environment() { + let default_cwd = AbsolutePathBuf::from_absolute_path("/workspace").expect("cwd"); + let provider = TomlEnvironmentProvider::new(EnvironmentsToml { + default: None, + environments: vec![EnvironmentToml { + id: "ssh-dev".to_string(), + program: Some("ssh".to_string()), + default_cwd: Some(default_cwd.clone()), + ..Default::default() + }], + }) + .expect("provider"); + + let snapshot = provider.snapshot().await.expect("environments"); + let environment = &snapshot.environments[0].1; + + assert_eq!(environment.default_cwd(), Some(&default_cwd)); + } + #[test] fn toml_provider_rejects_relative_stdio_cwd_without_config_dir() { let err = TomlEnvironmentProvider::new(EnvironmentsToml { @@ -664,6 +688,7 @@ id = "ssh-dev" program = "ssh" args = ["dev", "codex exec-server --listen stdio"] cwd = "/tmp" +default_cwd = "/workspace" [environments.env] CODEX_LOG = "debug" "#, @@ -698,6 +723,7 @@ CODEX_LOG = "debug" "debug".to_string(), )])), cwd: Some(PathBuf::from("/tmp")), + default_cwd: Some(AbsolutePathBuf::from_absolute_path("/workspace").expect("cwd")), ..Default::default() } );