diff --git a/codex-rs/exec-server-protocol/src/protocol.rs b/codex-rs/exec-server-protocol/src/protocol.rs index e6bf999ca1..44015eea90 100644 --- a/codex-rs/exec-server-protocol/src/protocol.rs +++ b/codex-rs/exec-server-protocol/src/protocol.rs @@ -93,6 +93,10 @@ pub struct EnvironmentInfo { /// Working directory inherited by the exec-server process. #[serde(default)] pub cwd: Option, + /// Executor-local default directories for resolving `:tmpdir`, when reported. + /// On Windows, a command's `TEMP` or `TMP` overrides take precedence. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub temporary_directories: Option>, /// Optional executor features that clients must gate before sending newer request fields. #[serde(default)] pub capabilities: EnvironmentCapabilities, @@ -132,11 +136,36 @@ pub enum EnvironmentStatusKind { impl EnvironmentInfo { /// Returns information about the current local exec-server process. pub fn local() -> Self { + let cwd = std::env::current_dir().ok(); + let temporary_directory_env_vars: &[&str] = if cfg!(windows) { + &["TEMP", "TMP"] + } else { + &["TMPDIR"] + }; + let mut temporary_directories = Vec::new(); + for name in temporary_directory_env_vars { + if let Some(path) = std::env::var_os(name) + .filter(|path| !path.is_empty()) + .filter(|path| cfg!(unix) || std::path::Path::new(path).is_absolute()) + .and_then(|path| { + PathUri::from_host_native_path(&path).ok().or_else(|| { + if cfg!(unix) { + PathUri::from_host_native_path(cwd.as_ref()?.join(path)).ok() + } else { + None + } + }) + }) + && !temporary_directories.contains(&path) + { + temporary_directories.push(path); + } + } + Self { shell: codex_shell_command::shell_detect::default_user_shell().into(), - cwd: std::env::current_dir() - .ok() - .and_then(|cwd| PathUri::from_host_native_path(cwd).ok()), + cwd: cwd.and_then(|cwd| PathUri::from_host_native_path(cwd).ok()), + temporary_directories: Some(temporary_directories), capabilities: EnvironmentCapabilities { network_proxy_launch: true, capability_discovery_sandbox: true, @@ -868,11 +897,92 @@ mod tests { path: "/bin/zsh".to_string(), }, cwd: None, + temporary_directories: None, capabilities: EnvironmentCapabilities::default(), } ); } + #[test] + fn environment_info_preserves_executor_temporary_directories() { + let expected = serde_json::json!({ + "shell": { "name": "powershell", "path": "powershell.exe" }, + "cwd": null, + "temporaryDirectories": ["file:///C:/Temp", "file:///D:/Temp"], + "capabilities": { + "networkProxyLaunch": false, + "capabilityDiscoverySandbox": false, + }, + }); + let info: EnvironmentInfo = serde_json::from_value(expected.clone()) + .expect("environment info with executor temporary directories should deserialize"); + + assert_eq!( + serde_json::to_value(info).expect("environment info should serialize"), + expected, + ); + } + + #[test] + fn local_environment_info_reads_platform_temporary_directories() { + let cwd = std::env::current_dir().expect("current directory"); + let names: &[&str] = if cfg!(windows) { + &["TEMP", "TMP"] + } else { + &["TMPDIR"] + }; + let mut expected = names + .iter() + .filter_map(std::env::var_os) + .filter(|path| !path.is_empty()) + .filter(|path| cfg!(unix) || std::path::Path::new(path).is_absolute()) + .filter_map(|path| { + PathUri::from_host_native_path(&path).ok().or_else(|| { + if cfg!(unix) { + PathUri::from_host_native_path(cwd.join(path)).ok() + } else { + None + } + }) + }) + .collect::>(); + expected.dedup(); + + assert_eq!( + EnvironmentInfo::local().temporary_directories, + Some(expected) + ); + } + + #[cfg(unix)] + #[test] + fn local_environment_info_resolves_relative_temporary_directory() { + if std::env::var_os("CODEX_TEST_RELATIVE_TMPDIR").is_none() { + let status = std::process::Command::new(std::env::current_exe().expect("test binary")) + .arg("--exact") + .arg( + "protocol::tests::local_environment_info_resolves_relative_temporary_directory", + ) + .env("CODEX_TEST_RELATIVE_TMPDIR", "1") + .env("TMPDIR", "relative-temp") + .status() + .expect("run relative TMPDIR subprocess"); + assert!(status.success(), "relative TMPDIR subprocess failed"); + return; + } + + let expected = PathUri::from_host_native_path( + std::env::current_dir() + .expect("current directory") + .join("relative-temp"), + ) + .expect("absolute temporary directory URI"); + assert_eq!( + EnvironmentInfo::local().temporary_directories, + Some(vec![expected]) + ); + } + #[test] fn filesystem_protocol_rejects_native_absolute_paths() { let native_path = std::env::current_dir() diff --git a/codex-rs/exec-server/tests/process.rs b/codex-rs/exec-server/tests/process.rs index 0ca6ce4248..1b0289776a 100644 --- a/codex-rs/exec-server/tests/process.rs +++ b/codex-rs/exec-server/tests/process.rs @@ -93,7 +93,19 @@ async fn exec_server_starts_process_over_websocket() -> anyhow::Result<()> { /// Ordinary requests run one at a time when concurrent processing is not enabled. #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn exec_server_runs_ordinary_requests_serially_by_default() -> anyhow::Result<()> { - let mut server = exec_server().await?; + let temporary_directory = tempfile::tempdir()?; + let temporary_directory_env_vars: &[&str] = if cfg!(windows) { + &["TEMP", "TMP"] + } else { + &["TMPDIR"] + }; + let mut server = exec_server_with_env( + temporary_directory_env_vars + .iter() + .map(|name| (*name, temporary_directory.path())), + &[], + ) + .await?; let process_argv = if cfg!(windows) { vec!["cmd.exe", "/D", "/C", "ping -n 601 127.0.0.1 >NUL"] } else { @@ -195,7 +207,14 @@ async fn exec_server_runs_ordinary_requests_serially_by_default() -> anyhow::Res panic!("expected the queued environment/info response after process/read"); }; assert_eq!(id, queued_environment_info_id); - let _: EnvironmentInfo = serde_json::from_value(result)?; + let mut expected_environment_info = EnvironmentInfo::local(); + expected_environment_info.temporary_directories = Some(vec![PathUri::from_host_native_path( + temporary_directory.path(), + )?]); + assert_eq!( + serde_json::from_value::(result)?, + expected_environment_info + ); let response = server .wait_for_event(|event| matches!(event, JSONRPCMessage::Response(_))) .await?;