From 64540685affcb788e85edf855ac249a628ccde88 Mon Sep 17 00:00:00 2001 From: Adam Perry Date: Sun, 14 Jun 2026 06:11:37 +0000 Subject: [PATCH] core: preserve command cwd path uri --- .../src/protocol/item_builders.rs | 30 ++++++++--- .../src/protocol/thread_history.rs | 9 ++-- .../app-server/src/bespoke_event_handling.rs | 12 +++++ codex-rs/core/src/codex_delegate.rs | 31 +++++++++++- codex-rs/core/src/codex_delegate_tests.rs | 2 +- codex-rs/core/src/session/mod.rs | 2 +- codex-rs/core/src/tasks/user_shell.rs | 13 +++-- codex-rs/core/src/tools/events.rs | 15 +++--- .../handlers/unified_exec/exec_command.rs | 9 ++-- codex-rs/core/src/tools/network_approval.rs | 2 +- codex-rs/core/src/tools/runtimes/shell.rs | 2 +- .../tools/runtimes/shell/unix_escalation.rs | 2 +- .../core/src/tools/runtimes/unified_exec.rs | 50 ++++++++++--------- .../core/src/unified_exec/async_watcher.rs | 8 +-- codex-rs/core/src/unified_exec/mod.rs | 6 ++- codex-rs/core/src/unified_exec/mod_tests.rs | 8 +++ .../core/src/unified_exec/process_manager.rs | 36 +++++++------ .../src/unified_exec/process_manager_tests.rs | 15 ++++-- codex-rs/core/tests/suite/unified_exec.rs | 10 ++-- codex-rs/mcp-server/src/codex_tool_runner.rs | 14 +++++- codex-rs/protocol/src/approvals.rs | 3 +- codex-rs/protocol/src/protocol.rs | 4 +- 22 files changed, 188 insertions(+), 95 deletions(-) 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 17e0f9aef4..c4be57b207 100644 --- a/codex-rs/app-server-protocol/src/protocol/item_builders.rs +++ b/codex-rs/app-server-protocol/src/protocol/item_builders.rs @@ -34,9 +34,24 @@ use codex_protocol::protocol::PatchApplyBeginEvent; use codex_protocol::protocol::PatchApplyEndEvent; use codex_shell_command::parse_command::parse_command; use codex_shell_command::parse_command::shlex_join; +use codex_utils_absolute_path::AbsolutePathBuf; +use codex_utils_path_uri::ApiPathString; +use codex_utils_path_uri::PathConvention; +use codex_utils_path_uri::PathUri; use std::collections::HashMap; use std::path::PathBuf; +#[expect( + clippy::expect_used, + reason = "legacy app-server command items still require a host-native cwd" +)] +fn project_command_cwd_to_host(cwd: &PathUri) -> AbsolutePathBuf { + let cwd = ApiPathString::from_path_uri(cwd, PathConvention::native()) + .expect("command cwd should render using the app-server host path convention"); + AbsolutePathBuf::try_from(cwd.into_string()) + .expect("projected command cwd should be absolute on the app-server host") +} + pub fn build_file_change_approval_request_item( payload: &ApplyPatchApprovalRequestEvent, ) -> ThreadItem { @@ -66,10 +81,11 @@ pub fn build_file_change_end_item(payload: &PatchApplyEndEvent) -> ThreadItem { pub fn build_command_execution_approval_request_item( payload: &ExecApprovalRequestEvent, ) -> ThreadItem { + let cwd = project_command_cwd_to_host(&payload.cwd); ThreadItem::CommandExecution { id: payload.call_id.clone(), command: shlex_join(&payload.command), - cwd: payload.cwd.clone(), + cwd: cwd.clone(), process_id: None, source: CommandExecutionSource::Agent, status: CommandExecutionStatus::InProgress, @@ -77,7 +93,7 @@ pub fn build_command_execution_approval_request_item( .parsed_cmd .iter() .cloned() - .map(|parsed| CommandAction::from_core_with_cwd(parsed, &payload.cwd)) + .map(|parsed| CommandAction::from_core_with_cwd(parsed, &cwd)) .collect(), aggregated_output: None, exit_code: None, @@ -86,10 +102,11 @@ pub fn build_command_execution_approval_request_item( } pub fn build_command_execution_begin_item(payload: &ExecCommandBeginEvent) -> ThreadItem { + let cwd = project_command_cwd_to_host(&payload.cwd); ThreadItem::CommandExecution { id: payload.call_id.clone(), command: shlex_join(&payload.command), - cwd: payload.cwd.clone(), + cwd: cwd.clone(), process_id: payload.process_id.clone(), source: payload.source.into(), status: CommandExecutionStatus::InProgress, @@ -97,7 +114,7 @@ pub fn build_command_execution_begin_item(payload: &ExecCommandBeginEvent) -> Th .parsed_cmd .iter() .cloned() - .map(|parsed| CommandAction::from_core_with_cwd(parsed, &payload.cwd)) + .map(|parsed| CommandAction::from_core_with_cwd(parsed, &cwd)) .collect(), aggregated_output: None, exit_code: None, @@ -112,11 +129,12 @@ pub fn build_command_execution_end_item(payload: &ExecCommandEndEvent) -> Thread Some(payload.aggregated_output.clone()) }; let duration_ms = i64::try_from(payload.duration.as_millis()).unwrap_or(i64::MAX); + let cwd = project_command_cwd_to_host(&payload.cwd); ThreadItem::CommandExecution { id: payload.call_id.clone(), command: shlex_join(&payload.command), - cwd: payload.cwd.clone(), + cwd: cwd.clone(), process_id: payload.process_id.clone(), source: payload.source.into(), status: (&payload.status).into(), @@ -124,7 +142,7 @@ pub fn build_command_execution_end_item(payload: &ExecCommandEndEvent) -> Thread .parsed_cmd .iter() .cloned() - .map(|parsed| CommandAction::from_core_with_cwd(parsed, &payload.cwd)) + .map(|parsed| CommandAction::from_core_with_cwd(parsed, &cwd)) .collect(), aggregated_output, exit_code: Some(payload.exit_code), 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 6ca038981f..2882dfbff4 100644 --- a/codex-rs/app-server-protocol/src/protocol/thread_history.rs +++ b/codex-rs/app-server-protocol/src/protocol/thread_history.rs @@ -1278,6 +1278,7 @@ 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::PathUri; use pretty_assertions::assert_eq; use std::path::PathBuf; use std::time::Duration; @@ -2072,7 +2073,7 @@ 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()), parsed_cmd: vec![ParsedCommand::Unknown { cmd: "echo hello world".into(), }], @@ -2312,7 +2313,7 @@ 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()), parsed_cmd: vec![ParsedCommand::Unknown { cmd: "ls".into() }], source: ExecCommandSource::Agent, interaction_input: None, @@ -2578,7 +2579,7 @@ 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()), parsed_cmd: vec![ParsedCommand::Unknown { cmd: "echo done".into(), }], @@ -2676,7 +2677,7 @@ 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()), parsed_cmd: vec![ParsedCommand::Unknown { cmd: "echo done".into(), }], diff --git a/codex-rs/app-server/src/bespoke_event_handling.rs b/codex-rs/app-server/src/bespoke_event_handling.rs index 868cef81fc..b4eb2b10c3 100644 --- a/codex-rs/app-server/src/bespoke_event_handling.rs +++ b/codex-rs/app-server/src/bespoke_event_handling.rs @@ -113,6 +113,8 @@ use codex_protocol::request_user_input::RequestUserInputResponse as CoreRequestU use codex_sandboxing::policy_transforms::intersect_permission_profiles; use codex_shell_command::parse_command::shlex_join; use codex_utils_absolute_path::AbsolutePathBuf; +use codex_utils_path_uri::ApiPathString; +use codex_utils_path_uri::PathConvention; use std::collections::HashMap; use std::sync::Arc; use std::time::SystemTime; @@ -561,6 +563,16 @@ pub(crate) async fn apply_bespoke_event_handling( parsed_cmd, .. } = ev; + #[expect( + clippy::expect_used, + reason = "legacy command approval payloads still require a host-native cwd" + )] + let cwd = { + let cwd = ApiPathString::from_path_uri(&cwd, PathConvention::native()) + .expect("command cwd should render using the app-server host path convention"); + AbsolutePathBuf::try_from(cwd.into_string()) + .expect("projected command cwd should be absolute on the app-server host") + }; let command_actions = parsed_cmd .iter() .cloned() diff --git a/codex-rs/core/src/codex_delegate.rs b/codex-rs/core/src/codex_delegate.rs index d31dc138b5..93ce1ef234 100644 --- a/codex-rs/core/src/codex_delegate.rs +++ b/codex-rs/core/src/codex_delegate.rs @@ -25,6 +25,9 @@ use codex_protocol::request_permissions::RequestPermissionsResponse; use codex_protocol::request_user_input::RequestUserInputArgs; use codex_protocol::request_user_input::RequestUserInputResponse; use codex_protocol::user_input::UserInput; +use codex_utils_absolute_path::AbsolutePathBuf; +use codex_utils_path_uri::ApiPathString; +use codex_utils_path_uri::PathConvention; use serde_json::Value; use std::time::Duration; use tokio::sync::Mutex; @@ -469,7 +472,33 @@ async fn handle_exec_approval( available_decisions, .. } = event; - let decision = if routes_approval_to_guardian(parent_ctx) { + let routes_to_guardian = routes_approval_to_guardian(parent_ctx); + let guardian_cwd = if routes_to_guardian { + match ApiPathString::from_path_uri(&cwd, PathConvention::native()) { + Ok(rendered_cwd) => match AbsolutePathBuf::try_from(rendered_cwd.into_string()) { + Ok(cwd) => Some(cwd), + Err(err) => { + tracing::warn!( + cwd = %cwd, + %err, + "delegated command approval cwd could not be projected onto the Codex host for guardian review" + ); + None + } + }, + Err(err) => { + tracing::warn!( + cwd = %cwd, + %err, + "delegated command approval cwd could not be projected onto the Codex host for guardian review" + ); + None + } + } + } else { + None + }; + let decision = if let Some(cwd) = guardian_cwd { let review_cancel = cancel_token.child_token(); let review_rx = spawn_approval_request_review( Arc::clone(parent_session), diff --git a/codex-rs/core/src/codex_delegate_tests.rs b/codex-rs/core/src/codex_delegate_tests.rs index f97e81c6e8..3ef80c4990 100644 --- a/codex-rs/core/src/codex_delegate_tests.rs +++ b/codex-rs/core/src/codex_delegate_tests.rs @@ -324,7 +324,7 @@ async fn handle_exec_approval_uses_call_id_for_guardian_review_and_approval_id_f turn_id: "child-turn-1".to_string(), started_at_ms: 0, command: vec!["rm".to_string(), "-rf".to_string(), "tmp".to_string()], - cwd: test_path_buf("/tmp").abs(), + cwd: codex_utils_path_uri::PathUri::from_abs_path(&test_path_buf("/tmp").abs()), reason: Some("unsafe subcommand".to_string()), network_approval_context: None, proposed_execpolicy_amendment: None, diff --git a/codex-rs/core/src/session/mod.rs b/codex-rs/core/src/session/mod.rs index 242e35f375..17d62a3eeb 100644 --- a/codex-rs/core/src/session/mod.rs +++ b/codex-rs/core/src/session/mod.rs @@ -2036,7 +2036,7 @@ impl Session { call_id: String, approval_id: Option, command: Vec, - cwd: AbsolutePathBuf, + cwd: PathUri, reason: Option, network_approval_context: Option, proposed_execpolicy_amendment: Option, diff --git a/codex-rs/core/src/tasks/user_shell.rs b/codex-rs/core/src/tasks/user_shell.rs index c3abf03e74..4ee0e38105 100644 --- a/codex-rs/core/src/tasks/user_shell.rs +++ b/codex-rs/core/src/tasks/user_shell.rs @@ -162,9 +162,8 @@ pub(crate) async fn execute_user_shell_command( let call_id = Uuid::new_v4().to_string(); let raw_command = command; - // TODO(anp): Migrate user-shell events and execution plumbing to PathUri so this local-only - // feature does not need to project the selected environment cwd onto the Codex host. - let Ok(cwd) = turn_environment.cwd().to_abs_path() else { + let cwd_uri = turn_environment.cwd().clone(); + let Ok(cwd) = cwd_uri.to_abs_path() else { send_user_shell_error( &session, turn_context.as_ref(), @@ -184,7 +183,7 @@ 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(), parsed_cmd: parsed_cmd.clone(), source: ExecCommandSource::UserShell, interaction_input: None, @@ -258,7 +257,7 @@ 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(), parsed_cmd: parsed_cmd.clone(), source: ExecCommandSource::UserShell, interaction_input: None, @@ -283,7 +282,7 @@ 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(), parsed_cmd: parsed_cmd.clone(), source: ExecCommandSource::UserShell, interaction_input: None, @@ -328,7 +327,7 @@ 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, 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..c65cfab16a 100644 --- a/codex-rs/core/src/tools/events.rs +++ b/codex-rs/core/src/tools/events.rs @@ -21,6 +21,7 @@ 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::PathUri; use std::collections::HashMap; use std::path::PathBuf; use std::time::Duration; @@ -95,7 +96,7 @@ fn tracker_update_for_known_delta<'a>( pub(crate) async fn emit_exec_command_begin( ctx: ToolEventCtx<'_>, command: &[String], - cwd: &AbsolutePathBuf, + cwd: &PathUri, parsed_cmd: &[ParsedCommand], source: ExecCommandSource, interaction_input: Option, @@ -122,7 +123,7 @@ pub(crate) async fn emit_exec_command_begin( pub(crate) enum ToolEmitter { Shell { command: Vec, - cwd: AbsolutePathBuf, + cwd: PathUri, source: ExecCommandSource, parsed_cmd: Vec, }, @@ -133,7 +134,7 @@ pub(crate) enum ToolEmitter { }, UnifiedExec { command: Vec, - cwd: AbsolutePathBuf, + cwd: PathUri, source: ExecCommandSource, parsed_cmd: Vec, process_id: Option, @@ -145,7 +146,7 @@ impl ToolEmitter { let parsed_cmd = parse_command(&command); Self::Shell { command, - cwd, + cwd: PathUri::from_abs_path(&cwd), source, parsed_cmd, } @@ -165,7 +166,7 @@ impl ToolEmitter { pub fn unified_exec( command: &[String], - cwd: AbsolutePathBuf, + cwd: PathUri, source: ExecCommandSource, process_id: Option, ) -> Self { @@ -432,7 +433,7 @@ impl ToolEmitter { struct ExecCommandInput<'a> { command: &'a [String], - cwd: &'a AbsolutePathBuf, + cwd: &'a PathUri, parsed_cmd: &'a [ParsedCommand], source: ExecCommandSource, interaction_input: Option<&'a str>, @@ -442,7 +443,7 @@ struct ExecCommandInput<'a> { impl<'a> ExecCommandInput<'a> { fn new( command: &'a [String], - cwd: &'a AbsolutePathBuf, + cwd: &'a PathUri, parsed_cmd: &'a [ParsedCommand], source: ExecCommandSource, interaction_input: Option<&'a str>, 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 1d7d64e2f5..7abe1b2bbb 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 @@ -128,7 +128,7 @@ impl ExecCommandHandler { "unified exec is unavailable in this session".to_string(), )); }; - let cwd = environment_args + let cwd_uri = environment_args .workdir .as_ref() .filter(|workdir| !workdir.is_empty()) @@ -136,9 +136,9 @@ impl ExecCommandHandler { .unwrap_or_else(|| turn_environment.cwd().clone()); // TODO(anp): Migrate command approvals, granted permissions, and tool hooks to PathUri so // foreign command working directories do not require a host-native projection here. - let native_cwd = cwd.to_abs_path().map_err(|err| { + let native_cwd = cwd_uri.to_abs_path().map_err(|err| { FunctionCallError::RespondToModel(format!( - "command workdir `{cwd}` is not native to the Codex host: {err}" + "command workdir `{cwd_uri}` is not native to the Codex host: {err}" )) })?; let environment = Arc::clone(&turn_environment.environment); @@ -280,7 +280,8 @@ impl ExecCommandHandler { process_id, yield_time_ms, max_output_tokens, - cwd, + cwd_uri, + cwd: native_cwd, // TODO(anp): Migrate the sandbox orchestration traits to PathUri so the // selected environment root can remain foreign until sandbox enforcement. sandbox_cwd: turn_environment.cwd().to_abs_path().map_err(|err| { diff --git a/codex-rs/core/src/tools/network_approval.rs b/codex-rs/core/src/tools/network_approval.rs index 9cc808a5b6..cb22942400 100644 --- a/codex-rs/core/src/tools/network_approval.rs +++ b/codex-rs/core/src/tools/network_approval.rs @@ -526,7 +526,7 @@ impl NetworkApprovalService { /*approval_id*/ None, prompt_command, #[allow(deprecated)] - turn_context.cwd.clone(), + codex_utils_path_uri::PathUri::from_abs_path(&turn_context.cwd), Some(prompt_reason), Some(network_approval_context.clone()), /*proposed_execpolicy_amendment*/ None, diff --git a/codex-rs/core/src/tools/runtimes/shell.rs b/codex-rs/core/src/tools/runtimes/shell.rs index 3ccd9e4c72..c51f79f0ec 100644 --- a/codex-rs/core/src/tools/runtimes/shell.rs +++ b/codex-rs/core/src/tools/runtimes/shell.rs @@ -176,7 +176,7 @@ impl Approvable for ShellRuntime { call_id, /*approval_id*/ None, command, - cwd, + codex_utils_path_uri::PathUri::from_abs_path(&cwd), reason, ctx.network_approval_context.clone(), req.exec_approval_requirement diff --git a/codex-rs/core/src/tools/runtimes/shell/unix_escalation.rs b/codex-rs/core/src/tools/runtimes/shell/unix_escalation.rs index 3b843c7fae..6a24a69fa1 100644 --- a/codex-rs/core/src/tools/runtimes/shell/unix_escalation.rs +++ b/codex-rs/core/src/tools/runtimes/shell/unix_escalation.rs @@ -505,7 +505,7 @@ impl CoreShellActionProvider { call_id, approval_id, command, - workdir.clone(), + codex_utils_path_uri::PathUri::from_abs_path(&workdir), /*reason*/ None, /*network_approval_context*/ None, /*proposed_execpolicy_amendment*/ None, diff --git a/codex-rs/core/src/tools/runtimes/unified_exec.rs b/codex-rs/core/src/tools/runtimes/unified_exec.rs index 2964f43ea7..58ffed07f1 100644 --- a/codex-rs/core/src/tools/runtimes/unified_exec.rs +++ b/codex-rs/core/src/tools/runtimes/unified_exec.rs @@ -64,7 +64,8 @@ pub struct UnifiedExecRequest { pub shell_type: ShellType, pub hook_command: String, pub process_id: i32, - pub cwd: PathUri, + pub cwd_uri: PathUri, + pub cwd: AbsolutePathBuf, pub sandbox_cwd: AbsolutePathBuf, pub turn_environment: TurnEnvironment, pub env: HashMap, @@ -137,7 +138,7 @@ impl Approvable for UnifiedExecRuntime<'_> { fn approval_keys(&self, req: &UnifiedExecRequest) -> Vec { vec![UnifiedExecApprovalKey { command: canonicalize_command_for_approval(&req.command), - cwd: req.cwd.clone(), + cwd: req.cwd_uri.clone(), tty: req.tty, sandbox_permissions: req.sandbox_permissions, additional_permissions: req.additional_permissions.clone(), @@ -154,18 +155,12 @@ impl Approvable for UnifiedExecRuntime<'_> { let turn = ctx.turn; let call_id = ctx.call_id.to_string(); let command = req.command.clone(); + let cwd = req.cwd.clone(); + let cwd_uri = req.cwd_uri.clone(); let retry_reason = ctx.retry_reason.clone(); let reason = retry_reason.clone().or_else(|| req.justification.clone()); let guardian_review_id = ctx.guardian_review_id.clone(); Box::pin(async move { - // TODO(anp): Migrate command approval payloads to PathUri so approvals can represent - // working directories native to a remote environment. - let cwd = req.cwd.to_abs_path().map_err(|err| { - ToolError::Rejected(format!( - "command cwd `{}` is not native to the Codex host: {err}", - req.cwd - )) - })?; if let Some(review_id) = guardian_review_id { return Ok(review_approval_request( session, @@ -193,7 +188,7 @@ impl Approvable for UnifiedExecRuntime<'_> { call_id, /*approval_id*/ None, command, - cwd.clone(), + cwd_uri, reason, ctx.network_approval_context.clone(), req.exec_approval_requirement @@ -248,9 +243,6 @@ impl<'a> ToolRuntime for UnifiedExecRunt ); let network = managed_network_for_sandbox_permissions(req.network.as_ref(), sandbox_permissions)?; - // TODO(anp): Migrate guardian network approval triggers to PathUri so remote working - // directories do not suppress managed-network approval context. - let cwd = req.cwd.to_abs_path().ok()?; Some(NetworkApprovalSpec { network: Some(network.clone()), mode: NetworkApprovalMode::Deferred, @@ -258,7 +250,7 @@ impl<'a> ToolRuntime for UnifiedExecRunt call_id: ctx.call_id.clone(), tool_name: flat_tool_name(&ctx.tool_name).into_owned(), command: req.command.clone(), - cwd, + cwd: req.cwd.clone(), sandbox_permissions: req.sandbox_permissions, additional_permissions: req.additional_permissions.clone(), justification: req.justification.clone(), @@ -281,7 +273,7 @@ impl<'a> ToolRuntime for UnifiedExecRunt .shell .as_ref() .unwrap_or(session_shell.as_ref()); - let shell_snapshot_location = req.turn_environment.shell_snapshot(&req.cwd); + let shell_snapshot_location = req.turn_environment.shell_snapshot(&req.cwd_uri); let (file_system_sandbox_policy, _) = attempt.permissions.to_runtime_permissions(); let launch_sandbox_permissions = sandbox_permissions_preserving_denied_reads( req.sandbox_permissions, @@ -342,8 +334,12 @@ impl<'a> ToolRuntime for UnifiedExecRunt }; if let UnifiedExecShellMode::ZshFork(zsh_fork_config) = &self.shell_mode { - let command = - build_sandbox_command(&command, &req.cwd, &env, req.additional_permissions.clone()) + let command = build_sandbox_command( + &command, + &req.cwd_uri, + &env, + req.additional_permissions.clone(), + ) .map_err(|error| match error { ToolError::Rejected(_) => { ToolError::Rejected("missing command line for PTY".to_string()) @@ -376,6 +372,7 @@ impl<'a> ToolRuntime for UnifiedExecRunt .open_session_with_exec_env( req.process_id, &prepared.exec_request, + &prepared.exec_request.cwd, req.tty, prepared.spawn_lifecycle, req.turn_environment.environment.as_ref(), @@ -398,8 +395,12 @@ impl<'a> ToolRuntime for UnifiedExecRunt } } } - let command = - build_sandbox_command(&command, &req.cwd, &env, req.additional_permissions.clone()) + let command = build_sandbox_command( + &command, + &req.cwd_uri, + &env, + req.additional_permissions.clone(), + ) .map_err(|error| match error { ToolError::Rejected(_) => { ToolError::Rejected("missing command line for PTY".to_string()) @@ -412,12 +413,13 @@ impl<'a> ToolRuntime for UnifiedExecRunt .map_err(ToolError::Codex)?; exec_env.exec_server_env_config = req.exec_server_env_config.clone(); if req.turn_environment.environment.is_remote() { - exec_env.cwd = req.cwd.clone(); + exec_env.cwd = req.cwd_uri.clone(); } self.manager .open_session_with_exec_env( req.process_id, &exec_env, + &req.cwd_uri, req.tty, Box::new(NoopSpawnLifecycle), req.turn_environment.environment.as_ref(), @@ -493,7 +495,8 @@ mod tests { shell_type: ShellType::Sh, hook_command: "pwd".to_string(), process_id: 1000, - cwd: PathUri::from_abs_path(&cwd), + cwd_uri: PathUri::from_abs_path(&cwd), + cwd, sandbox_cwd: sandbox_cwd.clone(), turn_environment: test_turn_environment(sandbox_cwd.clone()), env: HashMap::new(), @@ -592,7 +595,8 @@ mod tests { shell_type: ShellType::Zsh, hook_command: "echo hi".to_string(), process_id: 1000, - cwd: PathUri::from_abs_path(&cwd), + cwd_uri: PathUri::from_abs_path(&cwd), + cwd: cwd.clone(), sandbox_cwd: cwd.clone(), turn_environment: test_turn_environment(cwd), env: HashMap::new(), diff --git a/codex-rs/core/src/unified_exec/async_watcher.rs b/codex-rs/core/src/unified_exec/async_watcher.rs index 33dbf843dc..38010754e9 100644 --- a/codex-rs/core/src/unified_exec/async_watcher.rs +++ b/codex-rs/core/src/unified_exec/async_watcher.rs @@ -22,7 +22,7 @@ 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::PathUri; pub(crate) const TRAILING_OUTPUT_GRACE: Duration = Duration::from_millis(100); @@ -110,7 +110,7 @@ pub(crate) fn spawn_exit_watcher( turn_ref: Arc, call_id: String, command: Vec, - cwd: AbsolutePathBuf, + cwd: PathUri, process_id: i32, transcript: Arc>, started_at: Instant, @@ -197,7 +197,7 @@ pub(crate) async fn emit_exec_end_for_unified_exec( turn_ref: Arc, call_id: String, command: Vec, - cwd: AbsolutePathBuf, + cwd: PathUri, process_id: Option, transcript: Arc>, fallback_output: String, @@ -242,7 +242,7 @@ pub(crate) async fn emit_failed_exec_end_for_unified_exec( turn_ref: Arc, call_id: String, command: Vec, - cwd: AbsolutePathBuf, + cwd: PathUri, process_id: Option, transcript: Arc>, fallback_output: String, diff --git a/codex-rs/core/src/unified_exec/mod.rs b/codex-rs/core/src/unified_exec/mod.rs index 54e9af09fe..c873b72dd6 100644 --- a/codex-rs/core/src/unified_exec/mod.rs +++ b/codex-rs/core/src/unified_exec/mod.rs @@ -97,7 +97,11 @@ pub(crate) struct ExecCommandRequest { pub process_id: i32, pub yield_time_ms: u64, pub max_output_tokens: Option, - pub cwd: PathUri, + /// Environment-owned cwd retained for remote launch and emitted events. + pub cwd_uri: PathUri, + /// Host-native projection used only by approval, policy, and sandbox code + /// that has not migrated to foreign paths yet. + pub cwd: AbsolutePathBuf, pub sandbox_cwd: AbsolutePathBuf, pub turn_environment: TurnEnvironment, pub shell_mode: UnifiedExecShellMode, diff --git a/codex-rs/core/src/unified_exec/mod_tests.rs b/codex-rs/core/src/unified_exec/mod_tests.rs index ad39d62b96..6358b34639 100644 --- a/codex-rs/core/src/unified_exec/mod_tests.rs +++ b/codex-rs/core/src/unified_exec/mod_tests.rs @@ -103,12 +103,14 @@ async fn exec_command_with_tty( .map_or_else(|| turn.cwd.clone(), |workdir| turn.cwd.join(workdir)); let command = vec!["bash".to_string(), "-lc".to_string(), cmd.to_string()]; let request = test_exec_request(turn, command.clone(), cwd.clone(), shell_env()); + let cwd_uri = request.cwd.clone(); let process = Arc::new( manager .open_session_with_exec_env( process_id, &request, + &cwd_uri, tty, Box::new(NoopSpawnLifecycle), turn.environments @@ -808,10 +810,12 @@ async fn completed_pipe_commands_preserve_exit_code() -> anyhow::Result<()> { ); let environment = codex_exec_server::Environment::default_for_tests(); + let cwd_uri = request.cwd.clone(); let process = UnifiedExecProcessManager::default() .open_session_with_exec_env( /*process_id*/ 1234, &request, + &cwd_uri, /*tty*/ false, Box::new(NoopSpawnLifecycle), &environment, @@ -850,10 +854,12 @@ async fn unified_exec_uses_remote_exec_server_when_configured() -> anyhow::Resul ); let manager = UnifiedExecProcessManager::default(); + let cwd_uri = request.cwd.clone(); let process = manager .open_session_with_exec_env( /*process_id*/ 1234, &request, + &cwd_uri, /*tty*/ true, Box::new(NoopSpawnLifecycle), remote_test_env.environment(), @@ -907,10 +913,12 @@ async fn remote_exec_server_rejects_inherited_fd_launches() -> anyhow::Result<() ); let manager = UnifiedExecProcessManager::default(); + let cwd_uri = request.cwd.clone(); let err = manager .open_session_with_exec_env( /*process_id*/ 1234, &request, + &cwd_uri, /*tty*/ true, Box::new(TestSpawnLifecycle { inherited_fds: vec![42], diff --git a/codex-rs/core/src/unified_exec/process_manager.rs b/codex-rs/core/src/unified_exec/process_manager.rs index be4f2abe2c..91c4be48cf 100644 --- a/codex-rs/core/src/unified_exec/process_manager.rs +++ b/codex-rs/core/src/unified_exec/process_manager.rs @@ -57,6 +57,7 @@ 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::PathUri; const UNIFIED_EXEC_ENV: [(&str, &str); 10] = [ ("NO_COLOR", "1"), @@ -150,13 +151,14 @@ fn exec_server_env_for_request( fn exec_server_params_for_request( process_id: i32, request: &ExecRequest, + cwd: &PathUri, tty: bool, ) -> codex_exec_server::ExecParams { let (env_policy, env) = exec_server_env_for_request(request); codex_exec_server::ExecParams { process_id: exec_server_process_id(process_id).into(), argv: request.command.clone(), - cwd: request.cwd.clone(), + cwd: cwd.clone(), env_policy, env, tty, @@ -293,7 +295,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, @@ -308,7 +309,7 @@ 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(), Some(request.process_id.to_string()), transcript, fallback_output, @@ -384,14 +385,8 @@ impl UnifiedExecProcessManager { request: ExecCommandRequest, context: &UnifiedExecContext, ) -> Result { - // TODO(anp): Migrate unified-exec events and retained process metadata to PathUri so - // foreign working directories do not need a host-native projection here. - let event_cwd = request.cwd.to_abs_path().map_err(|err| { - UnifiedExecError::create_process(format!( - "command cwd `{}` is not native to the Codex host: {err}", - request.cwd - )) - })?; + let cwd = request.cwd.clone(); + let cwd_uri = request.cwd_uri.clone(); let process = self.open_session_with_sandbox(&request, context).await; let (process, mut deferred_network_approval) = match process { @@ -420,7 +415,7 @@ impl UnifiedExecProcessManager { ); let emitter = ToolEmitter::unified_exec( &request.command, - event_cwd.clone(), + cwd_uri.clone(), ExecCommandSource::UnifiedExecStartup, Some(request.process_id.to_string()), ); @@ -438,7 +433,8 @@ impl UnifiedExecProcessManager { context, &request.command, request.hook_command.clone(), - event_cwd.clone(), + cwd.clone(), + cwd_uri.clone(), start, request.process_id, request.tty, @@ -497,7 +493,6 @@ impl UnifiedExecProcessManager { process_started_alive, context, &request, - event_cwd.clone(), Arc::clone(&transcript), text.clone(), message.clone(), @@ -517,7 +512,6 @@ impl UnifiedExecProcessManager { process_started_alive, context, &request, - event_cwd.clone(), Arc::clone(&transcript), text.clone(), message.clone(), @@ -569,7 +563,6 @@ impl UnifiedExecProcessManager { process_started_alive, context, &request, - event_cwd.clone(), Arc::clone(&transcript), text.clone(), message.clone(), @@ -586,7 +579,7 @@ impl UnifiedExecProcessManager { Arc::clone(&context.turn), context.call_id.clone(), request.command.clone(), - event_cwd.clone(), + cwd_uri, Some(process_id.to_string()), Arc::clone(&transcript), text.clone(), @@ -846,6 +839,7 @@ impl UnifiedExecProcessManager { command: &[String], hook_command: String, cwd: AbsolutePathBuf, + cwd_uri: PathUri, started_at: Instant, process_id: i32, tty: bool, @@ -884,7 +878,7 @@ impl UnifiedExecProcessManager { Arc::clone(&context.turn), context.call_id.clone(), command.to_vec(), - cwd, + cwd_uri, process_id, transcript, started_at, @@ -895,6 +889,7 @@ impl UnifiedExecProcessManager { &self, process_id: i32, request: &ExecRequest, + cwd: &PathUri, tty: bool, mut spawn_lifecycle: SpawnLifecycleHandle, environment: &codex_exec_server::Environment, @@ -994,7 +989,9 @@ impl UnifiedExecProcessManager { let started = environment .get_exec_backend() - .start(exec_server_params_for_request(process_id, request, tty)) + .start(exec_server_params_for_request( + process_id, request, cwd, tty, + )) .await .map_err(|err| UnifiedExecError::create_process(err.to_string()))?; spawn_lifecycle.after_spawn(); @@ -1082,6 +1079,7 @@ impl UnifiedExecProcessManager { shell_type: request.shell_type, hook_command: request.hook_command.clone(), process_id: request.process_id, + cwd_uri: request.cwd_uri.clone(), cwd: request.cwd.clone(), sandbox_cwd: request.sandbox_cwd.clone(), turn_environment: request.turn_environment.clone(), 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 993adf022a..d27be7afb7 100644 --- a/codex-rs/core/src/unified_exec/process_manager_tests.rs +++ b/codex-rs/core/src/unified_exec/process_manager_tests.rs @@ -114,11 +114,12 @@ fn exec_server_params_use_path_uri_and_env_policy_overlay_contract() { arg0: None, }; + let cwd = PathUri::parse("file:///C:/remote%20workspace").expect("foreign cwd URI"); let params = - exec_server_params_for_request(/*process_id*/ 123, &request, /*tty*/ true); + exec_server_params_for_request(/*process_id*/ 123, &request, &cwd, /*tty*/ true); assert_eq!(params.process_id.as_str(), "123"); - assert_eq!(params.cwd, command_cwd); + assert_eq!(params.cwd, cwd); assert!(params.env_policy.is_some()); assert_eq!( params.env, @@ -201,8 +202,14 @@ async fn failed_initial_end_for_unstored_process_uses_fallback_output() { process_id: 123, yield_time_ms: 1000, max_output_tokens: None, + cwd_uri: turn + .environments + .primary() + .expect("primary environment") + .cwd() + .clone(), #[allow(deprecated)] - cwd: PathUri::from_abs_path(&turn.cwd), + cwd: turn.cwd.clone(), #[allow(deprecated)] sandbox_cwd: turn.cwd.clone(), turn_environment: turn @@ -230,8 +237,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(), diff --git a/codex-rs/core/tests/suite/unified_exec.rs b/codex-rs/core/tests/suite/unified_exec.rs index 9f8d931da2..0327bb66bc 100644 --- a/codex-rs/core/tests/suite/unified_exec.rs +++ b/codex-rs/core/tests/suite/unified_exec.rs @@ -437,7 +437,7 @@ 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, PathUri::from_path(&cwd)?); wait_for_event(&test.codex, |event| { matches!(event, EventMsg::TurnComplete(_)) @@ -507,8 +507,8 @@ async fn unified_exec_resolves_relative_workdir() -> Result<()> { .await; assert_eq!( - begin_event.cwd.as_path(), - workdir.as_path(), + begin_event.cwd, + PathUri::from_path(&workdir)?, "exec_command cwd should resolve relative workdir against turn cwd", ); @@ -570,8 +570,8 @@ async fn unified_exec_respects_workdir_override() -> Result<()> { .await; assert_eq!( - begin_event.cwd.as_path(), - workdir.as_path(), + begin_event.cwd, + PathUri::from_path(&workdir)?, "exec_command cwd should reflect the requested workdir override" ); diff --git a/codex-rs/mcp-server/src/codex_tool_runner.rs b/codex-rs/mcp-server/src/codex_tool_runner.rs index c4ec218dc9..189725cc93 100644 --- a/codex-rs/mcp-server/src/codex_tool_runner.rs +++ b/codex-rs/mcp-server/src/codex_tool_runner.rs @@ -233,9 +233,21 @@ async fn run_codex_tool_session_inner( additional_permissions: _, available_decisions: _, } = ev; + let cwd = match cwd.to_abs_path() { + Ok(cwd) => cwd.into_path_buf(), + Err(err) => { + let result = create_call_tool_result_with_thread_id( + thread_id, + format!("Codex runtime error: {err}"), + Some(true), + ); + outgoing.send_response(request_id.clone(), result).await; + break; + } + }; handle_exec_approval_request( command, - cwd.to_path_buf(), + cwd, outgoing.clone(), thread.clone(), request_id.clone(), diff --git a/codex-rs/protocol/src/approvals.rs b/codex-rs/protocol/src/approvals.rs index ace096359c..add7e1c44f 100644 --- a/codex-rs/protocol/src/approvals.rs +++ b/codex-rs/protocol/src/approvals.rs @@ -6,6 +6,7 @@ use crate::protocol::FileChange; use crate::protocol::ReviewDecision; use crate::request_permissions::RequestPermissionProfile; use codex_utils_absolute_path::AbsolutePathBuf; +use codex_utils_path_uri::PathUri; use schemars::JsonSchema; use serde::Deserialize; use serde::Serialize; @@ -234,7 +235,7 @@ pub struct ExecApprovalRequestEvent { /// The command to be executed. pub command: Vec, /// The command's working directory. - pub cwd: AbsolutePathBuf, + pub cwd: PathUri, /// Optional human-readable reason for the approval (e.g. retry without sandbox). #[serde(skip_serializing_if = "Option::is_none")] pub reason: Option, diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index da3e5c879c..721f65b02d 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -3242,7 +3242,7 @@ pub struct ExecCommandBeginEvent { /// The command to be executed. pub command: Vec, /// The command's working directory if not the default cwd for the agent. - pub cwd: AbsolutePathBuf, + pub cwd: PathUri, pub parsed_cmd: Vec, /// Where the command originated. Defaults to Agent for backward compatibility. #[serde(default)] @@ -3268,7 +3268,7 @@ pub struct ExecCommandEndEvent { /// The command that was executed. pub command: Vec, /// The command's working directory if not the default cwd for the agent. - pub cwd: AbsolutePathBuf, + pub cwd: PathUri, pub parsed_cmd: Vec, /// Where the command originated. Defaults to Agent for backward compatibility. #[serde(default)]