From 6dcbba34c3d8e8a660c36e2d72a4194be6212088 Mon Sep 17 00:00:00 2001 From: Dylan Hurd Date: Mon, 17 Nov 2025 16:20:57 -0800 Subject: [PATCH 1/5] fix(windows) shell_command on windows, minor parsing --- codex-rs/core/src/features.rs | 2 +- codex-rs/core/src/lib.rs | 1 + codex-rs/core/src/parse_command.rs | 49 ++++++++++++++++++ codex-rs/core/src/powershell.rs | 81 ++++++++++++++++++++++++++++++ codex-rs/core/src/shell.rs | 14 ++++-- codex-rs/tui/src/exec_command.rs | 4 +- 6 files changed, 143 insertions(+), 8 deletions(-) create mode 100644 codex-rs/core/src/powershell.rs diff --git a/codex-rs/core/src/features.rs b/codex-rs/core/src/features.rs index 87788d428d..8a7e476d03 100644 --- a/codex-rs/core/src/features.rs +++ b/codex-rs/core/src/features.rs @@ -259,7 +259,7 @@ pub const FEATURES: &[FeatureSpec] = &[ id: Feature::ShellCommandTool, key: "shell_command_tool", stage: Stage::Experimental, - default_enabled: false, + default_enabled: cfg!(windows), }, FeatureSpec { id: Feature::RmcpClient, diff --git a/codex-rs/core/src/lib.rs b/codex-rs/core/src/lib.rs index e684781194..85618ffa2a 100644 --- a/codex-rs/core/src/lib.rs +++ b/codex-rs/core/src/lib.rs @@ -13,6 +13,7 @@ mod client; mod client_common; pub mod codex; mod codex_conversation; +pub mod powershell; pub use codex_conversation::CodexConversation; mod codex_delegate; mod command_safety; diff --git a/codex-rs/core/src/parse_command.rs b/codex-rs/core/src/parse_command.rs index f9819ba83b..f335347042 100644 --- a/codex-rs/core/src/parse_command.rs +++ b/codex-rs/core/src/parse_command.rs @@ -1,6 +1,7 @@ use crate::bash::extract_bash_command; use crate::bash::try_parse_shell; use crate::bash::try_parse_word_only_commands_sequence; +use crate::powershell::extract_powershell_command; use codex_protocol::parse_command::ParsedCommand; use shlex::split as shlex_split; use shlex::try_join as shlex_try_join; @@ -11,6 +12,11 @@ pub fn shlex_join(tokens: &[String]) -> String { .unwrap_or_else(|_| "".to_string()) } +/// Extracts the shell and script from a command, regardless of platform +pub fn extract_shell_command(command: &[String]) -> Option<(&str, &str)> { + extract_bash_command(command).or_else(|| extract_powershell_command(command)) +} + /// DO NOT REVIEW THIS CODE BY HAND /// This parsing code is quite complex and not easy to hand-modify. /// The easiest way to iterate is to add unit tests and have Codex fix the implementation. @@ -877,6 +883,42 @@ mod tests { }], ); } + + #[test] + fn powershell_command_is_stripped() { + assert_parsed( + &vec_str(&["powershell", "-Command", "Get-ChildItem"]), + vec![ParsedCommand::Unknown { + cmd: "Get-ChildItem".to_string(), + }], + ); + } + + #[test] + fn pwsh_with_noprofile_and_c_alias_is_stripped() { + assert_parsed( + &vec_str(&["pwsh", "-NoProfile", "-c", "Write-Host hi"]), + vec![ParsedCommand::Unknown { + cmd: "Write-Host hi".to_string(), + }], + ); + } + + #[test] + fn powershell_with_path_is_stripped() { + let command = if cfg!(windows) { + "C:\\windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe" + } else { + "/usr/local/bin/powershell.exe" + }; + + assert_parsed( + &vec_str(&[command, "-NoProfile", "-c", "Write-Host hi"]), + vec![ParsedCommand::Unknown { + cmd: "Write-Host hi".to_string(), + }], + ); + } } pub fn parse_command_impl(command: &[String]) -> Vec { @@ -884,6 +926,12 @@ pub fn parse_command_impl(command: &[String]) -> Vec { return commands; } + if let Some((_, script)) = extract_powershell_command(command) { + return vec![ParsedCommand::Unknown { + cmd: script.to_string(), + }]; + } + let normalized = normalize_tokens(command); let parts = if contains_connectors(&normalized) { @@ -1190,6 +1238,7 @@ fn parse_find_query_and_path(tail: &[String]) -> (Option, Option } fn parse_shell_lc_commands(original: &[String]) -> Option> { + // Only handle bash/zsh here; PowerShell is stripped separately without bash parsing. let (_, script) = extract_bash_command(original)?; if let Some(tree) = try_parse_shell(script) diff --git a/codex-rs/core/src/powershell.rs b/codex-rs/core/src/powershell.rs new file mode 100644 index 0000000000..9526f87bdb --- /dev/null +++ b/codex-rs/core/src/powershell.rs @@ -0,0 +1,81 @@ +use std::path::PathBuf; + +use crate::shell::ShellType; +use crate::shell::detect_shell_type; + +const POWERSHELL_FLAGS: &[&str] = &["-NoLogo", "-NoProfile", "-Command", "-c"]; + +/// Extract the PowerShell script body from an invocation such as: +/// +/// - ["pwsh", "-NoProfile", "-Command", "Get-ChildItem -Recurse | Select-String foo"] +/// - ["powershell.exe", "-Command", "Write-Host hi"] +/// - ["powershell", "-NoLogo", "-NoProfile", "-Command", "...script..."] +/// +/// Returns (`shell`, `script`) when the first arg is a PowerShell executable and a +/// `-Command` (or `-c`) flag is present followed by a script string. +pub fn extract_powershell_command(command: &[String]) -> Option<(&str, &str)> { + if command.len() < 3 { + return None; + } + + let shell = &command[0]; + if detect_shell_type(&PathBuf::from(shell)) != Some(ShellType::PowerShell) { + return None; + } + + // Find the first occurrence of -Command (accept common short alias -c as well) + let mut i = 1usize; + while i + 1 < command.len() { + let flag = &command[i]; + // Reject unknown flags + if !POWERSHELL_FLAGS.contains(&flag.as_str()) { + return None; + } + if flag.eq_ignore_ascii_case("-Command") || flag.eq_ignore_ascii_case("-c") { + let script = &command[i + 1]; + return Some((shell, script.as_str())); + } + i += 1; + } + None +} + +#[cfg(test)] +mod tests { + use super::extract_powershell_command; + + #[test] + fn extracts_basic_powershell_command() { + let cmd = vec![ + "powershell".to_string(), + "-Command".to_string(), + "Write-Host hi".to_string(), + ]; + let (_shell, script) = extract_powershell_command(&cmd).expect("extract"); + assert_eq!(script, "Write-Host hi"); + } + + #[test] + fn extracts_full_path_powershell_command() { + let command = if cfg!(windows) { + "C:\\windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe".to_string() + } else { + "/usr/local/bin/powershell.exe".to_string() + }; + let cmd = vec![command, "-Command".to_string(), "Write-Host hi".to_string()]; + let (_shell, script) = extract_powershell_command(&cmd).expect("extract"); + assert_eq!(script, "Write-Host hi"); + } + + #[test] + fn extracts_with_noprofile_and_alias() { + let cmd = vec![ + "pwsh".to_string(), + "-NoProfile".to_string(), + "-c".to_string(), + "Get-ChildItem | Select-String foo".to_string(), + ]; + let (_shell, script) = extract_powershell_command(&cmd).expect("extract"); + assert_eq!(script, "Get-ChildItem | Select-String foo"); + } +} diff --git a/codex-rs/core/src/shell.rs b/codex-rs/core/src/shell.rs index ed52491dad..7bfec089c5 100644 --- a/codex-rs/core/src/shell.rs +++ b/codex-rs/core/src/shell.rs @@ -61,10 +61,7 @@ impl Shell { ] } Shell::PowerShell(ps) => { - let mut args = vec![ - ps.shell_path.to_string_lossy().to_string(), - "-NoLogo".to_string(), - ]; + let mut args = vec![ps.shell_path.to_string_lossy().to_string()]; if !use_login_shell { args.push("-NoProfile".to_string()); } @@ -192,7 +189,6 @@ pub fn detect_shell_type(shell_path: &PathBuf) -> Option { Some("powershell") => Some(ShellType::PowerShell), _ => { let shell_name = shell_path.file_stem(); - if let Some(shell_name) = shell_name && shell_name != shell_path { @@ -251,6 +247,14 @@ mod detect_shell_type_tests { detect_shell_type(&PathBuf::from("powershell.exe")), Some(ShellType::PowerShell) ); + assert_eq!( + detect_shell_type(&PathBuf::from(if cfg!(windows) { + "C:\\windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe" + } else { + "/usr/local/bin/pwsh" + })), + Some(ShellType::PowerShell) + ); assert_eq!( detect_shell_type(&PathBuf::from("pwsh.exe")), Some(ShellType::PowerShell) diff --git a/codex-rs/tui/src/exec_command.rs b/codex-rs/tui/src/exec_command.rs index 6f2212b094..8ce6c2632e 100644 --- a/codex-rs/tui/src/exec_command.rs +++ b/codex-rs/tui/src/exec_command.rs @@ -1,7 +1,7 @@ use std::path::Path; use std::path::PathBuf; -use codex_core::bash::extract_bash_command; +use codex_core::parse_command::extract_shell_command; use dirs::home_dir; use shlex::try_join; @@ -10,7 +10,7 @@ pub(crate) fn escape_command(command: &[String]) -> String { } pub(crate) fn strip_bash_lc_and_escape(command: &[String]) -> String { - if let Some((_, script)) = extract_bash_command(command) { + if let Some((_, script)) = extract_shell_command(command) { return script.to_string(); } escape_command(command) From 752dadeca0a05b0ca22fe1b684ba94bbec03dd90 Mon Sep 17 00:00:00 2001 From: Dylan Hurd Date: Mon, 17 Nov 2025 19:21:01 -0800 Subject: [PATCH 2/5] windows tool tests --- codex-rs/core/src/tools/spec.rs | 24 ++++++++++++++++++++---- 1 file changed, 20 insertions(+), 4 deletions(-) diff --git a/codex-rs/core/src/tools/spec.rs b/codex-rs/core/src/tools/spec.rs index 28e88c3811..27ecd19a78 100644 --- a/codex-rs/core/src/tools/spec.rs +++ b/codex-rs/core/src/tools/spec.rs @@ -1292,7 +1292,11 @@ mod tests { "gpt-5-codex", &Features::with_defaults(), &[ - "shell", + if cfg!(windows) { + "shell_command" + } else { + "shell" + }, "list_mcp_resources", "list_mcp_resource_templates", "read_mcp_resource", @@ -1309,7 +1313,11 @@ mod tests { "gpt-5.1-codex", &Features::with_defaults(), &[ - "shell", + if cfg!(windows) { + "shell_command" + } else { + "shell" + }, "list_mcp_resources", "list_mcp_resource_templates", "read_mcp_resource", @@ -1384,7 +1392,11 @@ mod tests { "gpt-5.1-codex-mini", &Features::with_defaults(), &[ - "shell", + if cfg!(windows) { + "shell_command" + } else { + "shell" + }, "list_mcp_resources", "list_mcp_resource_templates", "read_mcp_resource", @@ -1401,7 +1413,11 @@ mod tests { "gpt-5.1", &Features::with_defaults(), &[ - "shell", + if cfg!(windows) { + "shell_command" + } else { + "shell" + }, "list_mcp_resources", "list_mcp_resource_templates", "read_mcp_resource", From f527613cffcffc53da8e6f1eb92b3de307ec4769 Mon Sep 17 00:00:00 2001 From: Dylan Hurd Date: Mon, 17 Nov 2025 20:38:27 -0800 Subject: [PATCH 3/5] lowercase --- codex-rs/core/src/powershell.rs | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/codex-rs/core/src/powershell.rs b/codex-rs/core/src/powershell.rs index 9526f87bdb..844866262e 100644 --- a/codex-rs/core/src/powershell.rs +++ b/codex-rs/core/src/powershell.rs @@ -3,7 +3,7 @@ use std::path::PathBuf; use crate::shell::ShellType; use crate::shell::detect_shell_type; -const POWERSHELL_FLAGS: &[&str] = &["-NoLogo", "-NoProfile", "-Command", "-c"]; +const POWERSHELL_FLAGS: &[&str] = &["-nologo", "-noprofile", "-command", "-c"]; /// Extract the PowerShell script body from an invocation such as: /// @@ -28,7 +28,7 @@ pub fn extract_powershell_command(command: &[String]) -> Option<(&str, &str)> { while i + 1 < command.len() { let flag = &command[i]; // Reject unknown flags - if !POWERSHELL_FLAGS.contains(&flag.as_str()) { + if !POWERSHELL_FLAGS.contains(&flag.to_ascii_lowercase().as_str()) { return None; } if flag.eq_ignore_ascii_case("-Command") || flag.eq_ignore_ascii_case("-c") { @@ -55,6 +55,18 @@ mod tests { assert_eq!(script, "Write-Host hi"); } + #[test] + fn extracts_lowercase_flags() { + let cmd = vec![ + "powershell".to_string(), + "-nologo".to_string(), + "-command".to_string(), + "Write-Host hi".to_string(), + ]; + let (_shell, script) = extract_powershell_command(&cmd).expect("extract"); + assert_eq!(script, "Write-Host hi"); + } + #[test] fn extracts_full_path_powershell_command() { let command = if cfg!(windows) { From 3f891f285551b9e720851cc084d848edc94faa3e Mon Sep 17 00:00:00 2001 From: Dylan Hurd Date: Mon, 17 Nov 2025 21:07:21 -0800 Subject: [PATCH 4/5] codex models only --- codex-rs/core/src/features.rs | 2 +- codex-rs/core/src/model_family.rs | 2 ++ codex-rs/core/src/tools/spec.rs | 6 +----- 3 files changed, 4 insertions(+), 6 deletions(-) diff --git a/codex-rs/core/src/features.rs b/codex-rs/core/src/features.rs index 8a7e476d03..87788d428d 100644 --- a/codex-rs/core/src/features.rs +++ b/codex-rs/core/src/features.rs @@ -259,7 +259,7 @@ pub const FEATURES: &[FeatureSpec] = &[ id: Feature::ShellCommandTool, key: "shell_command_tool", stage: Stage::Experimental, - default_enabled: cfg!(windows), + default_enabled: false, }, FeatureSpec { id: Feature::RmcpClient, diff --git a/codex-rs/core/src/model_family.rs b/codex-rs/core/src/model_family.rs index 4a10a6295f..db37119d1a 100644 --- a/codex-rs/core/src/model_family.rs +++ b/codex-rs/core/src/model_family.rs @@ -161,6 +161,7 @@ pub fn find_family_for_model(slug: &str) -> Option { "list_dir".to_string(), "read_file".to_string(), ], + shell_type: if cfg!(windows) { ConfigShellToolType::ShellCommand } else { ConfigShellToolType::Default }, supports_parallel_tool_calls: true, support_verbosity: true, ) @@ -176,6 +177,7 @@ pub fn find_family_for_model(slug: &str) -> Option { reasoning_summary_format: ReasoningSummaryFormat::Experimental, base_instructions: GPT_5_CODEX_INSTRUCTIONS.to_string(), apply_patch_tool_type: Some(ApplyPatchToolType::Freeform), + shell_type: if cfg!(windows) { ConfigShellToolType::ShellCommand } else { ConfigShellToolType::Default }, support_verbosity: false, ) } else if slug.starts_with("gpt-5.1") { diff --git a/codex-rs/core/src/tools/spec.rs b/codex-rs/core/src/tools/spec.rs index 27ecd19a78..88ddbf5517 100644 --- a/codex-rs/core/src/tools/spec.rs +++ b/codex-rs/core/src/tools/spec.rs @@ -1413,11 +1413,7 @@ mod tests { "gpt-5.1", &Features::with_defaults(), &[ - if cfg!(windows) { - "shell_command" - } else { - "shell" - }, + "shell", "list_mcp_resources", "list_mcp_resource_templates", "read_mcp_resource", From 6e492df75903003182eb982abf02e14c662cda4f Mon Sep 17 00:00:00 2001 From: Dylan Hurd Date: Mon, 17 Nov 2025 21:23:52 -0800 Subject: [PATCH 5/5] fix model_tools test --- codex-rs/core/tests/suite/model_tools.rs | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/codex-rs/core/tests/suite/model_tools.rs b/codex-rs/core/tests/suite/model_tools.rs index 42bc655f57..28593a55b8 100644 --- a/codex-rs/core/tests/suite/model_tools.rs +++ b/codex-rs/core/tests/suite/model_tools.rs @@ -119,7 +119,12 @@ async fn model_selects_expected_tools() { assert_eq!( gpt5_codex_tools, vec![ - "shell".to_string(), + if cfg!(windows) { + "shell_command" + } else { + "shell" + } + .to_string(), "list_mcp_resources".to_string(), "list_mcp_resource_templates".to_string(), "read_mcp_resource".to_string(), @@ -133,7 +138,12 @@ async fn model_selects_expected_tools() { assert_eq!( gpt51_codex_tools, vec![ - "shell".to_string(), + if cfg!(windows) { + "shell_command" + } else { + "shell" + } + .to_string(), "list_mcp_resources".to_string(), "list_mcp_resource_templates".to_string(), "read_mcp_resource".to_string(),