hooks: emit Bash PostToolUse when exec_command completes via write_stdin (#18888)

Fixes #16246.

## Why

`exec_command` already emits `PreToolUse`, but long-running unified exec
commands that finish on a later `write_stdin` poll could miss the
matching `PostToolUse`. That left the Bash hook lifecycle inconsistent,
broke expectations around `tool_use_id` and `tool_input.command`, and
meant `PostToolUse` block/replacement feedback could fail to replace the
final session output before it reached model context.

This keeps the fix scoped to the `exec_command` / `write_stdin`
lifecycle. Broader non-Bash hook expansion is still out of scope here
and remains tracked separately in #16732.

## What changed

- Compute and store `PostToolUsePayload` while handlers still have
access to their concrete output type, and carry `tool_use_id` through
that payload.
- Preserve the original hook-facing `exec_command` string through
unified exec state (`ExecCommandRequest`, `ProcessEntry`,
`PreparedProcessHandles`, and `ExecCommandToolOutput`) via
`hook_command`, and remove the now-unused `session_command` output
metadata.
- Emit exactly one Bash `PostToolUse` for long-running `exec_command`
sessions when a later `write_stdin` poll observes final completion,
using the original `exec_command` call id and hook-facing command.
- Keep one-shot `exec_command` behavior aligned with the same payload
construction, including interactive completions that return a final
result directly.
- Apply `PostToolUse` block/replacement feedback before the final
`write_stdin` completion output is sent back to the model.
- Keep `write_stdin` itself out of `PreToolUse` matching so it continues
to act as transport/polling for the original Bash tool call.
- Restore plain matcher behavior for tool-name matchers such as `Bash`
and `Edit|Write`, while still treating patterns with regex characters
(for example `mcp__.*`) as regexes.
- Add unit coverage for unified exec payload construction and parallel
session separation, plus a core integration regression that verifies a
blocked `PostToolUse` replaces the final `write_stdin` output in model
context.

## Testing

- `cargo test -p codex-hooks`
- `cargo test -p codex-core post_tool_use_payload`
- `cargo test -p codex-core
post_tool_use_blocks_when_exec_session_completes_via_write_stdin`
This commit is contained in:
Andrei Eternal
2026-04-22 17:14:22 -07:00
committed by GitHub
Unverified
parent 6ca038bbd1
commit eed0e07825
15 changed files with 345 additions and 78 deletions
+2 -2
View File
@@ -370,7 +370,7 @@ pub struct ExecCommandToolOutput {
pub process_id: Option<i32>,
pub exit_code: Option<i32>,
pub original_token_count: Option<usize>,
pub session_command: Option<Vec<String>>,
pub hook_command: Option<String>,
}
impl ToolOutput for ExecCommandToolOutput {
@@ -394,7 +394,7 @@ impl ToolOutput for ExecCommandToolOutput {
}
fn post_tool_use_response(&self, _call_id: &str, _payload: &ToolPayload) -> Option<JsonValue> {
if self.process_id.is_some() || self.session_command.is_none() {
if self.process_id.is_some() || self.hook_command.is_none() {
return None;
}
+1 -1
View File
@@ -390,7 +390,7 @@ fn exec_command_tool_output_formats_truncated_response() {
process_id: None,
exit_code: Some(0),
original_token_count: Some(10),
session_command: None,
hook_command: None,
}
.to_response_item("call-42", &payload);
@@ -324,11 +324,12 @@ impl ToolHandler for ApplyPatchHandler {
&self,
call_id: &str,
payload: &ToolPayload,
result: &dyn ToolOutput,
result: &Self::Output,
) -> Option<PostToolUsePayload> {
let tool_response = result.post_tool_use_response(call_id, payload)?;
Some(PostToolUsePayload {
tool_name: HookToolName::apply_patch(),
tool_use_id: call_id.to_string(),
command: apply_patch_payload_command(payload)?,
tool_response,
})
@@ -90,6 +90,7 @@ fn post_tool_use_payload_uses_patch_input_and_tool_output() {
handler.post_tool_use_payload("call-apply-patch", &payload, &output),
Some(PostToolUsePayload {
tool_name: HookToolName::apply_patch(),
tool_use_id: "call-apply-patch".to_string(),
command: patch.to_string(),
tool_response: json!("Success. Updated files."),
})
+4 -2
View File
@@ -216,11 +216,12 @@ impl ToolHandler for ShellHandler {
&self,
call_id: &str,
payload: &ToolPayload,
result: &dyn ToolOutput,
result: &Self::Output,
) -> Option<PostToolUsePayload> {
let tool_response = result.post_tool_use_response(call_id, payload)?;
Some(PostToolUsePayload {
tool_name: HookToolName::bash(),
tool_use_id: call_id.to_string(),
command: shell_payload_command(payload)?,
tool_response,
})
@@ -328,11 +329,12 @@ impl ToolHandler for ShellCommandHandler {
&self,
call_id: &str,
payload: &ToolPayload,
result: &dyn ToolOutput,
result: &Self::Output,
) -> Option<PostToolUsePayload> {
let tool_response = result.post_tool_use_response(call_id, payload)?;
Some(PostToolUsePayload {
tool_name: HookToolName::bash(),
tool_use_id: call_id.to_string(),
command: shell_command_payload_command(payload)?,
tool_response,
})
@@ -284,6 +284,7 @@ fn build_post_tool_use_payload_uses_tool_output_wire_value() {
handler.post_tool_use_payload("call-42", &payload, &output),
Some(crate::tools::registry::PostToolUsePayload {
tool_name: HookToolName::bash(),
tool_use_id: "call-42".to_string(),
command: "printf shell command".to_string(),
tool_response: json!("shell output"),
})
@@ -147,21 +147,23 @@ impl ToolHandler for UnifiedExecHandler {
&self,
call_id: &str,
payload: &ToolPayload,
result: &dyn ToolOutput,
result: &Self::Output,
) -> Option<PostToolUsePayload> {
let ToolPayload::Function { arguments } = payload else {
let ToolPayload::Function { .. } = payload else {
return None;
};
let args = parse_arguments::<ExecCommandArgs>(arguments).ok()?;
if args.tty {
return None;
}
let tool_response = result.post_tool_use_response(call_id, payload)?;
let command = result.hook_command.clone()?;
let tool_use_id = if result.event_call_id.is_empty() {
call_id.to_string()
} else {
result.event_call_id.clone()
};
let tool_response = result.post_tool_use_response(&tool_use_id, payload)?;
Some(PostToolUsePayload {
tool_name: HookToolName::bash(),
command: args.cmd,
tool_use_id,
command,
tool_response,
})
}
@@ -200,11 +202,12 @@ impl ToolHandler for UnifiedExecHandler {
"exec_command" => {
let cwd = resolve_workdir_base_path(&arguments, &context.turn.cwd)?;
let args: ExecCommandArgs = parse_arguments_with_base_path(&arguments, &cwd)?;
let hook_command = args.cmd.clone();
let workdir = context.turn.resolve_path(args.workdir.clone());
maybe_emit_implicit_skill_invocation(
session.as_ref(),
context.turn.as_ref(),
&args.cmd,
&hook_command,
&workdir,
)
.await;
@@ -313,17 +316,16 @@ impl ToolHandler for UnifiedExecHandler {
process_id: None,
exit_code: None,
original_token_count: None,
session_command: None,
hook_command: None,
});
}
emit_unified_exec_tty_metric(&turn.session_telemetry, tty);
let session_command = command.clone();
match manager
.exec_command(
ExecCommandRequest {
command,
hook_command: args.cmd,
hook_command: hook_command.clone(),
process_id,
yield_time_ms,
max_output_tokens,
@@ -357,7 +359,7 @@ impl ToolHandler for UnifiedExecHandler {
process_id: None,
exit_code: Some(output.exit_code),
original_token_count: Some(original_token_count),
session_command: Some(session_command),
hook_command: Some(hook_command),
}
}
Err(err) => {
@@ -252,7 +252,7 @@ fn exec_command_post_tool_use_payload_uses_output_for_noninteractive_one_shot_co
arguments: serde_json::json!({ "cmd": "echo three", "tty": false }).to_string(),
};
let output = ExecCommandToolOutput {
event_call_id: "event-43".to_string(),
event_call_id: "call-43".to_string(),
chunk_id: "chunk-1".to_string(),
wall_time: std::time::Duration::from_millis(498),
raw_output: b"three".to_vec(),
@@ -260,17 +260,14 @@ fn exec_command_post_tool_use_payload_uses_output_for_noninteractive_one_shot_co
process_id: None,
exit_code: Some(0),
original_token_count: None,
session_command: Some(vec![
"/bin/zsh".to_string(),
"-lc".to_string(),
"echo three".to_string(),
]),
hook_command: Some("echo three".to_string()),
};
assert_eq!(
UnifiedExecHandler.post_tool_use_payload("call-43", &payload, &output),
Some(crate::tools::registry::PostToolUsePayload {
tool_name: HookToolName::bash(),
tool_use_id: "call-43".to_string(),
command: "echo three".to_string(),
tool_response: serde_json::json!("three"),
})
@@ -278,12 +275,12 @@ fn exec_command_post_tool_use_payload_uses_output_for_noninteractive_one_shot_co
}
#[test]
fn exec_command_post_tool_use_payload_skips_interactive_exec() {
fn exec_command_post_tool_use_payload_uses_output_for_interactive_completion() {
let payload = ToolPayload::Function {
arguments: serde_json::json!({ "cmd": "echo three", "tty": true }).to_string(),
};
let output = ExecCommandToolOutput {
event_call_id: "event-44".to_string(),
event_call_id: "call-44".to_string(),
chunk_id: "chunk-1".to_string(),
wall_time: std::time::Duration::from_millis(498),
raw_output: b"three".to_vec(),
@@ -291,16 +288,17 @@ fn exec_command_post_tool_use_payload_skips_interactive_exec() {
process_id: None,
exit_code: Some(0),
original_token_count: None,
session_command: Some(vec![
"/bin/zsh".to_string(),
"-lc".to_string(),
"echo three".to_string(),
]),
hook_command: Some("echo three".to_string()),
};
assert_eq!(
UnifiedExecHandler.post_tool_use_payload("call-44", &payload, &output),
None
Some(crate::tools::registry::PostToolUsePayload {
tool_name: HookToolName::bash(),
tool_use_id: "call-44".to_string(),
command: "echo three".to_string(),
tool_response: serde_json::json!("three"),
})
);
}
@@ -318,11 +316,7 @@ fn exec_command_post_tool_use_payload_skips_running_sessions() {
process_id: Some(45),
exit_code: None,
original_token_count: None,
session_command: Some(vec![
"/bin/zsh".to_string(),
"-lc".to_string(),
"echo three".to_string(),
]),
hook_command: Some("echo three".to_string()),
};
assert_eq!(
@@ -330,3 +324,87 @@ fn exec_command_post_tool_use_payload_skips_running_sessions() {
None
);
}
#[test]
fn write_stdin_post_tool_use_payload_uses_original_exec_call_id_and_command_on_completion() {
let payload = ToolPayload::Function {
arguments: serde_json::json!({
"session_id": 45,
"chars": "",
})
.to_string(),
};
let output = ExecCommandToolOutput {
event_call_id: "exec-call-45".to_string(),
chunk_id: "chunk-2".to_string(),
wall_time: std::time::Duration::from_millis(498),
raw_output: b"finished\n".to_vec(),
max_output_tokens: None,
process_id: None,
exit_code: Some(0),
original_token_count: None,
hook_command: Some("sleep 1; echo finished".to_string()),
};
assert_eq!(
UnifiedExecHandler.post_tool_use_payload("write-stdin-call", &payload, &output),
Some(crate::tools::registry::PostToolUsePayload {
tool_name: HookToolName::bash(),
tool_use_id: "exec-call-45".to_string(),
command: "sleep 1; echo finished".to_string(),
tool_response: serde_json::json!("finished\n"),
})
);
}
#[test]
fn write_stdin_post_tool_use_payload_keeps_parallel_session_metadata_separate() {
let payload = ToolPayload::Function {
arguments: serde_json::json!({ "session_id": 45, "chars": "" }).to_string(),
};
let output_a = ExecCommandToolOutput {
event_call_id: "exec-call-a".to_string(),
chunk_id: "chunk-a".to_string(),
wall_time: std::time::Duration::from_millis(498),
raw_output: b"alpha\n".to_vec(),
max_output_tokens: None,
process_id: None,
exit_code: Some(0),
original_token_count: None,
hook_command: Some("sleep 2; echo alpha".to_string()),
};
let output_b = ExecCommandToolOutput {
event_call_id: "exec-call-b".to_string(),
chunk_id: "chunk-b".to_string(),
wall_time: std::time::Duration::from_millis(498),
raw_output: b"beta\n".to_vec(),
max_output_tokens: None,
process_id: None,
exit_code: Some(0),
original_token_count: None,
hook_command: Some("sleep 1; echo beta".to_string()),
};
let payloads = [
UnifiedExecHandler.post_tool_use_payload("write-call-b", &payload, &output_b),
UnifiedExecHandler.post_tool_use_payload("write-call-a", &payload, &output_a),
];
assert_eq!(
payloads,
[
Some(crate::tools::registry::PostToolUsePayload {
tool_name: HookToolName::bash(),
tool_use_id: "exec-call-b".to_string(),
command: "sleep 1; echo beta".to_string(),
tool_response: serde_json::json!("beta\n"),
}),
Some(crate::tools::registry::PostToolUsePayload {
tool_name: HookToolName::bash(),
tool_use_id: "exec-call-a".to_string(),
command: "sleep 2; echo alpha".to_string(),
tool_response: serde_json::json!("alpha\n"),
}),
]
);
}
+1
View File
@@ -178,6 +178,7 @@ impl ToolCallRuntime {
result: Box::new(AbortedToolOutput {
message: Self::abort_message(call, secs),
}),
post_tool_use_payload: None,
}
}
+13 -29
View File
@@ -72,7 +72,7 @@ pub trait ToolHandler: Send + Sync {
&self,
_call_id: &str,
_payload: &ToolPayload,
_result: &dyn ToolOutput,
_result: &Self::Output,
) -> Option<PostToolUsePayload> {
None
}
@@ -107,6 +107,7 @@ pub(crate) struct AnyToolResult {
pub(crate) call_id: String,
pub(crate) payload: ToolPayload,
pub(crate) result: Box<dyn ToolOutput>,
pub(crate) post_tool_use_payload: Option<PostToolUsePayload>,
}
impl AnyToolResult {
@@ -143,9 +144,11 @@ pub(crate) struct PreToolUsePayload {
pub(crate) struct PostToolUsePayload {
/// Hook-facing tool name model.
///
/// Keep this aligned with the corresponding pre-use payload so external
/// hook consumers can pair events by `tool_use_id`.
/// The canonical name is serialized to hook stdin, while aliases are used
/// only for matcher compatibility.
pub(crate) tool_name: HookToolName,
/// The originating tool-use id exposed at `tool_use_id`.
pub(crate) tool_use_id: String,
/// Command-shaped input exposed at `tool_input.command`.
pub(crate) command: String,
/// Tool result exposed at `tool_response`.
@@ -159,15 +162,7 @@ trait AnyToolHandler: Send + Sync {
fn pre_tool_use_payload(&self, invocation: &ToolInvocation) -> Option<PreToolUsePayload>;
fn post_tool_use_payload(
&self,
call_id: &str,
payload: &ToolPayload,
result: &dyn ToolOutput,
) -> Option<PostToolUsePayload>;
fn create_diff_consumer(&self) -> Option<Box<dyn ToolArgumentDiffConsumer>>;
fn handle_any<'a>(
&'a self,
invocation: ToolInvocation,
@@ -190,19 +185,9 @@ where
ToolHandler::pre_tool_use_payload(self, invocation)
}
fn post_tool_use_payload(
&self,
call_id: &str,
payload: &ToolPayload,
result: &dyn ToolOutput,
) -> Option<PostToolUsePayload> {
ToolHandler::post_tool_use_payload(self, call_id, payload, result)
}
fn create_diff_consumer(&self) -> Option<Box<dyn ToolArgumentDiffConsumer>> {
ToolHandler::create_diff_consumer(self)
}
fn handle_any<'a>(
&'a self,
invocation: ToolInvocation,
@@ -211,10 +196,13 @@ where
let call_id = invocation.call_id.clone();
let payload = invocation.payload.clone();
let output = self.handle(invocation).await?;
let post_tool_use_payload =
ToolHandler::post_tool_use_payload(self, &call_id, &payload, &output);
Ok(AnyToolResult {
call_id,
payload,
result: Box::new(output),
post_tool_use_payload,
})
})
}
@@ -400,13 +388,9 @@ impl ToolRegistry {
emit_metric_for_tool_read(&invocation, success).await;
let post_tool_use_payload = if success {
let guard = response_cell.lock().await;
guard.as_ref().and_then(|result| {
handler.post_tool_use_payload(
&result.call_id,
&result.payload,
result.result.as_ref(),
)
})
guard
.as_ref()
.and_then(|result| result.post_tool_use_payload.clone())
} else {
None
};
@@ -415,7 +399,7 @@ impl ToolRegistry {
run_post_tool_use_hooks(
&invocation.session,
&invocation.turn,
invocation.call_id.clone(),
post_tool_use_payload.tool_use_id,
post_tool_use_payload.tool_name.name().to_string(),
post_tool_use_payload.tool_name.matcher_aliases().to_vec(),
post_tool_use_payload.command,
+1 -1
View File
@@ -148,7 +148,7 @@ struct ProcessEntry {
process: Arc<UnifiedExecProcess>,
call_id: String,
process_id: i32,
command: Vec<String>,
hook_command: String,
tty: bool,
network_approval_id: Option<String>,
session: Weak<Session>,
+2 -2
View File
@@ -113,7 +113,7 @@ async fn exec_command_with_tty(
process: Arc::clone(&process),
call_id: context.call_id.clone(),
process_id,
command: command.clone(),
hook_command: cmd.to_string(),
tty,
network_approval_id: None,
session: Arc::downgrade(session),
@@ -165,7 +165,7 @@ async fn exec_command_with_tty(
process_id: response_process_id,
exit_code,
original_token_count: Some(approx_token_count(&text)),
session_command: Some(command),
hook_command: Some(cmd.to_string()),
})
}
@@ -169,7 +169,7 @@ struct PreparedProcessHandles {
output_closed_notify: Arc<Notify>,
cancellation_token: CancellationToken,
pause_state: Option<watch::Receiver<bool>>,
command: Vec<String>,
hook_command: String,
process_id: i32,
tty: bool,
}
@@ -279,6 +279,7 @@ impl UnifiedExecProcessManager {
Arc::clone(&process),
context,
&request.command,
request.hook_command.clone(),
cwd.clone(),
start,
request.process_id,
@@ -398,7 +399,7 @@ impl UnifiedExecProcessManager {
process_id: response_process_id,
exit_code,
original_token_count: Some(original_token_count),
session_command: Some(request.command.clone()),
hook_command: Some(request.hook_command.clone()),
};
Ok(response)
@@ -418,7 +419,7 @@ impl UnifiedExecProcessManager {
output_closed_notify,
cancellation_token,
pause_state,
command: session_command,
hook_command,
process_id,
tty,
..
@@ -517,7 +518,7 @@ impl UnifiedExecProcessManager {
process_id,
exit_code,
original_token_count: Some(original_token_count),
session_command: Some(session_command.clone()),
hook_command: Some(hook_command),
};
Ok(response)
@@ -585,7 +586,7 @@ impl UnifiedExecProcessManager {
output_closed_notify,
cancellation_token,
pause_state,
command: entry.command.clone(),
hook_command: entry.hook_command.clone(),
process_id: entry.process_id,
tty: entry.tty,
})
@@ -597,6 +598,7 @@ impl UnifiedExecProcessManager {
process: Arc<UnifiedExecProcess>,
context: &UnifiedExecContext,
command: &[String],
hook_command: String,
cwd: AbsolutePathBuf,
started_at: Instant,
process_id: i32,
@@ -608,7 +610,7 @@ impl UnifiedExecProcessManager {
process: Arc::clone(&process),
call_id: context.call_id.clone(),
process_id,
command: command.to_vec(),
hook_command,
tty,
network_approval_id,
session: Arc::downgrade(&context.session),
+165
View File
@@ -33,6 +33,7 @@ use core_test_support::responses::mount_sse_sequence;
use core_test_support::responses::sse;
use core_test_support::responses::start_mock_server;
use core_test_support::skip_if_no_network;
use core_test_support::skip_if_windows;
use core_test_support::streaming_sse::StreamingSseChunk;
use core_test_support::streaming_sse::start_streaming_sse_server;
use core_test_support::test_codex::test_codex;
@@ -407,6 +408,64 @@ elif mode == "exit_2":
Ok(())
}
fn write_logging_pre_and_blocking_post_tool_use_hooks(home: &Path, feedback: &str) -> Result<()> {
let pre_script_path = home.join("pre_tool_use_hook.py");
let pre_log_path = home.join("pre_tool_use_hook_log.jsonl");
let post_script_path = home.join("post_tool_use_hook.py");
let post_log_path = home.join("post_tool_use_hook_log.jsonl");
let feedback_json =
serde_json::to_string(feedback).context("serialize post tool use feedback")?;
let pre_script = format!(
r#"import json
from pathlib import Path
import sys
payload = json.load(sys.stdin)
with Path(r"{pre_log_path}").open("a", encoding="utf-8") as handle:
handle.write(json.dumps(payload) + "\n")
"#,
pre_log_path = pre_log_path.display(),
);
let post_script = format!(
r#"import json
from pathlib import Path
import sys
payload = json.load(sys.stdin)
with Path(r"{post_log_path}").open("a", encoding="utf-8") as handle:
handle.write(json.dumps(payload) + "\n")
sys.stderr.write({feedback_json} + "\n")
raise SystemExit(2)
"#,
post_log_path = post_log_path.display(),
);
let hooks = serde_json::json!({
"hooks": {
"PreToolUse": [{
"matcher": "Bash",
"hooks": [{
"type": "command",
"command": format!("python3 {}", pre_script_path.display()),
"statusMessage": "running pre tool use hook",
}]
}],
"PostToolUse": [{
"matcher": "Bash",
"hooks": [{
"type": "command",
"command": format!("python3 {}", post_script_path.display()),
"statusMessage": "running post tool use hook",
}]
}]
}
});
fs::write(&pre_script_path, pre_script).context("write pre tool use hook script")?;
fs::write(&post_script_path, post_script).context("write post tool use hook script")?;
fs::write(home.join("hooks.json"), hooks.to_string()).context("write hooks.json")?;
Ok(())
}
fn write_session_start_hook_recording_transcript(home: &Path) -> Result<()> {
let script_path = home.join("session_start_hook.py");
let log_path = home.join("session_start_hook_log.jsonl");
@@ -2514,6 +2573,112 @@ async fn post_tool_use_exit_two_replaces_one_shot_exec_command_output_with_feedb
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn post_tool_use_blocks_when_exec_session_completes_via_write_stdin() -> Result<()> {
skip_if_no_network!(Ok(()));
skip_if_windows!(Ok(()));
let server = start_mock_server().await;
let start_call_id = "posttooluse-exec-session-start";
let poll_call_id = "posttooluse-exec-session-poll";
let command = "sleep 1; printf session-post-hook-output".to_string();
let start_args = serde_json::json!({
"cmd": command,
"shell": "/bin/sh",
"login": false,
"tty": false,
"yield_time_ms": 250,
});
let poll_args = serde_json::json!({
"session_id": 1000,
"chars": "",
"yield_time_ms": 5_000,
});
let feedback = "blocked by session post hook";
let responses = mount_sse_sequence(
&server,
vec![
sse(vec![
ev_response_created("resp-1"),
core_test_support::responses::ev_function_call(
start_call_id,
"exec_command",
&serde_json::to_string(&start_args)?,
),
ev_completed("resp-1"),
]),
sse(vec![
ev_response_created("resp-2"),
core_test_support::responses::ev_function_call(
poll_call_id,
"write_stdin",
&serde_json::to_string(&poll_args)?,
),
ev_completed("resp-2"),
]),
sse(vec![
ev_response_created("resp-3"),
ev_assistant_message("msg-1", "session post hook observed"),
ev_completed("resp-3"),
]),
],
)
.await;
let mut builder = test_codex()
.with_pre_build_hook(|home| {
if let Err(error) = write_logging_pre_and_blocking_post_tool_use_hooks(home, feedback) {
panic!("failed to write tool use hook test fixture: {error}");
}
})
.with_config(|config| {
config.use_experimental_unified_exec_tool = true;
config
.features
.enable(Feature::CodexHooks)
.expect("test config should allow feature update");
config
.features
.enable(Feature::UnifiedExec)
.expect("test config should allow feature update");
});
let test = builder.build(&server).await?;
test.submit_turn("run the exec command session with post hook")
.await?;
let requests = responses.requests();
assert_eq!(requests.len(), 3);
let output_item = requests[2].function_call_output(poll_call_id);
let output = output_item
.get("output")
.and_then(Value::as_str)
.expect("write_stdin output string");
assert_eq!(output, feedback);
let pre_hook_inputs = read_pre_tool_use_hook_inputs(test.codex_home_path())?;
assert_eq!(pre_hook_inputs.len(), 1);
assert_eq!(pre_hook_inputs[0]["tool_name"], "Bash");
assert_eq!(pre_hook_inputs[0]["tool_use_id"], start_call_id);
assert_eq!(pre_hook_inputs[0]["tool_input"]["command"], command);
let post_hook_inputs = read_post_tool_use_hook_inputs(test.codex_home_path())?;
assert_eq!(post_hook_inputs.len(), 1);
assert_eq!(post_hook_inputs[0]["hook_event_name"], "PostToolUse");
assert_eq!(post_hook_inputs[0]["tool_name"], "Bash");
assert_eq!(post_hook_inputs[0]["tool_use_id"], start_call_id);
assert_eq!(post_hook_inputs[0]["tool_input"]["command"], command);
assert!(
post_hook_inputs[0]["tool_response"]
.as_str()
.is_some_and(|tool_response| tool_response.contains("session-post-hook-output")),
"PostToolUse should see the final session output, got {:?}",
post_hook_inputs[0]["tool_response"]
);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn post_tool_use_records_additional_context_for_apply_patch() -> Result<()> {
skip_if_no_network!(Ok(()));
+31 -1
View File
@@ -109,7 +109,7 @@ pub(crate) fn matcher_pattern_for_event(
}
pub(crate) fn validate_matcher_pattern(matcher: &str) -> Result<(), regex::Error> {
if is_match_all_matcher(matcher) {
if is_match_all_matcher(matcher) || is_exact_matcher(matcher) {
return Ok(());
}
regex::Regex::new(matcher).map(|_| ())
@@ -119,6 +119,9 @@ pub(crate) fn matches_matcher(matcher: Option<&str>, input: Option<&str>) -> boo
match matcher {
None => true,
Some(matcher) if is_match_all_matcher(matcher) => true,
Some(matcher) if is_exact_matcher(matcher) => input
.map(|input| matcher.split('|').any(|candidate| candidate == input))
.unwrap_or(false),
Some(matcher) => input
.and_then(|input| {
regex::Regex::new(matcher)
@@ -144,6 +147,12 @@ fn is_match_all_matcher(matcher: &str) -> bool {
matcher.is_empty() || matcher == "*"
}
fn is_exact_matcher(matcher: &str) -> bool {
matcher
.chars()
.all(|ch| ch.is_ascii_alphanumeric() || ch == '_' || ch == '|')
}
#[cfg(test)]
mod tests {
use codex_protocol::protocol::HookEventName;
@@ -181,6 +190,27 @@ mod tests {
assert_eq!(validate_matcher_pattern("Edit|Write"), Ok(()));
}
#[test]
fn matcher_exact_string_does_not_substring_match() {
assert!(matches_matcher(Some("Bash"), Some("Bash")));
assert!(!matches_matcher(Some("Bash"), Some("BashOutput")));
assert_eq!(validate_matcher_pattern("Bash"), Ok(()));
}
#[test]
fn matcher_uses_regex_when_it_contains_other_characters() {
assert!(matches_matcher(Some("^Bash"), Some("BashOutput")));
assert!(matches_matcher(
Some("mcp__memory__.*"),
Some("mcp__memory__create_entities")
));
assert!(!matches_matcher(
Some("mcp__memory"),
Some("mcp__memory__create_entities")
));
assert_eq!(validate_matcher_pattern("mcp__memory__.*"), Ok(()));
}
#[test]
fn matcher_supports_anchored_regexes() {
assert!(matches_matcher(Some("^Bash$"), Some("Bash")));