Add Windows hook command overrides (#22159)

# Why

Managed hook configs need a shared cross-platform shape without making
the existing `command` field polymorphic. The common case is still one
command string, with Windows needing a different entrypoint only when
the runtime is actually Windows.

Keeping `command` as the portable/default path and adding an optional
Windows override keeps the config easier to read, preserves the existing
scalar shape for non-Windows users, and avoids forcing every caller into
a `{ unix, windows }` object when only one platform needs special
handling.

# What

- Add optional `command_windows` / `commandWindows` alongside the
existing hook `command` field.
- Resolve `command_windows` only on Windows during hook discovery; other
platforms continue to use `command` unchanged.
- Keep trust hashing aligned to the effective command selected for the
current runtime.

# Docs

The Codex hooks/config reference should document `command_windows` as
the Windows-only override for command hooks.
This commit is contained in:
Abhinav
2026-05-11 18:22:29 -04:00
committed by GitHub
Unverified
parent a175ddacc0
commit 9ab7f4e6ac
16 changed files with 245 additions and 3 deletions
@@ -7724,6 +7724,12 @@
"command": {
"type": "string"
},
"commandWindows": {
"type": [
"string",
"null"
]
},
"statusMessage": {
"type": [
"string",
@@ -4113,6 +4113,12 @@
"command": {
"type": "string"
},
"commandWindows": {
"type": [
"string",
"null"
]
},
"statusMessage": {
"type": [
"string",
@@ -121,6 +121,12 @@
"command": {
"type": "string"
},
"commandWindows": {
"type": [
"string",
"null"
]
},
"statusMessage": {
"type": [
"string",
@@ -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", };
@@ -413,6 +413,9 @@ pub enum ConfiguredHookHandler {
#[ts(rename = "command")]
Command {
command: String,
#[serde(rename = "commandWindows")]
#[ts(rename = "commandWindows")]
command_windows: Option<String>,
#[serde(rename = "timeoutSec")]
#[ts(rename = "timeoutSec")]
timeout_sec: Option<u64>,
@@ -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,
@@ -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),
+2
View File
@@ -126,6 +126,8 @@ pub enum HookHandlerConfig {
#[serde(rename = "command")]
Command {
command: String,
#[serde(default, rename = "commandWindows", alias = "command_windows")]
command_windows: Option<String>,
#[serde(default, rename = "timeout")]
timeout_sec: Option<u64>,
#[serde(default)]
+74
View File
@@ -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()
}
);
}
+4
View File
@@ -969,6 +969,10 @@
"command": {
"type": "string"
},
"commandWindows": {
"default": null,
"type": "string"
},
"statusMessage": {
"default": null,
"type": "string"
@@ -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()),
+1
View File
@@ -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,
+1
View File
@@ -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,
+48
View File
@@ -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::<String>::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": {
+88 -2
View File
@@ -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,
+1
View File
@@ -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()),