diff --git a/codex-rs/core/src/tools/handlers/unified_exec.rs b/codex-rs/core/src/tools/handlers/unified_exec.rs index 593925a7..6542c333 100644 --- a/codex-rs/core/src/tools/handlers/unified_exec.rs +++ b/codex-rs/core/src/tools/handlers/unified_exec.rs @@ -1,3 +1,5 @@ +use std::path::PathBuf; + use async_trait::async_trait; use serde::Deserialize; @@ -24,6 +26,8 @@ pub struct UnifiedExecHandler; #[derive(Debug, Deserialize)] struct ExecCommandArgs { cmd: String, + #[serde(default)] + workdir: Option, #[serde(default = "default_shell")] shell: String, #[serde(default = "default_login")] @@ -96,6 +100,12 @@ impl ToolHandler for UnifiedExecHandler { "failed to parse exec_command arguments: {err:?}" )) })?; + let workdir = args + .workdir + .as_deref() + .filter(|value| !value.is_empty()) + .map(PathBuf::from); + let cwd = workdir.clone().unwrap_or_else(|| context.turn.cwd.clone()); let event_ctx = ToolEventCtx::new( context.session.as_ref(), @@ -103,8 +113,7 @@ impl ToolHandler for UnifiedExecHandler { &context.call_id, None, ); - let emitter = - ToolEmitter::unified_exec(args.cmd.clone(), context.turn.cwd.clone(), true); + let emitter = ToolEmitter::unified_exec(args.cmd.clone(), cwd.clone(), true); emitter.emit(event_ctx, ToolEventStage::Begin).await; manager @@ -115,6 +124,7 @@ impl ToolHandler for UnifiedExecHandler { login: args.login, yield_time_ms: args.yield_time_ms, max_output_tokens: args.max_output_tokens, + workdir, }, &context, ) diff --git a/codex-rs/core/src/tools/spec.rs b/codex-rs/core/src/tools/spec.rs index 1e4a0d9e..cb1aeafd 100644 --- a/codex-rs/core/src/tools/spec.rs +++ b/codex-rs/core/src/tools/spec.rs @@ -138,6 +138,15 @@ fn create_exec_command_tool() -> ToolSpec { description: Some("Shell command to execute.".to_string()), }, ); + properties.insert( + "workdir".to_string(), + JsonSchema::String { + description: Some( + "Optional working directory to run the command in; defaults to the turn cwd." + .to_string(), + ), + }, + ); properties.insert( "shell".to_string(), JsonSchema::String { diff --git a/codex-rs/core/src/unified_exec/mod.rs b/codex-rs/core/src/unified_exec/mod.rs index a1e2c0c4..16fbc4c7 100644 --- a/codex-rs/core/src/unified_exec/mod.rs +++ b/codex-rs/core/src/unified_exec/mod.rs @@ -70,6 +70,7 @@ pub(crate) struct ExecCommandRequest<'a> { pub login: bool, pub yield_time_ms: Option, pub max_output_tokens: Option, + pub workdir: Option, } #[derive(Debug)] @@ -199,6 +200,7 @@ mod tests { login: true, yield_time_ms, max_output_tokens: None, + workdir: None, }, &context, ) diff --git a/codex-rs/core/src/unified_exec/session_manager.rs b/codex-rs/core/src/unified_exec/session_manager.rs index 7f4727d0..bc10fc2b 100644 --- a/codex-rs/core/src/unified_exec/session_manager.rs +++ b/codex-rs/core/src/unified_exec/session_manager.rs @@ -1,3 +1,4 @@ +use std::path::PathBuf; use std::sync::Arc; use tokio::sync::Notify; @@ -38,6 +39,10 @@ impl UnifiedExecSessionManager { request: ExecCommandRequest<'_>, context: &UnifiedExecContext, ) -> Result { + let cwd = request + .workdir + .clone() + .unwrap_or_else(|| context.turn.cwd.clone()); let shell_flag = if request.login { "-lc" } else { "-c" }; let command = vec![ request.shell.to_string(), @@ -45,7 +50,9 @@ impl UnifiedExecSessionManager { request.command.to_string(), ]; - let session = self.open_session_with_sandbox(command, context).await?; + let session = self + .open_session_with_sandbox(command, cwd.clone(), context) + .await?; let max_tokens = resolve_max_tokens(request.max_output_tokens); let yield_time_ms = @@ -66,7 +73,7 @@ impl UnifiedExecSessionManager { None } else { Some( - self.store_session(session, context, request.command, start) + self.store_session(session, context, request.command, cwd.clone(), start) .await, ) }; @@ -87,6 +94,7 @@ impl UnifiedExecSessionManager { Self::emit_exec_end_from_context( context, request.command.to_string(), + cwd, response.output.clone(), exit, response.wall_time, @@ -211,6 +219,7 @@ impl UnifiedExecSessionManager { session: UnifiedExecSession, context: &UnifiedExecContext, command: &str, + cwd: PathBuf, started_at: Instant, ) -> i32 { let session_id = self @@ -222,7 +231,7 @@ impl UnifiedExecSessionManager { turn_ref: Arc::clone(&context.turn), call_id: context.call_id.clone(), command: command.to_string(), - cwd: context.turn.cwd.clone(), + cwd, started_at, }; self.sessions.lock().await.insert(session_id, entry); @@ -258,6 +267,7 @@ impl UnifiedExecSessionManager { async fn emit_exec_end_from_context( context: &UnifiedExecContext, command: String, + cwd: PathBuf, aggregated_output: String, exit_code: i32, duration: Duration, @@ -276,7 +286,7 @@ impl UnifiedExecSessionManager { &context.call_id, None, ); - let emitter = ToolEmitter::unified_exec(command, context.turn.cwd.clone(), true); + let emitter = ToolEmitter::unified_exec(command, cwd, true); emitter .emit(event_ctx, ToolEventStage::Success(output)) .await; @@ -300,13 +310,14 @@ impl UnifiedExecSessionManager { pub(super) async fn open_session_with_sandbox( &self, command: Vec, + cwd: PathBuf, context: &UnifiedExecContext, ) -> Result { let mut orchestrator = ToolOrchestrator::new(); let mut runtime = UnifiedExecRuntime::new(self); let req = UnifiedExecToolRequest::new( command, - context.turn.cwd.clone(), + cwd, create_env(&context.turn.shell_environment_policy), ); let tool_ctx = ToolCtx { diff --git a/codex-rs/core/tests/suite/unified_exec.rs b/codex-rs/core/tests/suite/unified_exec.rs index 50918c14..cd8b3382 100644 --- a/codex-rs/core/tests/suite/unified_exec.rs +++ b/codex-rs/core/tests/suite/unified_exec.rs @@ -223,6 +223,90 @@ async fn unified_exec_emits_exec_command_begin_event() -> Result<()> { Ok(()) } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn unified_exec_respects_workdir_override() -> Result<()> { + skip_if_no_network!(Ok(())); + skip_if_sandbox!(Ok(())); + + let server = start_mock_server().await; + + let mut builder = test_codex().with_config(|config| { + config.use_experimental_unified_exec_tool = true; + config.features.enable(Feature::UnifiedExec); + }); + let TestCodex { + codex, + cwd, + session_configured, + .. + } = builder.build(&server).await?; + + let workdir = cwd.path().join("uexec_workdir_test"); + std::fs::create_dir_all(&workdir)?; + + let call_id = "uexec-workdir"; + let args = json!({ + "cmd": "pwd", + "yield_time_ms": 250, + "workdir": workdir.to_string_lossy().to_string(), + }); + + let responses = vec![ + sse(vec![ + ev_response_created("resp-1"), + ev_function_call(call_id, "exec_command", &serde_json::to_string(&args)?), + ev_completed("resp-1"), + ]), + sse(vec![ + ev_response_created("resp-2"), + ev_assistant_message("msg-1", "finished"), + ev_completed("resp-2"), + ]), + ]; + mount_sse_sequence(&server, responses).await; + + let session_model = session_configured.model.clone(); + + codex + .submit(Op::UserTurn { + items: vec![UserInput::Text { + text: "run workdir test".into(), + }], + final_output_json_schema: None, + cwd: cwd.path().to_path_buf(), + approval_policy: AskForApproval::Never, + sandbox_policy: SandboxPolicy::DangerFullAccess, + model: session_model, + effort: None, + summary: ReasoningSummary::Auto, + }) + .await?; + + wait_for_event(&codex, |event| matches!(event, EventMsg::TaskComplete(_))).await; + + let requests = server.received_requests().await.expect("recorded requests"); + assert!(!requests.is_empty(), "expected at least one POST request"); + + let bodies = requests + .iter() + .map(|req| req.body_json::().expect("request json")) + .collect::>(); + + let outputs = collect_tool_outputs(&bodies)?; + let output = outputs + .get(call_id) + .expect("missing exec_command workdir output"); + let output_text = output.output.trim(); + let output_canonical = std::fs::canonicalize(output_text)?; + let expected_canonical = std::fs::canonicalize(&workdir)?; + assert_eq!( + output_canonical, expected_canonical, + "pwd should reflect the requested workdir override" + ); + + Ok(()) +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn unified_exec_emits_exec_command_end_event() -> Result<()> { skip_if_no_network!(Ok(()));