Skip to content

Commit f3989f6

Browse files
authored
fix(unified_exec): use platform default shell when unified_exec shell… (openai#7486)
# Unified Exec Shell Selection on Windows ## Problem reference issue openai#7466 The `unified_exec` handler currently deserializes model-provided tool calls into the `ExecCommandArgs` struct: ```rust #[derive(Debug, Deserialize)] struct ExecCommandArgs { cmd: String, #[serde(default)] workdir: Option<String>, #[serde(default = "default_shell")] shell: String, #[serde(default = "default_login")] login: bool, #[serde(default = "default_exec_yield_time_ms")] yield_time_ms: u64, #[serde(default)] max_output_tokens: Option<usize>, #[serde(default)] with_escalated_permissions: Option<bool>, #[serde(default)] justification: Option<String>, } ``` The `shell` field uses a hard-coded default: ```rust fn default_shell() -> String { "/bin/bash".to_string() } ``` When the model returns a tool call JSON that only contains `cmd` (which is the common case), Serde fills in `shell` with this default value. Later, `get_command` uses that value as if it were a model-provided shell path: ```rust fn get_command(args: &ExecCommandArgs) -> Vec<String> { let shell = get_shell_by_model_provided_path(&PathBuf::from(args.shell.clone())); shell.derive_exec_args(&args.cmd, args.login) } ``` On Unix, this usually resolves to `/bin/bash` and works as expected. However, on Windows this behavior is problematic: - The hard-coded `"/bin/bash"` is not a valid Windows path. - `get_shell_by_model_provided_path` treats this as a model-specified shell, and tries to resolve it (e.g. via `which::which("bash")`), which may or may not exist and may not behave as intended. - In practice, this leads to commands being executed under a non-default or non-existent shell on Windows (for example, WSL bash), instead of the expected Windows PowerShell or `cmd.exe`. The core of the issue is that **"model did not specify `shell`" is currently interpreted as "the model explicitly requested `/bin/bash`"**, which is both Unix-specific and wrong on Windows. ## Proposed Solution Instead of hard-coding `"/bin/bash"` into `ExecCommandArgs`, we should distinguish between: 1. **The model explicitly specifying a shell**, e.g.: ```json { "cmd": "echo hello", "shell": "pwsh" } ``` In this case, we *do* want to respect the model’s choice and use `get_shell_by_model_provided_path`. 2. **The model omitting the `shell` field entirely**, e.g.: ```json { "cmd": "echo hello" } ``` In this case, we should *not* assume `/bin/bash`. Instead, we should use `default_user_shell()` and let the platform decide. To express this distinction, we can: 1. Change `shell` to be optional in `ExecCommandArgs`: ```rust #[derive(Debug, Deserialize)] struct ExecCommandArgs { cmd: String, #[serde(default)] workdir: Option<String>, #[serde(default)] shell: Option<String>, #[serde(default = "default_login")] login: bool, #[serde(default = "default_exec_yield_time_ms")] yield_time_ms: u64, #[serde(default)] max_output_tokens: Option<usize>, #[serde(default)] with_escalated_permissions: Option<bool>, #[serde(default)] justification: Option<String>, } ``` Here, the absence of `shell` in the JSON is represented as `shell: None`, rather than a hard-coded string value.
1 parent dbec741 commit f3989f6

File tree

2 files changed

+97
-39
lines changed

2 files changed

+97
-39
lines changed

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

Lines changed: 71 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ use crate::protocol::EventMsg;
66
use crate::protocol::ExecCommandOutputDeltaEvent;
77
use crate::protocol::ExecCommandSource;
88
use crate::protocol::ExecOutputStream;
9+
use crate::shell::default_user_shell;
910
use crate::shell::get_shell_by_model_provided_path;
1011
use crate::tools::context::ToolInvocation;
1112
use crate::tools::context::ToolOutput;
@@ -31,8 +32,8 @@ struct ExecCommandArgs {
3132
cmd: String,
3233
#[serde(default)]
3334
workdir: Option<String>,
34-
#[serde(default = "default_shell")]
35-
shell: String,
35+
#[serde(default)]
36+
shell: Option<String>,
3637
#[serde(default = "default_login")]
3738
login: bool,
3839
#[serde(default = "default_exec_yield_time_ms")]
@@ -65,10 +66,6 @@ fn default_write_stdin_yield_time_ms() -> u64 {
6566
250
6667
}
6768

68-
fn default_shell() -> String {
69-
"/bin/bash".to_string()
70-
}
71-
7269
fn default_login() -> bool {
7370
true
7471
}
@@ -257,7 +254,12 @@ impl ToolHandler for UnifiedExecHandler {
257254
}
258255

259256
fn get_command(args: &ExecCommandArgs) -> Vec<String> {
260-
let shell = get_shell_by_model_provided_path(&PathBuf::from(args.shell.clone()));
257+
let shell = if let Some(shell_str) = &args.shell {
258+
get_shell_by_model_provided_path(&PathBuf::from(shell_str))
259+
} else {
260+
default_user_shell()
261+
};
262+
261263
shell.derive_exec_args(&args.cmd, args.login)
262264
}
263265

@@ -289,3 +291,65 @@ fn format_response(response: &UnifiedExecResponse) -> String {
289291

290292
sections.join("\n")
291293
}
294+
295+
#[cfg(test)]
296+
mod tests {
297+
use super::*;
298+
299+
#[test]
300+
fn test_get_command_uses_default_shell_when_unspecified() {
301+
let json = r#"{"cmd": "echo hello"}"#;
302+
303+
let args: ExecCommandArgs =
304+
serde_json::from_str(json).expect("deserialize ExecCommandArgs");
305+
306+
assert!(args.shell.is_none());
307+
308+
let command = get_command(&args);
309+
310+
assert_eq!(command.len(), 3);
311+
assert_eq!(command[2], "echo hello");
312+
}
313+
314+
#[test]
315+
fn test_get_command_respects_explicit_bash_shell() {
316+
let json = r#"{"cmd": "echo hello", "shell": "/bin/bash"}"#;
317+
318+
let args: ExecCommandArgs =
319+
serde_json::from_str(json).expect("deserialize ExecCommandArgs");
320+
321+
assert_eq!(args.shell.as_deref(), Some("/bin/bash"));
322+
323+
let command = get_command(&args);
324+
325+
assert_eq!(command[2], "echo hello");
326+
}
327+
328+
#[test]
329+
fn test_get_command_respects_explicit_powershell_shell() {
330+
let json = r#"{"cmd": "echo hello", "shell": "powershell"}"#;
331+
332+
let args: ExecCommandArgs =
333+
serde_json::from_str(json).expect("deserialize ExecCommandArgs");
334+
335+
assert_eq!(args.shell.as_deref(), Some("powershell"));
336+
337+
let command = get_command(&args);
338+
339+
assert_eq!(command[2], "echo hello");
340+
}
341+
342+
#[test]
343+
fn test_get_command_respects_explicit_cmd_shell() {
344+
let json = r#"{"cmd": "echo hello", "shell": "cmd"}"#;
345+
346+
let args: ExecCommandArgs =
347+
serde_json::from_str(json).expect("deserialize ExecCommandArgs");
348+
349+
assert_eq!(args.shell.as_deref(), Some("cmd"));
350+
351+
let command = get_command(&args);
352+
353+
assert_eq!(command[2], "echo hello");
354+
}
355+
}

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

Lines changed: 26 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -295,6 +295,7 @@ async fn unified_exec_emits_exec_command_begin_event() -> Result<()> {
295295

296296
let call_id = "uexec-begin-event";
297297
let args = json!({
298+
"shell": "bash".to_string(),
298299
"cmd": "/bin/echo hello unified exec".to_string(),
299300
"yield_time_ms": 250,
300301
});
@@ -336,14 +337,8 @@ async fn unified_exec_emits_exec_command_begin_event() -> Result<()> {
336337
})
337338
.await;
338339

339-
assert_eq!(
340-
begin_event.command,
341-
vec![
342-
"/bin/bash".to_string(),
343-
"-lc".to_string(),
344-
"/bin/echo hello unified exec".to_string()
345-
]
346-
);
340+
assert_command(&begin_event.command, "-lc", "/bin/echo hello unified exec");
341+
347342
assert_eq!(begin_event.cwd, cwd.path());
348343

349344
wait_for_event(&codex, |event| matches!(event, EventMsg::TaskComplete(_))).await;
@@ -782,6 +777,7 @@ async fn unified_exec_emits_begin_for_write_stdin() -> Result<()> {
782777

783778
let open_call_id = "uexec-open-for-begin";
784779
let open_args = json!({
780+
"shell": "bash".to_string(),
785781
"cmd": "bash -i".to_string(),
786782
"yield_time_ms": 200,
787783
});
@@ -843,14 +839,7 @@ async fn unified_exec_emits_begin_for_write_stdin() -> Result<()> {
843839
})
844840
.await;
845841

846-
assert_eq!(
847-
begin_event.command,
848-
vec![
849-
"/bin/bash".to_string(),
850-
"-lc".to_string(),
851-
"bash -i".to_string()
852-
]
853-
);
842+
assert_command(&begin_event.command, "-lc", "bash -i");
854843
assert_eq!(
855844
begin_event.interaction_input,
856845
Some("echo hello".to_string())
@@ -884,6 +873,7 @@ async fn unified_exec_emits_begin_event_for_write_stdin_requests() -> Result<()>
884873

885874
let open_call_id = "uexec-open-session";
886875
let open_args = json!({
876+
"shell": "bash".to_string(),
887877
"cmd": "bash -i".to_string(),
888878
"yield_time_ms": 250,
889879
});
@@ -959,14 +949,9 @@ async fn unified_exec_emits_begin_event_for_write_stdin_requests() -> Result<()>
959949
.iter()
960950
.find(|ev| ev.call_id == open_call_id)
961951
.expect("missing exec_command begin");
962-
assert_eq!(
963-
open_event.command,
964-
vec![
965-
"/bin/bash".to_string(),
966-
"-lc".to_string(),
967-
"bash -i".to_string()
968-
]
969-
);
952+
953+
assert_command(&open_event.command, "-lc", "bash -i");
954+
970955
assert!(
971956
open_event.interaction_input.is_none(),
972957
"startup begin events should not include interaction input"
@@ -977,14 +962,9 @@ async fn unified_exec_emits_begin_event_for_write_stdin_requests() -> Result<()>
977962
.iter()
978963
.find(|ev| ev.call_id == poll_call_id)
979964
.expect("missing write_stdin begin");
980-
assert_eq!(
981-
poll_event.command,
982-
vec![
983-
"/bin/bash".to_string(),
984-
"-lc".to_string(),
985-
"bash -i".to_string()
986-
]
987-
);
965+
966+
assert_command(&poll_event.command, "-lc", "bash -i");
967+
988968
assert!(
989969
poll_event.interaction_input.is_none(),
990970
"poll begin events should omit interaction input"
@@ -2121,3 +2101,17 @@ async fn unified_exec_prunes_exited_sessions_first() -> Result<()> {
21212101

21222102
Ok(())
21232103
}
2104+
2105+
fn assert_command(command: &[String], expected_args: &str, expected_cmd: &str) {
2106+
assert_eq!(command.len(), 3);
2107+
let shell_path = &command[0];
2108+
assert!(
2109+
shell_path == "/bin/bash"
2110+
|| shell_path == "/usr/bin/bash"
2111+
|| shell_path == "/usr/local/bin/bash"
2112+
|| shell_path.ends_with("/bash"),
2113+
"unexpected bash path: {shell_path}"
2114+
);
2115+
assert_eq!(command[1], expected_args);
2116+
assert_eq!(command[2], expected_cmd);
2117+
}

0 commit comments

Comments
 (0)