diff --git a/codex-rs/hooks/src/engine/command_runner.rs b/codex-rs/hooks/src/engine/command_runner.rs index 9f4c4e6059..e64d68e4f7 100644 --- a/codex-rs/hooks/src/engine/command_runner.rs +++ b/codex-rs/hooks/src/engine/command_runner.rs @@ -168,9 +168,22 @@ fn build_command(shell: &CommandShell, handler: &ConfiguredHandler) -> Command { Command::new(&shell.program) }; if shell.program.is_empty() { + #[cfg(windows)] + command.raw_arg(format!(r#""{}""#, handler.command)); + + #[cfg(not(windows))] command.arg(&handler.command); } else { command.args(&shell.args); + + #[cfg(windows)] + if shell.args.iter().any(|arg| arg.eq_ignore_ascii_case("/c")) { + command.raw_arg(format!(r#""{}""#, handler.command)); + } else { + command.arg(&handler.command); + } + + #[cfg(not(windows))] command.arg(&handler.command); } command.envs(&handler.env); @@ -194,3 +207,7 @@ fn default_shell_command() -> Command { command } } + +#[cfg(all(test, windows))] +#[path = "command_runner_tests.rs"] +mod tests; diff --git a/codex-rs/hooks/src/engine/command_runner_tests.rs b/codex-rs/hooks/src/engine/command_runner_tests.rs new file mode 100644 index 0000000000..ddd26d52b5 --- /dev/null +++ b/codex-rs/hooks/src/engine/command_runner_tests.rs @@ -0,0 +1,63 @@ +use std::collections::HashMap; +use std::fs; + +use codex_protocol::protocol::HookEventName; +use codex_protocol::protocol::HookSource; +use codex_utils_absolute_path::AbsolutePathBuf; +use pretty_assertions::assert_eq; +use tempfile::tempdir; + +use super::CommandShell; +use super::ConfiguredHandler; +use super::run_command; + +#[tokio::test] +async fn cmd_shell_runs_quoted_hook_command_path() { + let temp = tempdir().expect("create temp dir"); + let hook_dir = temp.path().join("hook with spaces"); + fs::create_dir(&hook_dir).expect("create hook dir"); + let hook_path = hook_dir.join("hook.cmd"); + fs::write( + &hook_path, + "@echo off\r\nif not \"%~1\"==\"notify\" exit /B 7\r\necho hook-ran\r\n", + ) + .expect("write hook command"); + let source_path = + AbsolutePathBuf::try_from(hook_path.clone()).expect("absolute hook command path"); + let handler = ConfiguredHandler { + event_name: HookEventName::SessionStart, + matcher: None, + command: format!(r#""{}" notify"#, hook_path.display()), + timeout_sec: 10, + status_message: None, + source_path, + source: HookSource::User, + display_order: 0, + env: HashMap::new(), + }; + let shells = [ + CommandShell { + program: String::new(), + args: Vec::new(), + }, + CommandShell { + program: std::env::var("COMSPEC").unwrap_or_else(|_| "cmd.exe".to_string()), + args: vec!["/c".to_string()], + }, + ]; + + for shell in shells { + let result = run_command( + &shell, + &handler, + /*configured_order*/ 0, + "{}", + temp.path(), + ) + .await; + + assert_eq!(result.exit_code, Some(0), "stderr: {}", result.stderr); + assert_eq!(result.stdout.trim(), "hook-ran"); + assert!(result.error.is_none()); + } +}