diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json index 215842d929..4e061f8591 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json @@ -7724,6 +7724,12 @@ "command": { "type": "string" }, + "commandWindows": { + "type": [ + "string", + "null" + ] + }, "statusMessage": { "type": [ "string", diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json index 085744e819..c7130126f9 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json @@ -4113,6 +4113,12 @@ "command": { "type": "string" }, + "commandWindows": { + "type": [ + "string", + "null" + ] + }, "statusMessage": { "type": [ "string", diff --git a/codex-rs/app-server-protocol/schema/json/v2/ConfigRequirementsReadResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ConfigRequirementsReadResponse.json index 14a8d572d6..b229d8ef94 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ConfigRequirementsReadResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ConfigRequirementsReadResponse.json @@ -121,6 +121,12 @@ "command": { "type": "string" }, + "commandWindows": { + "type": [ + "string", + "null" + ] + }, "statusMessage": { "type": [ "string", diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ConfiguredHookHandler.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ConfiguredHookHandler.ts index a81ce61f6e..42b05cf8cc 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/ConfiguredHookHandler.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ConfiguredHookHandler.ts @@ -2,4 +2,4 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -export type ConfiguredHookHandler = { "type": "command", command: string, timeoutSec: bigint | null, async: boolean, statusMessage: string | null, } | { "type": "prompt", } | { "type": "agent", }; +export type ConfiguredHookHandler = { "type": "command", command: string, commandWindows: string | null, timeoutSec: bigint | null, async: boolean, statusMessage: string | null, } | { "type": "prompt", } | { "type": "agent", }; diff --git a/codex-rs/app-server-protocol/src/protocol/v2/config.rs b/codex-rs/app-server-protocol/src/protocol/v2/config.rs index 8bc50bb1f2..35e487e45e 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/config.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/config.rs @@ -413,6 +413,9 @@ pub enum ConfiguredHookHandler { #[ts(rename = "command")] Command { command: String, + #[serde(rename = "commandWindows")] + #[ts(rename = "commandWindows")] + command_windows: Option, #[serde(rename = "timeoutSec")] #[ts(rename = "timeoutSec")] timeout_sec: Option, diff --git a/codex-rs/app-server/src/request_processors/config_processor.rs b/codex-rs/app-server/src/request_processors/config_processor.rs index 40985f78a2..5365030d6b 100644 --- a/codex-rs/app-server/src/request_processors/config_processor.rs +++ b/codex-rs/app-server/src/request_processors/config_processor.rs @@ -515,11 +515,13 @@ fn map_hook_handler_to_api(handler: CoreHookHandlerConfig) -> ConfiguredHookHand match handler { CoreHookHandlerConfig::Command { command, + command_windows, timeout_sec, r#async, status_message, } => ConfiguredHookHandler::Command { command, + command_windows, timeout_sec, r#async, status_message, diff --git a/codex-rs/app-server/tests/suite/v2/hooks_list.rs b/codex-rs/app-server/tests/suite/v2/hooks_list.rs index 623896626c..f3ce4cbf2d 100644 --- a/codex-rs/app-server/tests/suite/v2/hooks_list.rs +++ b/codex-rs/app-server/tests/suite/v2/hooks_list.rs @@ -53,6 +53,7 @@ fn command_hook_hash( matcher: matcher.map(ToOwned::to_owned), hooks: vec![codex_config::HookHandlerConfig::Command { command: command.to_string(), + command_windows: None, timeout_sec: Some(timeout_sec), r#async: false, status_message: status_message.map(ToOwned::to_owned), diff --git a/codex-rs/config/src/hook_config.rs b/codex-rs/config/src/hook_config.rs index 630d18c569..869761bdaa 100644 --- a/codex-rs/config/src/hook_config.rs +++ b/codex-rs/config/src/hook_config.rs @@ -126,6 +126,8 @@ pub enum HookHandlerConfig { #[serde(rename = "command")] Command { command: String, + #[serde(default, rename = "commandWindows", alias = "command_windows")] + command_windows: Option, #[serde(default, rename = "timeout")] timeout_sec: Option, #[serde(default)] diff --git a/codex-rs/config/src/hooks_tests.rs b/codex-rs/config/src/hooks_tests.rs index 69fcd3fe95..915bec7d0c 100644 --- a/codex-rs/config/src/hooks_tests.rs +++ b/codex-rs/config/src/hooks_tests.rs @@ -40,6 +40,7 @@ fn hooks_file_deserializes_existing_json_shape() { matcher: Some("^Bash$".to_string()), hooks: vec![HookHandlerConfig::Command { command: "python3 /tmp/pre.py".to_string(), + command_windows: None, timeout_sec: Some(10), r#async: false, status_message: Some("checking".to_string()), @@ -74,6 +75,7 @@ statusMessage = "checking" matcher: Some("^Bash$".to_string()), hooks: vec![HookHandlerConfig::Command { command: "python3 /tmp/pre.py".to_string(), + command_windows: None, timeout_sec: Some(10), r#async: false, status_message: Some("checking".to_string()), @@ -110,6 +112,7 @@ command = "python3 /tmp/pre.py" matcher: Some("^Bash$".to_string()), hooks: vec![HookHandlerConfig::Command { command: "python3 /tmp/pre.py".to_string(), + command_windows: None, timeout_sec: None, r#async: false, status_message: None, @@ -154,6 +157,7 @@ command = "python3 /enterprise/place/pre.py" matcher: Some("^Bash$".to_string()), hooks: vec![HookHandlerConfig::Command { command: "python3 /enterprise/place/pre.py".to_string(), + command_windows: None, timeout_sec: None, r#async: false, status_message: None, @@ -164,3 +168,73 @@ command = "python3 /enterprise/place/pre.py" } ); } + +#[test] +fn hook_events_deserialize_windows_override_from_toml() { + let parsed: HookEventsToml = toml::from_str( + r#" +[[PreToolUse]] +matcher = "^Bash$" + +[[PreToolUse.hooks]] +type = "command" +command = "bash /enterprise/hooks/pre.sh" +command_windows = "powershell -File C:\\enterprise\\hooks\\pre.ps1" +"#, + ) + .expect("hook command Windows override TOML should deserialize"); + + assert_eq!( + parsed, + HookEventsToml { + pre_tool_use: vec![MatcherGroup { + matcher: Some("^Bash$".to_string()), + hooks: vec![HookHandlerConfig::Command { + command: "bash /enterprise/hooks/pre.sh".to_string(), + command_windows: Some( + r"powershell -File C:\enterprise\hooks\pre.ps1".to_string(), + ), + timeout_sec: None, + r#async: false, + status_message: None, + }], + }], + ..Default::default() + } + ); +} + +#[test] +fn hook_events_deserialize_camel_case_windows_override_from_toml() { + let parsed: HookEventsToml = toml::from_str( + r#" +[[PreToolUse]] +matcher = "^Bash$" + +[[PreToolUse.hooks]] +type = "command" +command = "bash /enterprise/hooks/pre.sh" +commandWindows = "powershell -File C:\\enterprise\\hooks\\pre.ps1" +"#, + ) + .expect("camelCase hook command Windows override TOML should deserialize"); + + assert_eq!( + parsed, + HookEventsToml { + pre_tool_use: vec![MatcherGroup { + matcher: Some("^Bash$".to_string()), + hooks: vec![HookHandlerConfig::Command { + command: "bash /enterprise/hooks/pre.sh".to_string(), + command_windows: Some( + r"powershell -File C:\enterprise\hooks\pre.ps1".to_string(), + ), + timeout_sec: None, + r#async: false, + status_message: None, + }], + }], + ..Default::default() + } + ); +} diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index 6c1eebeab7..1f17bd0282 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -969,6 +969,10 @@ "command": { "type": "string" }, + "commandWindows": { + "default": null, + "type": "string" + }, "statusMessage": { "default": null, "type": "string" diff --git a/codex-rs/core/src/config/config_loader_tests.rs b/codex-rs/core/src/config/config_loader_tests.rs index 4a7a33b7e6..6296d13886 100644 --- a/codex-rs/core/src/config/config_loader_tests.rs +++ b/codex-rs/core/src/config/config_loader_tests.rs @@ -1157,6 +1157,7 @@ async fn load_config_layers_includes_cloud_hook_requirements() -> anyhow::Result matcher: Some("^Bash$".to_string()), hooks: vec![codex_config::HookHandlerConfig::Command { command: format!("python3 {}/pre.py", managed_dir.display()), + command_windows: None, timeout_sec: Some(10), r#async: false, status_message: Some("checking".to_string()), diff --git a/codex-rs/core/src/session/tests.rs b/codex-rs/core/src/session/tests.rs index 6511ce7a76..9574cae30b 100644 --- a/codex-rs/core/src/session/tests.rs +++ b/codex-rs/core/src/session/tests.rs @@ -1313,6 +1313,7 @@ async fn refresh_runtime_config_refreshes_hooks() -> anyhow::Result<()> { matcher: None, hooks: vec![codex_config::HookHandlerConfig::Command { command: "python3 /tmp/user.py".to_string(), + command_windows: None, timeout_sec: Some(600), r#async: false, status_message: None, diff --git a/codex-rs/hooks/src/declarations.rs b/codex-rs/hooks/src/declarations.rs index 6c414eaf81..468fbc947a 100644 --- a/codex-rs/hooks/src/declarations.rs +++ b/codex-rs/hooks/src/declarations.rs @@ -65,6 +65,7 @@ mod tests { HookHandlerConfig::Prompt {}, HookHandlerConfig::Command { command: "echo hi".to_string(), + command_windows: None, timeout_sec: None, r#async: false, status_message: None, diff --git a/codex-rs/hooks/src/engine/discovery.rs b/codex-rs/hooks/src/engine/discovery.rs index cc180325b6..8dac67e30c 100644 --- a/codex-rs/hooks/src/engine/discovery.rs +++ b/codex-rs/hooks/src/engine/discovery.rs @@ -388,10 +388,16 @@ fn append_matcher_groups( match handler { HookHandlerConfig::Command { command, + command_windows, timeout_sec, r#async, status_message, } => { + let command = if cfg!(windows) { + command_windows.unwrap_or(command) + } else { + command + }; if r#async { warnings.push(format!( "skipping async hook in {}: async hooks are not supported yet", @@ -409,6 +415,7 @@ fn append_matcher_groups( let timeout_sec = timeout_sec.unwrap_or(600).max(1); let normalized_handler = HookHandlerConfig::Command { command: command.clone(), + command_windows: None, timeout_sec: Some(timeout_sec), r#async, status_message: status_message.clone(), @@ -608,6 +615,7 @@ mod tests { matcher: matcher.map(str::to_string), hooks: vec![HookHandlerConfig::Command { command: "echo hello".to_string(), + command_windows: None, timeout_sec: None, r#async: false, status_message: None, @@ -753,6 +761,7 @@ mod tests { matcher: None, hooks: vec![HookHandlerConfig::Command { command: "echo hello".to_string(), + command_windows: None, timeout_sec: None, r#async: false, status_message: None, @@ -763,6 +772,45 @@ mod tests { ); } + #[test] + fn pre_tool_use_resolves_windows_command_override_during_discovery() { + let mut handlers = Vec::new(); + let mut warnings = Vec::new(); + let mut display_order = 0; + let source_path = source_path(); + let hook_states = std::collections::HashMap::new(); + + append_matcher_groups( + &mut handlers, + &mut Vec::new(), + &mut warnings, + &mut display_order, + &hook_handler_source(&source_path, &hook_states), + HookEventName::PreToolUse, + vec![MatcherGroup { + matcher: Some("^Bash$".to_string()), + hooks: vec![HookHandlerConfig::Command { + command: "echo unix".to_string(), + command_windows: Some("echo windows".to_string()), + timeout_sec: None, + r#async: false, + status_message: None, + }], + }], + ); + + assert_eq!(warnings, Vec::::new()); + assert_eq!(handlers.len(), 1); + assert_eq!( + handlers[0].command, + if cfg!(windows) { + "echo windows" + } else { + "echo unix" + } + ); + } + fn config_with_malformed_state_and_session_start_hook() -> TomlValue { serde_json::from_value(serde_json::json!({ "hooks": { diff --git a/codex-rs/hooks/src/engine/mod_tests.rs b/codex-rs/hooks/src/engine/mod_tests.rs index 32739165f1..cb56826900 100644 --- a/codex-rs/hooks/src/engine/mod_tests.rs +++ b/codex-rs/hooks/src/engine/mod_tests.rs @@ -19,6 +19,7 @@ use codex_config::TomlValue; use codex_plugin::PluginHookSource; use codex_plugin::PluginId; use codex_protocol::ThreadId; +use codex_protocol::protocol::HookOutputEntry; use codex_protocol::protocol::HookOutputEntryKind; use codex_protocol::protocol::HookRunStatus; use codex_protocol::protocol::HookSource; @@ -85,6 +86,7 @@ with Path(r"{log_path}").open("a", encoding="utf-8") as handle: matcher: Some("^Bash$".to_string()), hooks: vec![HookHandlerConfig::Command { command: format!("python3 {}", script_path.display()), + command_windows: None, timeout_sec: Some(10), r#async: false, status_message: Some("checking".to_string()), @@ -169,6 +171,84 @@ with Path(r"{log_path}").open("a", encoding="utf-8") as handle: assert!(log_contents.contains("\"hook_event_name\": \"PreToolUse\"")); } +#[tokio::test] +async fn requirements_managed_hooks_execute_windows_command_override() { + let temp = tempdir().expect("create temp dir"); + let managed_dir = + AbsolutePathBuf::try_from(temp.path().join("managed-hooks")).expect("absolute path"); + fs::create_dir_all(managed_dir.as_path()).expect("create managed hooks dir"); + + let managed_hooks = managed_hooks_for_current_platform( + managed_dir, + HookEventsToml { + pre_tool_use: vec![MatcherGroup { + matcher: Some("^Bash$".to_string()), + hooks: vec![HookHandlerConfig::Command { + command: "exit 17".to_string(), + command_windows: Some("exit /B 19".to_string()), + timeout_sec: Some(10), + r#async: false, + status_message: Some("checking".to_string()), + }], + }], + ..Default::default() + }, + ); + let config_layer_stack = ConfigLayerStack::new( + Vec::new(), + ConfigRequirements { + managed_hooks: Some(ConstrainedWithSource::new( + Constrained::allow_any(managed_hooks.clone()), + Some(RequirementSource::CloudRequirements), + )), + ..ConfigRequirements::default() + }, + ConfigRequirementsToml { + hooks: Some(managed_hooks), + ..ConfigRequirementsToml::default() + }, + ) + .expect("config layer stack"); + + let engine = ClaudeHooksEngine::new( + /*enabled*/ true, + Some(&config_layer_stack), + Vec::new(), + Vec::new(), + CommandShell { + program: String::new(), + args: Vec::new(), + }, + ); + + let outcome = engine + .run_pre_tool_use(PreToolUseRequest { + session_id: ThreadId::new(), + turn_id: "turn-1".to_string(), + cwd: cwd(), + transcript_path: None, + model: "gpt-test".to_string(), + permission_mode: "default".to_string(), + tool_name: "Bash".to_string(), + matcher_aliases: Vec::new(), + tool_use_id: "tool-1".to_string(), + tool_input: serde_json::json!({ "command": "echo hello" }), + }) + .await; + + assert!(!outcome.should_block); + let expected_exit_code = if cfg!(windows) { 19 } else { 17 }; + assert_eq!(outcome.hook_events.len(), 1); + assert_eq!(outcome.hook_events[0].run.status, HookRunStatus::Failed); + assert_eq!( + outcome.hook_events[0].run.entries, + vec![HookOutputEntry { + kind: HookOutputEntryKind::Error, + text: format!("hook exited with code {expected_exit_code}"), + }] + ); +} + #[test] fn unknown_requirement_source_hooks_stay_managed() { let temp = tempdir().expect("create temp dir"); @@ -182,6 +262,7 @@ fn unknown_requirement_source_hooks_stay_managed() { matcher: Some("^Bash$".to_string()), hooks: vec![HookHandlerConfig::Command { command: "python3 /tmp/managed.py".to_string(), + command_windows: None, timeout_sec: Some(10), r#async: false, status_message: Some("checking".to_string()), @@ -244,6 +325,7 @@ fn user_disablement_filters_non_managed_hooks_but_not_managed_hooks() { matcher: Some("^Bash$".to_string()), hooks: vec![HookHandlerConfig::Command { command: "python3 /tmp/managed.py".to_string(), + command_windows: None, timeout_sec: Some(10), r#async: false, status_message: Some("checking".to_string()), @@ -463,6 +545,7 @@ fn requirements_managed_hooks_warn_when_managed_dir_is_missing() { matcher: Some("^Bash$".to_string()), hooks: vec![HookHandlerConfig::Command { command: format!("python3 {}", missing_dir.join("pre.py").display()), + command_windows: None, timeout_sec: Some(10), r#async: false, status_message: Some("checking".to_string()), @@ -674,6 +757,7 @@ print(json.dumps({ matcher: Some("Bash".to_string()), hooks: vec![HookHandlerConfig::Command { command: format!("python3 {}", script_path.display()), + command_windows: None, timeout_sec: Some(10), r#async: false, status_message: None, @@ -780,8 +864,10 @@ fn plugin_hook_sources_expand_plugin_placeholders() { pre_tool_use: vec![MatcherGroup { matcher: Some("Bash".to_string()), hooks: vec![HookHandlerConfig::Command { - command: "run ${PLUGIN_ROOT} ${CLAUDE_PLUGIN_ROOT} ${PLUGIN_DATA} ${CLAUDE_PLUGIN_DATA}" - .to_string(), + command: + "run ${PLUGIN_ROOT} ${CLAUDE_PLUGIN_ROOT} ${PLUGIN_DATA} ${CLAUDE_PLUGIN_DATA}" + .to_string(), + command_windows: None, timeout_sec: Some(5), r#async: false, status_message: None, diff --git a/codex-rs/tui/src/debug_config.rs b/codex-rs/tui/src/debug_config.rs index 1d4bf2b858..3f5b53266c 100644 --- a/codex-rs/tui/src/debug_config.rs +++ b/codex-rs/tui/src/debug_config.rs @@ -930,6 +930,7 @@ approval_policy = "never" matcher: Some("^Bash$".to_string()), hooks: vec![HookHandlerConfig::Command { command: "python3 /enterprise/hooks/pre.py".to_string(), + command_windows: None, timeout_sec: Some(10), r#async: false, status_message: Some("checking".to_string()),