Skip to content

Commit f01f2ec

Browse files
authored
feat: add workdir to unified_exec (#6466)
1 parent 9808864 commit f01f2ec

File tree

5 files changed

+123
-7
lines changed

5 files changed

+123
-7
lines changed

codex-rs/core/src/tools/handlers/unified_exec.rs

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
use std::path::PathBuf;
2+
13
use async_trait::async_trait;
24
use serde::Deserialize;
35

@@ -24,6 +26,8 @@ pub struct UnifiedExecHandler;
2426
#[derive(Debug, Deserialize)]
2527
struct ExecCommandArgs {
2628
cmd: String,
29+
#[serde(default)]
30+
workdir: Option<String>,
2731
#[serde(default = "default_shell")]
2832
shell: String,
2933
#[serde(default = "default_login")]
@@ -96,15 +100,20 @@ impl ToolHandler for UnifiedExecHandler {
96100
"failed to parse exec_command arguments: {err:?}"
97101
))
98102
})?;
103+
let workdir = args
104+
.workdir
105+
.as_deref()
106+
.filter(|value| !value.is_empty())
107+
.map(PathBuf::from);
108+
let cwd = workdir.clone().unwrap_or_else(|| context.turn.cwd.clone());
99109

100110
let event_ctx = ToolEventCtx::new(
101111
context.session.as_ref(),
102112
context.turn.as_ref(),
103113
&context.call_id,
104114
None,
105115
);
106-
let emitter =
107-
ToolEmitter::unified_exec(args.cmd.clone(), context.turn.cwd.clone(), true);
116+
let emitter = ToolEmitter::unified_exec(args.cmd.clone(), cwd.clone(), true);
108117
emitter.emit(event_ctx, ToolEventStage::Begin).await;
109118

110119
manager
@@ -115,6 +124,7 @@ impl ToolHandler for UnifiedExecHandler {
115124
login: args.login,
116125
yield_time_ms: args.yield_time_ms,
117126
max_output_tokens: args.max_output_tokens,
127+
workdir,
118128
},
119129
&context,
120130
)

codex-rs/core/src/tools/spec.rs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,15 @@ fn create_exec_command_tool() -> ToolSpec {
138138
description: Some("Shell command to execute.".to_string()),
139139
},
140140
);
141+
properties.insert(
142+
"workdir".to_string(),
143+
JsonSchema::String {
144+
description: Some(
145+
"Optional working directory to run the command in; defaults to the turn cwd."
146+
.to_string(),
147+
),
148+
},
149+
);
141150
properties.insert(
142151
"shell".to_string(),
143152
JsonSchema::String {

codex-rs/core/src/unified_exec/mod.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,7 @@ pub(crate) struct ExecCommandRequest<'a> {
7070
pub login: bool,
7171
pub yield_time_ms: Option<u64>,
7272
pub max_output_tokens: Option<usize>,
73+
pub workdir: Option<PathBuf>,
7374
}
7475

7576
#[derive(Debug)]
@@ -199,6 +200,7 @@ mod tests {
199200
login: true,
200201
yield_time_ms,
201202
max_output_tokens: None,
203+
workdir: None,
202204
},
203205
&context,
204206
)

codex-rs/core/src/unified_exec/session_manager.rs

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
use std::path::PathBuf;
12
use std::sync::Arc;
23

