diff --git a/codex-rs/app-server-protocol/src/protocol/item_builders.rs b/codex-rs/app-server-protocol/src/protocol/item_builders.rs index 4066659c46..2746f10d60 100644 --- a/codex-rs/app-server-protocol/src/protocol/item_builders.rs +++ b/codex-rs/app-server-protocol/src/protocol/item_builders.rs @@ -23,6 +23,7 @@ use crate::protocol::v2::PatchApplyStatus; use crate::protocol::v2::PatchChangeKind; use crate::protocol::v2::ThreadItem; use codex_protocol::ThreadId; +use codex_protocol::parse_command::ParsedCommand; use codex_protocol::protocol::ApplyPatchApprovalRequestEvent; use codex_protocol::protocol::ExecApprovalRequestEvent; use codex_protocol::protocol::ExecCommandBeginEvent; @@ -42,8 +43,37 @@ use std::collections::HashMap; use std::path::PathBuf; pub fn native_command_cwd(cwd: &AbsolutePathBuf) -> ApiPathString { - ApiPathString::from_path_uri(&PathUri::from_abs_path(cwd), PathConvention::native()) - .unwrap_or_else(|_| ApiPathString::new(cwd.to_string_lossy().into_owned())) + native_command_cwd_from_uri(&PathUri::from_abs_path(cwd), PathConvention::native()) +} + +fn native_command_cwd_from_uri(cwd: &PathUri, path_convention: PathConvention) -> ApiPathString { + ApiPathString::from_path_uri(cwd, path_convention) + .unwrap_or_else(|_| ApiPathString::new(cwd.to_string())) +} + +fn command_actions_for_event( + parsed_cmd: &[ParsedCommand], + cwd: &PathUri, + path_convention: PathConvention, +) -> Vec { + let host_cwd = (path_convention == PathConvention::native()) + .then(|| cwd.to_abs_path().ok()) + .flatten(); + parsed_cmd + .iter() + .cloned() + .map(|parsed| match host_cwd.as_ref() { + Some(cwd) => CommandAction::from_core_with_cwd(parsed, cwd), + None => CommandAction::Unknown { + command: match parsed { + ParsedCommand::Read { cmd, .. } + | ParsedCommand::ListFiles { cmd, .. } + | ParsedCommand::Search { cmd, .. } + | ParsedCommand::Unknown { cmd } => cmd, + }, + }, + }) + .collect() } pub fn build_file_change_approval_request_item( @@ -98,16 +128,15 @@ pub fn build_command_execution_begin_item(payload: &ExecCommandBeginEvent) -> Th ThreadItem::CommandExecution { id: payload.call_id.clone(), command: shlex_join(&payload.command), - cwd: native_command_cwd(&payload.cwd), + cwd: native_command_cwd_from_uri(&payload.cwd, payload.path_convention), process_id: payload.process_id.clone(), source: payload.source.into(), status: CommandExecutionStatus::InProgress, - command_actions: payload - .parsed_cmd - .iter() - .cloned() - .map(|parsed| CommandAction::from_core_with_cwd(parsed, &payload.cwd)) - .collect(), + command_actions: command_actions_for_event( + &payload.parsed_cmd, + &payload.cwd, + payload.path_convention, + ), aggregated_output: None, exit_code: None, duration_ms: None, @@ -125,16 +154,15 @@ pub fn build_command_execution_end_item(payload: &ExecCommandEndEvent) -> Thread ThreadItem::CommandExecution { id: payload.call_id.clone(), command: shlex_join(&payload.command), - cwd: native_command_cwd(&payload.cwd), + cwd: native_command_cwd_from_uri(&payload.cwd, payload.path_convention), process_id: payload.process_id.clone(), source: payload.source.into(), status: (&payload.status).into(), - command_actions: payload - .parsed_cmd - .iter() - .cloned() - .map(|parsed| CommandAction::from_core_with_cwd(parsed, &payload.cwd)) - .collect(), + command_actions: command_actions_for_event( + &payload.parsed_cmd, + &payload.cwd, + payload.path_convention, + ), aggregated_output, exit_code: Some(payload.exit_code), duration_ms: Some(duration_ms), @@ -324,3 +352,7 @@ fn format_file_change_diff(change: &FileChange) -> String { } } } + +#[cfg(test)] +#[path = "item_builders_tests.rs"] +mod tests; diff --git a/codex-rs/app-server-protocol/src/protocol/item_builders_tests.rs b/codex-rs/app-server-protocol/src/protocol/item_builders_tests.rs new file mode 100644 index 0000000000..d06a91c898 --- /dev/null +++ b/codex-rs/app-server-protocol/src/protocol/item_builders_tests.rs @@ -0,0 +1,97 @@ +use super::*; +use codex_protocol::parse_command::ParsedCommand; +use codex_protocol::protocol::ExecCommandBeginEvent; +use codex_protocol::protocol::ExecCommandSource; +use pretty_assertions::assert_eq; +use std::path::PathBuf; + +fn begin_event( + cwd: PathUri, + path_convention: PathConvention, + parsed_cmd: Vec, +) -> ExecCommandBeginEvent { + ExecCommandBeginEvent { + call_id: "exec-1".to_string(), + process_id: None, + turn_id: "turn-1".to_string(), + started_at_ms: 0, + command: vec!["cat".to_string(), "notes.txt".to_string()], + cwd, + path_convention, + parsed_cmd, + source: ExecCommandSource::Agent, + interaction_input: None, + } +} + +#[test] +fn windows_command_event_renders_windows_native_cwd() { + let event = begin_event( + PathUri::parse("file:///C:/Research/space%20%23%25").expect("Windows cwd URI"), + PathConvention::Windows, + vec![ParsedCommand::Unknown { + cmd: "cat notes.txt".to_string(), + }], + ); + + assert_eq!( + build_command_execution_begin_item(&event), + ThreadItem::CommandExecution { + id: "exec-1".to_string(), + command: "cat notes.txt".to_string(), + cwd: ApiPathString::new(r"C:\Research\space #%"), + process_id: None, + source: CommandExecutionSource::Agent, + status: CommandExecutionStatus::InProgress, + command_actions: vec![CommandAction::Unknown { + command: "cat notes.txt".to_string(), + }], + aggregated_output: None, + exit_code: None, + duration_ms: None, + } + ); +} + +#[test] +fn foreign_command_event_does_not_project_read_path_onto_host() { + let (cwd, path_convention, native_cwd) = match PathConvention::native() { + PathConvention::Posix => ( + PathUri::parse("file:///C:/workspace").expect("Windows cwd URI"), + PathConvention::Windows, + ApiPathString::new(r"C:\workspace"), + ), + PathConvention::Windows => ( + PathUri::parse("file:///workspace").expect("POSIX cwd URI"), + PathConvention::Posix, + ApiPathString::new("/workspace"), + ), + }; + let event = begin_event( + cwd, + path_convention, + vec![ParsedCommand::Read { + cmd: "cat notes.txt".to_string(), + name: "notes.txt".to_string(), + path: PathBuf::from("notes.txt"), + }], + ); + + assert_eq!( + build_command_execution_begin_item(&event), + ThreadItem::CommandExecution { + id: "exec-1".to_string(), + command: "cat notes.txt".to_string(), + cwd: native_cwd, + process_id: None, + source: CommandExecutionSource::Agent, + status: CommandExecutionStatus::InProgress, + command_actions: vec![CommandAction::Unknown { + command: "cat notes.txt".to_string(), + }], + aggregated_output: None, + exit_code: None, + duration_ms: None, + } + ); +} diff --git a/codex-rs/app-server-protocol/src/protocol/thread_history.rs b/codex-rs/app-server-protocol/src/protocol/thread_history.rs index 38d6c532f0..1037df1ae0 100644 --- a/codex-rs/app-server-protocol/src/protocol/thread_history.rs +++ b/codex-rs/app-server-protocol/src/protocol/thread_history.rs @@ -1266,6 +1266,8 @@ mod tests { use codex_protocol::protocol::WebSearchEndEvent; use codex_utils_absolute_path::test_support::PathBufExt; use codex_utils_absolute_path::test_support::test_path_buf; + use codex_utils_path_uri::PathConvention; + use codex_utils_path_uri::PathUri; use pretty_assertions::assert_eq; use std::path::PathBuf; use std::time::Duration; @@ -2013,7 +2015,8 @@ mod tests { turn_id: "turn-1".into(), completed_at_ms: 0, command: vec!["echo".into(), "hello world".into()], - cwd: test_path_buf("/tmp").abs(), + cwd: PathUri::from_abs_path(&test_path_buf("/tmp").abs()), + path_convention: PathConvention::native(), parsed_cmd: vec![ParsedCommand::Unknown { cmd: "echo hello world".into(), }], @@ -2253,7 +2256,8 @@ mod tests { turn_id: "turn-1".into(), completed_at_ms: 0, command: vec!["ls".into()], - cwd: test_path_buf("/tmp").abs(), + cwd: PathUri::from_abs_path(&test_path_buf("/tmp").abs()), + path_convention: PathConvention::native(), parsed_cmd: vec![ParsedCommand::Unknown { cmd: "ls".into() }], source: ExecCommandSource::Agent, interaction_input: None, @@ -2519,7 +2523,8 @@ mod tests { turn_id: "turn-a".into(), completed_at_ms: 0, command: vec!["echo".into(), "done".into()], - cwd: test_path_buf("/tmp").abs(), + cwd: PathUri::from_abs_path(&test_path_buf("/tmp").abs()), + path_convention: PathConvention::native(), parsed_cmd: vec![ParsedCommand::Unknown { cmd: "echo done".into(), }], @@ -2617,7 +2622,8 @@ mod tests { turn_id: "turn-missing".into(), completed_at_ms: 0, command: vec!["echo".into(), "done".into()], - cwd: test_path_buf("/tmp").abs(), + cwd: PathUri::from_abs_path(&test_path_buf("/tmp").abs()), + path_convention: PathConvention::native(), parsed_cmd: vec![ParsedCommand::Unknown { cmd: "echo done".into(), }], diff --git a/codex-rs/core/src/tasks/user_shell.rs b/codex-rs/core/src/tasks/user_shell.rs index 6affc3d039..983fc11d3b 100644 --- a/codex-rs/core/src/tasks/user_shell.rs +++ b/codex-rs/core/src/tasks/user_shell.rs @@ -6,6 +6,8 @@ use codex_async_utils::CancelErr; use codex_async_utils::OrCancelExt; use codex_network_proxy::PROXY_ACTIVE_ENV_KEY; use codex_utils_absolute_path::AbsolutePathBuf; +use codex_utils_path_uri::PathConvention; +use codex_utils_path_uri::PathUri; use tokio_util::sync::CancellationToken; use tracing::error; use uuid::Uuid; @@ -150,6 +152,7 @@ pub(crate) async fn execute_user_shell_command( let raw_command = command; #[allow(deprecated)] let cwd = turn_context.cwd.clone(); + let cwd_uri = PathUri::from_abs_path(&cwd); let parsed_cmd = parse_command(&display_command); session @@ -161,7 +164,8 @@ pub(crate) async fn execute_user_shell_command( turn_id: turn_context.sub_id.clone(), started_at_ms: now_unix_timestamp_ms(), command: display_command.clone(), - cwd: cwd.clone(), + cwd: cwd_uri.clone(), + path_convention: PathConvention::native(), parsed_cmd: parsed_cmd.clone(), source: ExecCommandSource::UserShell, interaction_input: None, @@ -235,7 +239,8 @@ pub(crate) async fn execute_user_shell_command( turn_id: turn_context.sub_id.clone(), completed_at_ms: now_unix_timestamp_ms(), command: display_command.clone(), - cwd: cwd.clone(), + cwd: cwd_uri.clone(), + path_convention: PathConvention::native(), parsed_cmd: parsed_cmd.clone(), source: ExecCommandSource::UserShell, interaction_input: None, @@ -260,7 +265,8 @@ pub(crate) async fn execute_user_shell_command( turn_id: turn_context.sub_id.clone(), completed_at_ms: now_unix_timestamp_ms(), command: display_command.clone(), - cwd: cwd.clone(), + cwd: cwd_uri.clone(), + path_convention: PathConvention::native(), parsed_cmd: parsed_cmd.clone(), source: ExecCommandSource::UserShell, interaction_input: None, @@ -305,7 +311,8 @@ pub(crate) async fn execute_user_shell_command( turn_id: turn_context.sub_id.clone(), completed_at_ms: now_unix_timestamp_ms(), command: display_command, - cwd, + cwd: cwd_uri, + path_convention: PathConvention::native(), parsed_cmd, source: ExecCommandSource::UserShell, interaction_input: None, diff --git a/codex-rs/core/src/tools/events.rs b/codex-rs/core/src/tools/events.rs index 2751780d58..eb7b2a30a5 100644 --- a/codex-rs/core/src/tools/events.rs +++ b/codex-rs/core/src/tools/events.rs @@ -21,6 +21,8 @@ use codex_protocol::protocol::PatchApplyStatus; use codex_protocol::protocol::TurnDiffEvent; use codex_shell_command::parse_command::parse_command; use codex_utils_absolute_path::AbsolutePathBuf; +use codex_utils_path_uri::PathConvention; +use codex_utils_path_uri::PathUri; use std::collections::HashMap; use std::path::PathBuf; use std::time::Duration; @@ -92,28 +94,21 @@ fn tracker_update_for_known_delta<'a>( } } -pub(crate) async fn emit_exec_command_begin( - ctx: ToolEventCtx<'_>, - command: &[String], - cwd: &AbsolutePathBuf, - parsed_cmd: &[ParsedCommand], - source: ExecCommandSource, - interaction_input: Option, - process_id: Option<&str>, -) { +async fn emit_exec_command_begin(ctx: ToolEventCtx<'_>, exec_input: ExecCommandInput<'_>) { ctx.session .send_event( ctx.turn, EventMsg::ExecCommandBegin(ExecCommandBeginEvent { call_id: ctx.call_id.to_string(), - process_id: process_id.map(str::to_owned), + process_id: exec_input.process_id.map(str::to_owned), turn_id: ctx.turn.sub_id.clone(), started_at_ms: now_unix_timestamp_ms(), - command: command.to_vec(), - cwd: cwd.clone(), - parsed_cmd: parsed_cmd.to_vec(), - source, - interaction_input, + command: exec_input.command.to_vec(), + cwd: exec_input.cwd.clone(), + path_convention: exec_input.path_convention, + parsed_cmd: exec_input.parsed_cmd.to_vec(), + source: exec_input.source, + interaction_input: exec_input.interaction_input.map(str::to_owned), }), ) .await; @@ -122,7 +117,8 @@ pub(crate) async fn emit_exec_command_begin( pub(crate) enum ToolEmitter { Shell { command: Vec, - cwd: AbsolutePathBuf, + cwd: PathUri, + path_convention: PathConvention, source: ExecCommandSource, parsed_cmd: Vec, }, @@ -133,7 +129,8 @@ pub(crate) enum ToolEmitter { }, UnifiedExec { command: Vec, - cwd: AbsolutePathBuf, + cwd: PathUri, + path_convention: PathConvention, source: ExecCommandSource, parsed_cmd: Vec, process_id: Option, @@ -145,7 +142,8 @@ impl ToolEmitter { let parsed_cmd = parse_command(&command); Self::Shell { command, - cwd, + cwd: PathUri::from_abs_path(&cwd), + path_convention: PathConvention::native(), source, parsed_cmd, } @@ -165,7 +163,8 @@ impl ToolEmitter { pub fn unified_exec( command: &[String], - cwd: AbsolutePathBuf, + cwd: PathUri, + path_convention: PathConvention, source: ExecCommandSource, process_id: Option, ) -> Self { @@ -173,6 +172,7 @@ impl ToolEmitter { Self::UnifiedExec { command: command.to_vec(), cwd, + path_convention, source, parsed_cmd, process_id, @@ -185,6 +185,7 @@ impl ToolEmitter { Self::Shell { command, cwd, + path_convention, source, parsed_cmd, .. @@ -194,7 +195,12 @@ impl ToolEmitter { emit_exec_stage( ctx, ExecCommandInput::new( - command, cwd, parsed_cmd, *source, /*interaction_input*/ None, + command, + cwd, + *path_convention, + parsed_cmd, + *source, + /*interaction_input*/ None, /*process_id*/ None, ), stage, @@ -314,6 +320,7 @@ impl ToolEmitter { Self::UnifiedExec { command, cwd, + path_convention, source, parsed_cmd, process_id, @@ -325,6 +332,7 @@ impl ToolEmitter { ExecCommandInput::new( command, cwd, + *path_convention, parsed_cmd, *source, /*interaction_input*/ None, @@ -432,7 +440,8 @@ impl ToolEmitter { struct ExecCommandInput<'a> { command: &'a [String], - cwd: &'a AbsolutePathBuf, + cwd: &'a PathUri, + path_convention: PathConvention, parsed_cmd: &'a [ParsedCommand], source: ExecCommandSource, interaction_input: Option<&'a str>, @@ -442,7 +451,8 @@ struct ExecCommandInput<'a> { impl<'a> ExecCommandInput<'a> { fn new( command: &'a [String], - cwd: &'a AbsolutePathBuf, + cwd: &'a PathUri, + path_convention: PathConvention, parsed_cmd: &'a [ParsedCommand], source: ExecCommandSource, interaction_input: Option<&'a str>, @@ -451,6 +461,7 @@ impl<'a> ExecCommandInput<'a> { Self { command, cwd, + path_convention, parsed_cmd, source, interaction_input, @@ -476,16 +487,7 @@ async fn emit_exec_stage( ) { match stage { ToolEventStage::Begin => { - emit_exec_command_begin( - ctx, - exec_input.command, - exec_input.cwd, - exec_input.parsed_cmd, - exec_input.source, - exec_input.interaction_input.map(str::to_owned), - exec_input.process_id, - ) - .await; + emit_exec_command_begin(ctx, exec_input).await; } ToolEventStage::Success { output, .. } | ToolEventStage::Failure(ToolEventFailure::Output(output)) => { @@ -548,6 +550,7 @@ async fn emit_exec_end( completed_at_ms: now_unix_timestamp_ms(), command: exec_input.command.to_vec(), cwd: exec_input.cwd.clone(), + path_convention: exec_input.path_convention, parsed_cmd: exec_input.parsed_cmd.to_vec(), source: exec_input.source, interaction_input: exec_input.interaction_input.map(str::to_owned), diff --git a/codex-rs/core/src/tools/handlers/unified_exec/exec_command.rs b/codex-rs/core/src/tools/handlers/unified_exec/exec_command.rs index ef837d88ec..04d29193da 100644 --- a/codex-rs/core/src/tools/handlers/unified_exec/exec_command.rs +++ b/codex-rs/core/src/tools/handlers/unified_exec/exec_command.rs @@ -237,6 +237,7 @@ impl ExecCommandHandler { max_output_tokens, cwd, cwd_uri, + path_convention: turn_environment.path_convention(), sandbox_cwd, environment, shell_mode, @@ -350,6 +351,7 @@ impl ExecCommandHandler { max_output_tokens, cwd, cwd_uri, + path_convention: turn_environment.path_convention(), sandbox_cwd, environment, shell_mode, diff --git a/codex-rs/core/src/unified_exec/async_watcher.rs b/codex-rs/core/src/unified_exec/async_watcher.rs index 33dbf843dc..1537fb974a 100644 --- a/codex-rs/core/src/unified_exec/async_watcher.rs +++ b/codex-rs/core/src/unified_exec/async_watcher.rs @@ -22,7 +22,8 @@ use codex_protocol::protocol::EventMsg; use codex_protocol::protocol::ExecCommandOutputDeltaEvent; use codex_protocol::protocol::ExecCommandSource; use codex_protocol::protocol::ExecOutputStream; -use codex_utils_absolute_path::AbsolutePathBuf; +use codex_utils_path_uri::PathConvention; +use codex_utils_path_uri::PathUri; pub(crate) const TRAILING_OUTPUT_GRACE: Duration = Duration::from_millis(100); @@ -110,7 +111,8 @@ pub(crate) fn spawn_exit_watcher( turn_ref: Arc, call_id: String, command: Vec, - cwd: AbsolutePathBuf, + cwd: PathUri, + path_convention: PathConvention, process_id: i32, transcript: Arc>, started_at: Instant, @@ -130,6 +132,7 @@ pub(crate) fn spawn_exit_watcher( call_id, command, cwd, + path_convention, Some(process_id.to_string()), transcript, String::new(), @@ -145,6 +148,7 @@ pub(crate) fn spawn_exit_watcher( call_id, command, cwd, + path_convention, Some(process_id.to_string()), transcript, String::new(), @@ -197,7 +201,8 @@ pub(crate) async fn emit_exec_end_for_unified_exec( turn_ref: Arc, call_id: String, command: Vec, - cwd: AbsolutePathBuf, + cwd: PathUri, + path_convention: PathConvention, process_id: Option, transcript: Arc>, fallback_output: String, @@ -222,6 +227,7 @@ pub(crate) async fn emit_exec_end_for_unified_exec( let emitter = ToolEmitter::unified_exec( &command, cwd, + path_convention, ExecCommandSource::UnifiedExecStartup, process_id, ); @@ -242,7 +248,8 @@ pub(crate) async fn emit_failed_exec_end_for_unified_exec( turn_ref: Arc, call_id: String, command: Vec, - cwd: AbsolutePathBuf, + cwd: PathUri, + path_convention: PathConvention, process_id: Option, transcript: Arc>, fallback_output: String, @@ -276,6 +283,7 @@ pub(crate) async fn emit_failed_exec_end_for_unified_exec( let emitter = ToolEmitter::unified_exec( &command, cwd, + path_convention, ExecCommandSource::UnifiedExecStartup, process_id, ); diff --git a/codex-rs/core/src/unified_exec/mod.rs b/codex-rs/core/src/unified_exec/mod.rs index bde3438534..114c026b8d 100644 --- a/codex-rs/core/src/unified_exec/mod.rs +++ b/codex-rs/core/src/unified_exec/mod.rs @@ -33,6 +33,7 @@ use codex_protocol::models::AdditionalPermissionProfile; use codex_tools::UnifiedExecShellMode; use codex_utils_absolute_path::AbsolutePathBuf; use codex_utils_output_truncation::TruncationPolicy; +use codex_utils_path_uri::PathConvention; use codex_utils_path_uri::PathUri; use rand::Rng; use rand::rng; @@ -97,9 +98,10 @@ pub(crate) struct ExecCommandRequest { pub environment_id: String, pub yield_time_ms: u64, pub max_output_tokens: Option, - /// App-host-compatible cwd retained for approval and event compatibility. + /// App-host-compatible cwd retained for local sandbox and approval handling. pub cwd: AbsolutePathBuf, pub cwd_uri: PathUri, + pub path_convention: PathConvention, pub sandbox_cwd: Option, pub environment: Arc, pub shell_mode: UnifiedExecShellMode, diff --git a/codex-rs/core/src/unified_exec/process_manager.rs b/codex-rs/core/src/unified_exec/process_manager.rs index 7b15745bba..f175b0ac04 100644 --- a/codex-rs/core/src/unified_exec/process_manager.rs +++ b/codex-rs/core/src/unified_exec/process_manager.rs @@ -58,6 +58,8 @@ use codex_protocol::protocol::ExecCommandSource; use codex_tools::ToolName; use codex_utils_absolute_path::AbsolutePathBuf; use codex_utils_output_truncation::approx_token_count; +use codex_utils_path_uri::PathConvention; +use codex_utils_path_uri::PathUri; const UNIFIED_EXEC_ENV: [(&str, &str); 10] = [ ("NO_COLOR", "1"), @@ -337,7 +339,6 @@ async fn emit_failed_initial_exec_end_if_unstored( process_started_alive: bool, context: &UnifiedExecContext, request: &ExecCommandRequest, - cwd: AbsolutePathBuf, transcript: Arc>, fallback_output: String, message: String, @@ -352,7 +353,8 @@ async fn emit_failed_initial_exec_end_if_unstored( Arc::clone(&context.turn), context.call_id.clone(), request.command.clone(), - cwd, + request.cwd_uri.clone(), + request.path_convention, Some(request.process_id.to_string()), transcript, fallback_output, @@ -459,7 +461,8 @@ impl UnifiedExecProcessManager { ); let emitter = ToolEmitter::unified_exec( &request.command, - cwd.clone(), + request.cwd_uri.clone(), + request.path_convention, ExecCommandSource::UnifiedExecStartup, Some(request.process_id.to_string()), ); @@ -478,6 +481,8 @@ impl UnifiedExecProcessManager { &request.command, request.hook_command.clone(), cwd.clone(), + request.cwd_uri.clone(), + request.path_convention, start, request.process_id, request.tty, @@ -536,7 +541,6 @@ impl UnifiedExecProcessManager { process_started_alive, context, &request, - cwd.clone(), Arc::clone(&transcript), text.clone(), message.clone(), @@ -556,7 +560,6 @@ impl UnifiedExecProcessManager { process_started_alive, context, &request, - cwd.clone(), Arc::clone(&transcript), text.clone(), message.clone(), @@ -608,7 +611,6 @@ impl UnifiedExecProcessManager { process_started_alive, context, &request, - cwd.clone(), Arc::clone(&transcript), text.clone(), message.clone(), @@ -625,7 +627,8 @@ impl UnifiedExecProcessManager { Arc::clone(&context.turn), context.call_id.clone(), request.command.clone(), - cwd.clone(), + request.cwd_uri.clone(), + request.path_convention, Some(process_id.to_string()), Arc::clone(&transcript), text.clone(), @@ -885,6 +888,8 @@ impl UnifiedExecProcessManager { command: &[String], hook_command: String, cwd: AbsolutePathBuf, + cwd_uri: PathUri, + path_convention: PathConvention, started_at: Instant, process_id: i32, tty: bool, @@ -923,7 +928,8 @@ impl UnifiedExecProcessManager { Arc::clone(&context.turn), context.call_id.clone(), command.to_vec(), - cwd, + cwd_uri, + path_convention, process_id, transcript, started_at, diff --git a/codex-rs/core/src/unified_exec/process_manager_tests.rs b/codex-rs/core/src/unified_exec/process_manager_tests.rs index 196f4c80ed..2630467827 100644 --- a/codex-rs/core/src/unified_exec/process_manager_tests.rs +++ b/codex-rs/core/src/unified_exec/process_manager_tests.rs @@ -296,6 +296,7 @@ async fn failed_initial_end_for_unstored_process_uses_fallback_output() { #[allow(deprecated)] cwd: turn.cwd.clone(), cwd_uri: turn_environment.cwd_uri().clone(), + path_convention: turn_environment.path_convention(), #[allow(deprecated)] sandbox_cwd: Some(turn.cwd.clone()), environment: turn @@ -322,8 +323,6 @@ async fn failed_initial_end_for_unstored_process_uses_fallback_output() { /*process_started_alive*/ false, &context, &request, - #[allow(deprecated)] - turn.cwd.clone(), transcript, "PRE_DENIAL_MARKER".to_string(), "Network access denied".to_string(), @@ -345,6 +344,8 @@ async fn failed_initial_end_for_unstored_process_uses_fallback_output() { ); assert_eq!(end_event.exit_code, -1); assert_eq!(end_event.process_id.as_deref(), Some("123")); + assert_eq!(end_event.cwd, request.cwd_uri); + assert_eq!(end_event.path_convention, request.path_convention); assert_eq!( end_event.aggregated_output, "PRE_DENIAL_MARKER\nNetwork access denied" diff --git a/codex-rs/core/tests/remote_env_windows/remote_env_windows_test.rs b/codex-rs/core/tests/remote_env_windows/remote_env_windows_test.rs index dd84902a3c..71f182b401 100644 --- a/codex-rs/core/tests/remote_env_windows/remote_env_windows_test.rs +++ b/codex-rs/core/tests/remote_env_windows/remote_env_windows_test.rs @@ -160,10 +160,7 @@ async fn windows_exec_server_records_host_shell_mismatch() -> Result<()> { ); assert_eq!( (begin.cwd.clone(), begin.source), - ( - test.config.cwd.clone(), - ExecCommandSource::UnifiedExecStartup, - ), + (selected_cwd.clone(), ExecCommandSource::UnifiedExecStartup), ); let end = end.context("exec_command should emit an end event")?; @@ -180,7 +177,7 @@ async fn windows_exec_server_records_host_shell_mismatch() -> Result<()> { ), ( begin.command, - test.config.cwd.clone(), + selected_cwd, ExecCommandSource::UnifiedExecStartup, String::new(), String::new(), diff --git a/codex-rs/core/tests/suite/unified_exec.rs b/codex-rs/core/tests/suite/unified_exec.rs index 9291371010..946ef666f2 100644 --- a/codex-rs/core/tests/suite/unified_exec.rs +++ b/codex-rs/core/tests/suite/unified_exec.rs @@ -17,6 +17,7 @@ use codex_protocol::protocol::ExecCommandSource; use codex_protocol::protocol::ExecCommandStatus; use codex_protocol::protocol::Op; use codex_protocol::user_input::UserInput; +use codex_utils_path_uri::PathConvention; use codex_utils_path_uri::PathUri; use core_test_support::TempDirExt; use core_test_support::assert_regex_match; @@ -430,7 +431,10 @@ async fn unified_exec_emits_exec_command_begin_event() -> Result<()> { assert_command(&begin_event.command, "-lc", "/bin/echo hello unified exec"); - assert_eq!(begin_event.cwd.as_path(), cwd.as_path()); + assert_eq!( + (begin_event.cwd, begin_event.path_convention), + (PathUri::from_path(&cwd)?, PathConvention::native()), + ); wait_for_event(&test.codex, |event| { matches!(event, EventMsg::TurnComplete(_)) @@ -495,8 +499,8 @@ async fn unified_exec_resolves_relative_workdir() -> Result<()> { .await; assert_eq!( - begin_event.cwd.as_path(), - workdir.as_path(), + (begin_event.cwd, begin_event.path_convention), + (PathUri::from_path(&workdir)?, PathConvention::native(),), "exec_command cwd should resolve relative workdir against turn cwd", ); @@ -558,8 +562,8 @@ async fn unified_exec_respects_workdir_override() -> Result<()> { .await; assert_eq!( - begin_event.cwd.as_path(), - workdir.as_path(), + (begin_event.cwd, begin_event.path_convention), + (PathUri::from_path(&workdir)?, PathConvention::native(),), "exec_command cwd should reflect the requested workdir override" ); diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index 577e700b0f..f040b5a1a1 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -3206,8 +3206,11 @@ pub struct ExecCommandBeginEvent { pub started_at_ms: i64, /// The command to be executed. pub command: Vec, - /// The command's working directory if not the default cwd for the agent. - pub cwd: AbsolutePathBuf, + /// The command's working directory in the executing environment. + pub cwd: PathUri, + /// Path syntax used by the executing environment. + #[serde(default = "PathConvention::native")] + pub path_convention: PathConvention, pub parsed_cmd: Vec, /// Where the command originated. Defaults to Agent for backward compatibility. #[serde(default)] @@ -3232,8 +3235,11 @@ pub struct ExecCommandEndEvent { pub completed_at_ms: i64, /// The command that was executed. pub command: Vec, - /// The command's working directory if not the default cwd for the agent. - pub cwd: AbsolutePathBuf, + /// The command's working directory in the executing environment. + pub cwd: PathUri, + /// Path syntax used by the executing environment. + #[serde(default = "PathConvention::native")] + pub path_convention: PathConvention, pub parsed_cmd: Vec, /// Where the command originated. Defaults to Agent for backward compatibility. #[serde(default)] @@ -5385,6 +5391,25 @@ mod tests { Ok(()) } + #[test] + fn deserialize_legacy_exec_command_event_uses_native_path_convention() -> Result<()> { + let cwd = AbsolutePathBuf::current_dir()?; + let value = json!({ + "call_id": "call-1", + "turn_id": "turn-1", + "command": ["echo", "hello"], + "cwd": cwd, + "parsed_cmd": [], + }); + + let event: ExecCommandBeginEvent = serde_json::from_value(value)?; + assert_eq!( + (event.cwd, event.path_convention), + (PathUri::from_abs_path(&cwd), PathConvention::native()), + ); + Ok(()) + } + #[test] fn serialize_mcp_startup_update_event() -> Result<()> { let event = Event {