diff --git a/AGENTS.md b/AGENTS.md index cc7994efbe1..50c10b1da1f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -75,6 +75,7 @@ If you don’t have the tool: - Tests should use pretty_assertions::assert_eq for clearer diffs. Import this at the top of the test module if it isn't already. - Prefer deep equals comparisons whenever possible. Perform `assert_eq!()` on entire objects, rather than individual fields. +- Avoid mutating process environment in tests; prefer passing environment-derived flags or dependencies from above. ### Integration tests (core) diff --git a/codex-rs/core/src/config/service.rs b/codex-rs/core/src/config/service.rs index 6f9d0e4d602..958044eafde 100644 --- a/codex-rs/core/src/config/service.rs +++ b/codex-rs/core/src/config/service.rs @@ -7,6 +7,7 @@ use crate::config_loader::ConfigLayerStack; use crate::config_loader::LoaderOverrides; use crate::config_loader::load_config_layers_state; use crate::config_loader::merge_toml_values; +use crate::path_utils; use codex_app_server_protocol::Config as ApiConfig; use codex_app_server_protocol::ConfigBatchWriteParams; use codex_app_server_protocol::ConfigLayerMetadata; @@ -470,9 +471,10 @@ fn validate_config(value: &TomlValue) -> Result<(), toml::de::Error> { } fn paths_match(expected: &Path, provided: &Path) -> bool { - if let (Ok(expanded_expected), Ok(expanded_provided)) = - (expected.canonicalize(), provided.canonicalize()) - { + if let (Ok(expanded_expected), Ok(expanded_provided)) = ( + path_utils::normalize_for_path_comparison(expected), + path_utils::normalize_for_path_comparison(provided), + ) { return expanded_expected == expanded_provided; } diff --git a/codex-rs/core/src/lib.rs b/codex-rs/core/src/lib.rs index 11b49c78c41..f78c19328f0 100644 --- a/codex-rs/core/src/lib.rs +++ b/codex-rs/core/src/lib.rs @@ -41,6 +41,7 @@ mod mcp_tool_call; mod message_history; mod model_provider_info; pub mod parse_command; +pub mod path_utils; pub mod powershell; pub mod sandboxing; mod stream_events_utils; diff --git a/codex-rs/core/src/path_utils.rs b/codex-rs/core/src/path_utils.rs new file mode 100644 index 00000000000..9a7007e4f10 --- /dev/null +++ b/codex-rs/core/src/path_utils.rs @@ -0,0 +1,116 @@ +use std::path::Path; +use std::path::PathBuf; + +use crate::env; + +pub fn normalize_for_path_comparison(path: &Path) -> std::io::Result { + let canonical = path.canonicalize()?; + Ok(normalize_for_wsl(canonical)) +} + +fn normalize_for_wsl(path: PathBuf) -> PathBuf { + normalize_for_wsl_with_flag(path, env::is_wsl()) +} + +fn normalize_for_wsl_with_flag(path: PathBuf, is_wsl: bool) -> PathBuf { + if !is_wsl { + return path; + } + + if !is_wsl_case_insensitive_path(&path) { + return path; + } + + lower_ascii_path(path) +} + +fn is_wsl_case_insensitive_path(path: &Path) -> bool { + #[cfg(target_os = "linux")] + { + use std::os::unix::ffi::OsStrExt; + use std::path::Component; + + let mut components = path.components(); + let Some(Component::RootDir) = components.next() else { + return false; + }; + let Some(Component::Normal(mnt)) = components.next() else { + return false; + }; + if !ascii_eq_ignore_case(mnt.as_bytes(), b"mnt") { + return false; + } + let Some(Component::Normal(drive)) = components.next() else { + return false; + }; + let drive_bytes = drive.as_bytes(); + drive_bytes.len() == 1 && drive_bytes[0].is_ascii_alphabetic() + } + #[cfg(not(target_os = "linux"))] + { + let _ = path; + false + } +} + +#[cfg(target_os = "linux")] +fn ascii_eq_ignore_case(left: &[u8], right: &[u8]) -> bool { + left.len() == right.len() + && left + .iter() + .zip(right) + .all(|(lhs, rhs)| lhs.to_ascii_lowercase() == *rhs) +} + +#[cfg(target_os = "linux")] +fn lower_ascii_path(path: PathBuf) -> PathBuf { + use std::ffi::OsString; + use std::os::unix::ffi::OsStrExt; + use std::os::unix::ffi::OsStringExt; + + // WSL mounts Windows drives under /mnt/, which are case-insensitive. + let bytes = path.as_os_str().as_bytes(); + let mut lowered = Vec::with_capacity(bytes.len()); + for byte in bytes { + lowered.push(byte.to_ascii_lowercase()); + } + PathBuf::from(OsString::from_vec(lowered)) +} + +#[cfg(not(target_os = "linux"))] +fn lower_ascii_path(path: PathBuf) -> PathBuf { + path +} + +#[cfg(test)] +mod tests { + #[cfg(target_os = "linux")] + mod wsl { + use super::super::normalize_for_wsl_with_flag; + use pretty_assertions::assert_eq; + use std::path::PathBuf; + + #[test] + fn wsl_mnt_drive_paths_lowercase() { + let normalized = normalize_for_wsl_with_flag(PathBuf::from("/mnt/C/Users/Dev"), true); + + assert_eq!(normalized, PathBuf::from("/mnt/c/users/dev")); + } + + #[test] + fn wsl_non_drive_paths_unchanged() { + let path = PathBuf::from("/mnt/cc/Users/Dev"); + let normalized = normalize_for_wsl_with_flag(path.clone(), true); + + assert_eq!(normalized, path); + } + + #[test] + fn wsl_non_mnt_paths_unchanged() { + let path = PathBuf::from("/home/Dev"); + let normalized = normalize_for_wsl_with_flag(path.clone(), true); + + assert_eq!(normalized, path); + } + } +} diff --git a/codex-rs/tui/src/resume_picker.rs b/codex-rs/tui/src/resume_picker.rs index f2c6a3269dd..7f3665d563d 100644 --- a/codex-rs/tui/src/resume_picker.rs +++ b/codex-rs/tui/src/resume_picker.rs @@ -10,6 +10,7 @@ use codex_core::ConversationsPage; use codex_core::Cursor; use codex_core::INTERACTIVE_SESSION_SOURCES; use codex_core::RolloutRecorder; +use codex_core::path_utils; use codex_protocol::items::TurnItem; use color_eyre::eyre::Result; use crossterm::event::KeyCode; @@ -670,7 +671,10 @@ fn extract_session_meta_from_head(head: &[serde_json::Value]) -> (Option bool { - if let (Ok(ca), Ok(cb)) = (a.canonicalize(), b.canonicalize()) { + if let (Ok(ca), Ok(cb)) = ( + path_utils::normalize_for_path_comparison(a), + path_utils::normalize_for_path_comparison(b), + ) { return ca == cb; } a == b diff --git a/codex-rs/tui2/src/resume_picker.rs b/codex-rs/tui2/src/resume_picker.rs index f2c6a3269dd..7f3665d563d 100644 --- a/codex-rs/tui2/src/resume_picker.rs +++ b/codex-rs/tui2/src/resume_picker.rs @@ -10,6 +10,7 @@ use codex_core::ConversationsPage; use codex_core::Cursor; use codex_core::INTERACTIVE_SESSION_SOURCES; use codex_core::RolloutRecorder; +use codex_core::path_utils; use codex_protocol::items::TurnItem; use color_eyre::eyre::Result; use crossterm::event::KeyCode; @@ -670,7 +671,10 @@ fn extract_session_meta_from_head(head: &[serde_json::Value]) -> (Option bool { - if let (Ok(ca), Ok(cb)) = (a.canonicalize(), b.canonicalize()) { + if let (Ok(ca), Ok(cb)) = ( + path_utils::normalize_for_path_comparison(a), + path_utils::normalize_for_path_comparison(b), + ) { return ca == cb; } a == b