34
use tokio::sync::Notify;
@@ -38,14 +39,20 @@ impl UnifiedExecSessionManager {
3839
request: ExecCommandRequest<'_>,
3940
context: &UnifiedExecContext,
4041
) -> Result<UnifiedExecResponse, UnifiedExecError> {
42+
let cwd = request
43+
.workdir
44+
.clone()
45+
.unwrap_or_else(|| context.turn.cwd.clone());
4146
let shell_flag = if request.login { "-lc" } else { "-c" };
4247
let command = vec![
4348
request.shell.to_string(),
4449
shell_flag.to_string(),
4550
request.command.to_string(),
4651
];
4752

48-
let session = self.open_session_with_sandbox(command, context).await?;
53+
let session = self
54+
.open_session_with_sandbox(command, cwd.clone(), context)
55+
.await?;
4956

5057
let max_tokens = resolve_max_tokens(request.max_output_tokens);
5158
let yield_time_ms =
@@ -66,7 +73,7 @@ impl UnifiedExecSessionManager {
6673
None
6774
} else {
6875
Some(
69-
self.store_session(session, context, request.command, start)
76+
self.store_session(session, context, request.command, cwd.clone(), start)
7077
.await,
7178
)
7279
};
@@ -87,6 +94,7 @@ impl UnifiedExecSessionManager {
8794
Self::emit_exec_end_from_context(
8895
context,
8996
request.command.to_string(),
97+
cwd,
9098
response.output.clone(),
9199
exit,
92100
response.wall_time,
@@ -211,6 +219,7 @@ impl UnifiedExecSessionManager {
211219
session: UnifiedExecSession,
212220
context: &UnifiedExecContext,
213221
command: &str,
222+
cwd: PathBuf,
214223
started_at: Instant,
215224
) -> i32 {
216225
let session_id = self
@@ -222,7 +231,7 @@ impl UnifiedExecSessionManager {
222231
turn_ref: Arc::clone(&context.turn),
223232
call_id: context.call_id.clone(),
224233
command: command.to_string(),
225-
cwd: context.turn.cwd.clone(),
234+
cwd,
226235
started_at,
227236
};
228237
self.sessions.lock().await.insert(session_id, entry);
@@ -258,6 +267,7 @@ impl UnifiedExecSessionManager {
258267
async fn emit_exec_end_from_context(
259268
context: &UnifiedExecContext,
260269
command: String,
270+
cwd: PathBuf,
261271
aggregated_output: String,
262272
exit_code: i32,
263273
duration: Duration,
@@ -276,7 +286,7 @@ impl UnifiedExecSessionManager {
276286
&context.call_id,
277287
None,
278288
);
279-
let emitter = ToolEmitter::unified_exec(command, context.turn.cwd.clone(), true);
289+
let emitter = ToolEmitter::unified_exec(command, cwd, true);
280290
emitter
281291
.emit(event_ctx, ToolEventStage::Success(output))
282292
.await;
@@ -300,13 +310,14 @@ impl UnifiedExecSessionManager {
300310
pub(super) async fn open_session_with_sandbox(
301311
&self,
302312
command: Vec<String>,
313+
cwd: PathBuf,
303314
context: &UnifiedExecContext,
304315
) -> Result<UnifiedExecSession, UnifiedExecError> {
305316
let mut orchestrator = ToolOrchestrator::new();
306317
let mut runtime = UnifiedExecRuntime::new(self);
307318
let req = UnifiedExecToolRequest::new(
308319
command,
309-
context.turn.cwd.clone(),
320+
cwd,
310321
create_env(&context.turn.shell_environment_policy),
311322
);
312323
let tool_ctx = ToolCtx {

codex-rs/core/tests/suite/unified_exec.rs

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -223,6 +223,90 @@ async fn unified_exec_emits_exec_command_begin_event() -> Result<()> {
223223
Ok(())
224224
}
225225

226+
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
227+
async fn unified_exec_respects_workdir_override() -> Result<()> {
228+
skip_if_no_network!(Ok(()));
229+
skip_if_sandbox!(Ok(()));
230+
231+
let server = start_mock_server().await;
232+
233+
let mut builder = test_codex().with_config(|config| {
234+
config.use_experimental_unified_exec_tool = true;
235+
config.features.enable(Feature::UnifiedExec);
236+
});
237+
let TestCodex {
238+
codex,
239+
cwd,
240+
session_configured,
241+
..
242+
} = builder.build(&server).await?;
243+
244+
let workdir = cwd.path().join("uexec_workdir_test");
245+
std::fs::create_dir_all(&workdir)?;
246+
247+
let call_id = "uexec-workdir";
248+
let args = json!({
249+
"cmd": "pwd",
250+
"yield_time_ms": 250,
251+
"workdir": workdir.to_string_lossy().to_string(),
252+
});
253+
254+
let responses = vec![
255+
sse(vec![
256+
ev_response_created("resp-1"),
257+
ev_function_call(call_id, "exec_command", &serde_json::to_string(&args)?),
258+
ev_completed("resp-1"),
259+
]),
260+
sse(vec![
261+
ev_response_created("resp-2"),
262+
ev_assistant_message("msg-1", "finished"),
263+
ev_completed("resp-2"),
264+
]),
265+
];
266+
mount_sse_sequence(&server, responses).await;
267+
268+
let session_model = session_configured.model.clone();
269+
270+
codex
271+
.submit(Op::UserTurn {
272+
items: vec![UserInput::Text {
273+
text: "run workdir test".into(),
274+
}],
275+
final_output_json_schema: None,
276+
cwd: cwd.path().to_path_buf(),
277+
approval_policy: AskForApproval::Never,
278+
sandbox_policy: SandboxPolicy::DangerFullAccess,
279+
model: session_model,
280+
effort: None,
281+
summary: ReasoningSummary::Auto,
282+
})
283+
.await?;
284+
285+
wait_for_event(&codex, |event| matches!(event, EventMsg::TaskComplete(_))).await;
286+
287+
let requests = server.received_requests().await.expect("recorded requests");
288+
assert!(!requests.is_empty(), "expected at least one POST request");
289+
290+
let bodies = requests
291+
.iter()
292+
.map(|req| req.body_json::<Value>().expect("request json"))
293+
.collect::<Vec<_>>();
294+
295+
let outputs = collect_tool_outputs(&bodies)?;
296+
let output = outputs
297+
.get(call_id)
298+
.expect("missing exec_command workdir output");
299+
let output_text = output.output.trim();
300+
let output_canonical = std::fs::canonicalize(output_text)?;
301+
let expected_canonical = std::fs::canonicalize(&workdir)?;
302+
assert_eq!(
303+
output_canonical, expected_canonical,
304+
"pwd should reflect the requested workdir override"
305+
);
306+
307+
Ok(())
308+
}
309+
226310
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
227311
async fn unified_exec_emits_exec_command_end_event() -> Result<()> {
228312
skip_if_no_network!(Ok(()));

0 commit comments

Comments
 (0)