From c98b0d19254d7ba0a4aa410c0502cd0a8bf01607 Mon Sep 17 00:00:00 2001 From: Andrei Eternal Date: Wed, 29 Apr 2026 22:23:25 -0700 Subject: [PATCH] codex: add dynamic tool hook support --- codex-rs/core/src/mcp_tool_call.rs | 12 +- codex-rs/core/src/tools/handlers/dynamic.rs | 173 +++- codex-rs/core/src/tools/handlers/mcp.rs | 17 +- codex-rs/core/src/tools/hook_names.rs | 54 ++ .../core/src/tools/runtimes/apply_patch.rs | 8 +- codex-rs/core/src/tools/sandboxing.rs | 12 +- codex-rs/core/tests/suite/hooks_dynamic.rs | 839 ++++++++++++++++++ codex-rs/core/tests/suite/mod.rs | 2 + codex-rs/hooks/src/events/common.rs | 25 + 9 files changed, 1122 insertions(+), 20 deletions(-) create mode 100644 codex-rs/core/tests/suite/hooks_dynamic.rs diff --git a/codex-rs/core/src/mcp_tool_call.rs b/codex-rs/core/src/mcp_tool_call.rs index 85fc939ba9..e2105c0b59 100644 --- a/codex-rs/core/src/mcp_tool_call.rs +++ b/codex-rs/core/src/mcp_tool_call.rs @@ -95,7 +95,7 @@ pub(crate) async fn handle_mcp_tool_call( call_id: String, server: String, tool_name: String, - hook_tool_name: String, + hook_tool_name: HookToolName, arguments: String, ) -> HandledMcpToolCall { // Parse the `arguments` as JSON. An empty string is OK, but invalid JSON @@ -198,7 +198,7 @@ pub(crate) async fn handle_mcp_tool_call( turn_context, &call_id, &invocation, - &hook_tool_name, + hook_tool_name.name(), metadata.as_ref(), approval_mode, ) @@ -1009,13 +1009,13 @@ async fn maybe_request_mcp_tool_approval( sess, turn_context, call_id, - PermissionRequestPayload { - tool_name: HookToolName::new(hook_tool_name), - tool_input: invocation + PermissionRequestPayload::new( + HookToolName::new(hook_tool_name), + invocation .arguments .clone() .unwrap_or_else(|| serde_json::Value::Object(serde_json::Map::new())), - }, + ), ) .await { diff --git a/codex-rs/core/src/tools/handlers/dynamic.rs b/codex-rs/core/src/tools/handlers/dynamic.rs index b7e07090dc..40e132958b 100644 --- a/codex-rs/core/src/tools/handlers/dynamic.rs +++ b/codex-rs/core/src/tools/handlers/dynamic.rs @@ -3,8 +3,12 @@ use crate::session::session::Session; use crate::session::turn_context::TurnContext; use crate::tools::context::FunctionToolOutput; use crate::tools::context::ToolInvocation; +use crate::tools::context::ToolOutput; use crate::tools::context::ToolPayload; use crate::tools::handlers::parse_arguments; +use crate::tools::hook_names::HookToolName; +use crate::tools::registry::PostToolUsePayload; +use crate::tools::registry::PreToolUsePayload; use crate::tools::registry::ToolHandler; use crate::tools::registry::ToolKind; use codex_protocol::dynamic_tools::DynamicToolCallRequest; @@ -27,6 +31,27 @@ impl ToolHandler for DynamicToolHandler { ToolKind::Function } + fn pre_tool_use_payload(&self, invocation: &ToolInvocation) -> Option { + Some(PreToolUsePayload { + tool_name: HookToolName::for_function_tool(&invocation.tool_name), + tool_input: dynamic_tool_input(invocation).ok()?, + }) + } + + fn post_tool_use_payload( + &self, + invocation: &ToolInvocation, + result: &Self::Output, + ) -> Option { + Some(PostToolUsePayload { + tool_name: HookToolName::for_function_tool(&invocation.tool_name), + tool_use_id: invocation.call_id.clone(), + tool_input: dynamic_tool_input(invocation).ok()?, + tool_response: result + .post_tool_use_response(&invocation.call_id, &invocation.payload)?, + }) + } + async fn is_mutating(&self, _invocation: &ToolInvocation) -> bool { true } @@ -67,7 +92,34 @@ impl ToolHandler for DynamicToolHandler { .into_iter() .map(FunctionCallOutputContentItem::from) .collect::>(); - Ok(FunctionToolOutput::from_content(body, Some(success))) + Ok(FunctionToolOutput { + post_tool_use_response: Some(dynamic_tool_post_tool_use_response(&body)?), + body, + success: Some(success), + }) + } +} + +fn dynamic_tool_input(invocation: &ToolInvocation) -> Result { + let ToolPayload::Function { arguments } = &invocation.payload else { + return Err(FunctionCallError::RespondToModel( + "dynamic tool handler received unsupported payload".to_string(), + )); + }; + + parse_arguments(arguments) +} + +fn dynamic_tool_post_tool_use_response( + body: &[FunctionCallOutputContentItem], +) -> Result { + match body { + [FunctionCallOutputContentItem::InputText { text }] => Ok(Value::String(text.clone())), + _ => serde_json::to_value(body).map_err(|error| { + FunctionCallError::RespondToModel(format!( + "failed to serialize dynamic tool response for PostToolUse: {error}" + )) + }), } } @@ -140,3 +192,122 @@ async fn request_dynamic_tool( response } + +#[cfg(test)] +mod tests { + use super::DynamicToolHandler; + use super::dynamic_tool_post_tool_use_response; + use crate::session::tests::make_session_and_context; + use crate::tools::context::FunctionToolOutput; + use crate::tools::context::ToolCallSource; + use crate::tools::context::ToolInvocation; + use crate::tools::context::ToolPayload; + use crate::tools::hook_names::HookToolName; + use crate::tools::registry::PostToolUsePayload; + use crate::tools::registry::PreToolUsePayload; + use crate::tools::registry::ToolHandler; + use crate::turn_diff_tracker::TurnDiffTracker; + use codex_protocol::models::FunctionCallOutputContentItem; + use codex_tools::ToolName; + use pretty_assertions::assert_eq; + use serde_json::json; + use std::sync::Arc; + use tokio::sync::Mutex; + + async fn dynamic_invocation( + tool_name: ToolName, + arguments: serde_json::Value, + ) -> ToolInvocation { + let (session, turn) = make_session_and_context().await; + ToolInvocation { + session: session.into(), + turn: turn.into(), + cancellation_token: tokio_util::sync::CancellationToken::new(), + tracker: Arc::new(Mutex::new(TurnDiffTracker::new())), + call_id: "call-dynamic".to_string(), + tool_name, + source: ToolCallSource::Direct, + payload: ToolPayload::Function { + arguments: arguments.to_string(), + }, + } + } + + #[tokio::test] + async fn dynamic_pre_tool_use_payload_uses_plain_tool_name() { + let invocation = + dynamic_invocation(ToolName::plain("automation_update"), json!({"id": 1})).await; + + assert_eq!( + DynamicToolHandler.pre_tool_use_payload(&invocation), + Some(PreToolUsePayload { + tool_name: HookToolName::new("automation_update"), + tool_input: json!({ "id": 1 }), + }) + ); + } + + #[tokio::test] + async fn dynamic_post_tool_use_payload_uses_namespaced_hook_name() { + let invocation = dynamic_invocation( + ToolName::namespaced("codex_app", "automation_update"), + json!({ "job": "sync" }), + ) + .await; + let output = FunctionToolOutput { + body: vec![FunctionCallOutputContentItem::InputText { + text: "ok".to_string(), + }], + success: Some(true), + post_tool_use_response: Some(json!("ok")), + }; + + assert_eq!( + DynamicToolHandler.post_tool_use_payload(&invocation, &output), + Some(PostToolUsePayload { + tool_name: HookToolName::new("dynamic__codex_app__automation_update"), + tool_use_id: "call-dynamic".to_string(), + tool_input: json!({ "job": "sync" }), + tool_response: json!("ok"), + }) + ); + } + + #[test] + fn dynamic_post_tool_use_response_uses_text_for_single_text_item() { + assert_eq!( + dynamic_tool_post_tool_use_response(&[FunctionCallOutputContentItem::InputText { + text: "done".to_string(), + }]), + Ok(json!("done")) + ); + } + + #[test] + fn dynamic_post_tool_use_response_uses_content_items_for_mixed_output() { + let response = dynamic_tool_post_tool_use_response(&[ + FunctionCallOutputContentItem::InputText { + text: "done".to_string(), + }, + FunctionCallOutputContentItem::InputImage { + image_url: "https://example.com/image.png".to_string(), + detail: None, + }, + ]) + .expect("serialize mixed dynamic tool output"); + + assert_eq!( + response, + json!([ + { + "type": "input_text", + "text": "done", + }, + { + "type": "input_image", + "image_url": "https://example.com/image.png", + } + ]) + ); + } +} diff --git a/codex-rs/core/src/tools/handlers/mcp.rs b/codex-rs/core/src/tools/handlers/mcp.rs index 568e456158..ccd0254684 100644 --- a/codex-rs/core/src/tools/handlers/mcp.rs +++ b/codex-rs/core/src/tools/handlers/mcp.rs @@ -29,7 +29,7 @@ impl ToolHandler for McpHandler { }; Some(PreToolUsePayload { - tool_name: HookToolName::new(invocation.tool_name.display()), + tool_name: HookToolName::for_function_tool(&invocation.tool_name), tool_input: mcp_hook_tool_input(raw_arguments), }) } @@ -46,7 +46,7 @@ impl ToolHandler for McpHandler { let tool_response = result.post_tool_use_response(&invocation.call_id, &invocation.payload)?; Some(PostToolUsePayload { - tool_name: HookToolName::new(invocation.tool_name.display()), + tool_name: HookToolName::for_function_tool(&invocation.tool_name), tool_use_id: invocation.call_id.clone(), tool_input: result.tool_input.clone(), tool_response, @@ -78,6 +78,7 @@ impl ToolHandler for McpHandler { let (server, tool, raw_arguments) = payload; let arguments_str = raw_arguments; + let hook_tool_name = HookToolName::for_function_tool(&model_tool_name); let started = Instant::now(); let result = handle_mcp_tool_call( @@ -86,7 +87,7 @@ impl ToolHandler for McpHandler { call_id.clone(), server, tool, - model_tool_name.display(), + hook_tool_name, arguments_str, ) .await; @@ -147,7 +148,10 @@ mod tests { payload, }), Some(PreToolUsePayload { - tool_name: HookToolName::new("mcp__memory__create_entities"), + tool_name: HookToolName::for_function_tool(&codex_tools::ToolName::namespaced( + "mcp__memory__", + "create_entities", + )), tool_input: json!({ "entities": [{ "name": "Ada", @@ -198,7 +202,10 @@ mod tests { assert_eq!( McpHandler.post_tool_use_payload(&invocation, &output), Some(PostToolUsePayload { - tool_name: HookToolName::new("mcp__filesystem__read_file"), + tool_name: HookToolName::for_function_tool(&codex_tools::ToolName::namespaced( + "mcp__filesystem__", + "read_file", + )), tool_use_id: "call-mcp-post".to_string(), tool_input: json!({ "path": { diff --git a/codex-rs/core/src/tools/hook_names.rs b/codex-rs/core/src/tools/hook_names.rs index 9d3b6c2409..2eaa09a8d8 100644 --- a/codex-rs/core/src/tools/hook_names.rs +++ b/codex-rs/core/src/tools/hook_names.rs @@ -5,6 +5,8 @@ //! concepts together prevents handlers from accidentally serializing a //! compatibility alias, such as `Write`, as the stable hook payload name. +use codex_tools::ToolName; + /// Identifies a tool in hook payloads and hook matcher selection. /// /// `name` is the canonical value serialized into hook stdin. Matcher aliases are @@ -25,6 +27,21 @@ impl HookToolName { } } + /// Builds the canonical hook-facing identity for function-style tools that + /// do not need Claude-compatibility aliases. + /// + /// MCP tool names already use a stable fully qualified `mcp__...__tool` + /// form, so we preserve them verbatim. Other namespaced tools use the + /// `dynamic__namespace__tool` form so hooks can wildcard an entire dynamic + /// namespace without colliding with plain tool names. + pub(crate) fn for_function_tool(tool_name: &ToolName) -> Self { + match tool_name.namespace.as_deref() { + Some(namespace) if namespace.starts_with("mcp__") => Self::new(tool_name.display()), + Some(namespace) => Self::new(format!("dynamic__{namespace}__{}", tool_name.name)), + None => Self::new(tool_name.name.clone()), + } + } + /// Returns the hook identity for file edits performed through `apply_patch`. /// /// The serialized name remains `apply_patch` so logs and policies can key @@ -53,3 +70,40 @@ impl HookToolName { &self.matcher_aliases } } + +#[cfg(test)] +mod tests { + use super::HookToolName; + use codex_tools::ToolName; + use pretty_assertions::assert_eq; + + #[test] + fn for_function_tool_keeps_plain_tool_names() { + assert_eq!( + HookToolName::for_function_tool(&ToolName::plain("tool_search")), + HookToolName::new("tool_search"), + ); + } + + #[test] + fn for_function_tool_keeps_mcp_names_stable() { + assert_eq!( + HookToolName::for_function_tool(&ToolName::namespaced( + "mcp__memory__", + "create_entities", + )), + HookToolName::new("mcp__memory__create_entities"), + ); + } + + #[test] + fn for_function_tool_prefixes_dynamic_namespaces() { + assert_eq!( + HookToolName::for_function_tool(&ToolName::namespaced( + "codex_app", + "automation_update", + )), + HookToolName::new("dynamic__codex_app__automation_update"), + ); + } +} diff --git a/codex-rs/core/src/tools/runtimes/apply_patch.rs b/codex-rs/core/src/tools/runtimes/apply_patch.rs index e720243f2b..ac3f55a8ab 100644 --- a/codex-rs/core/src/tools/runtimes/apply_patch.rs +++ b/codex-rs/core/src/tools/runtimes/apply_patch.rs @@ -177,10 +177,10 @@ impl Approvable for ApplyPatchRuntime { &self, req: &ApplyPatchRequest, ) -> Option { - Some(PermissionRequestPayload { - tool_name: HookToolName::apply_patch(), - tool_input: serde_json::json!({ "command": req.action.patch }), - }) + Some(PermissionRequestPayload::new( + HookToolName::apply_patch(), + serde_json::json!({ "command": req.action.patch }), + )) } } diff --git a/codex-rs/core/src/tools/sandboxing.rs b/codex-rs/core/src/tools/sandboxing.rs index 122cd00fad..c3d4be03c0 100644 --- a/codex-rs/core/src/tools/sandboxing.rs +++ b/codex-rs/core/src/tools/sandboxing.rs @@ -139,6 +139,13 @@ pub(crate) struct PermissionRequestPayload { } impl PermissionRequestPayload { + pub(crate) fn new(tool_name: HookToolName, tool_input: serde_json::Value) -> Self { + Self { + tool_name, + tool_input, + } + } + pub(crate) fn bash(command: String, description: Option) -> Self { let mut tool_input = serde_json::Map::new(); tool_input.insert("command".to_string(), serde_json::Value::String(command)); @@ -149,10 +156,7 @@ impl PermissionRequestPayload { ); } - Self { - tool_name: HookToolName::bash(), - tool_input: serde_json::Value::Object(tool_input), - } + Self::new(HookToolName::bash(), serde_json::Value::Object(tool_input)) } } diff --git a/codex-rs/core/tests/suite/hooks_dynamic.rs b/codex-rs/core/tests/suite/hooks_dynamic.rs new file mode 100644 index 0000000000..2863a2ecb0 --- /dev/null +++ b/codex-rs/core/tests/suite/hooks_dynamic.rs @@ -0,0 +1,839 @@ +use std::fs; +use std::io::ErrorKind; +use std::path::Path; +use std::time::Duration; + +use anyhow::Context; +use anyhow::Result; +use codex_features::Feature; +use codex_protocol::dynamic_tools::DynamicToolCallOutputContentItem; +use codex_protocol::dynamic_tools::DynamicToolResponse; +use codex_protocol::dynamic_tools::DynamicToolSpec; +use codex_protocol::models::PermissionProfile; +use codex_protocol::protocol::AskForApproval; +use codex_protocol::protocol::EventMsg; +use codex_protocol::protocol::Op; +use codex_protocol::user_input::UserInput; +use core_test_support::responses::ev_assistant_message; +use core_test_support::responses::ev_completed; +use core_test_support::responses::ev_function_call; +use core_test_support::responses::ev_function_call_with_namespace; +use core_test_support::responses::ev_response_created; +use core_test_support::responses::mount_sse_once; +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::test_codex::TestCodex; +use core_test_support::test_codex::test_codex; +use core_test_support::test_codex::turn_permission_fields; +use core_test_support::wait_for_event; +use core_test_support::wait_for_event_match; +use pretty_assertions::assert_eq; +use serde_json::Value; +use serde_json::json; +use wiremock::MockServer; + +const DYNAMIC_TOOL_NAME: &str = "automation_update"; +const DYNAMIC_NAMESPACE: &str = "codex_app"; +const DYNAMIC_HOOK_NAME: &str = "dynamic__codex_app__automation_update"; + +fn dynamic_tool(namespace: Option<&str>, name: &str) -> DynamicToolSpec { + DynamicToolSpec { + namespace: namespace.map(str::to_string), + name: name.to_string(), + description: format!("Dynamic hook test tool for {name}."), + input_schema: json!({ + "type": "object", + "properties": { + "job": { "type": "string" } + }, + "required": ["job"], + "additionalProperties": false, + }), + defer_loading: false, + } +} + +fn write_pre_tool_use_hook(home: &Path, matcher: &str, reason: &str) -> Result<()> { + let script_path = home.join("pre_tool_use_hook.py"); + let log_path = home.join("pre_tool_use_hook_log.jsonl"); + let matcher_json = serde_json::to_string(matcher).context("serialize pre matcher")?; + let reason_json = serde_json::to_string(reason).context("serialize pre reason")?; + let script = format!( + r#"import json +from pathlib import Path +import sys + +matcher = {matcher_json} +reason = {reason_json} +payload = json.load(sys.stdin) + +with Path(r"{log_path}").open("a", encoding="utf-8") as handle: + handle.write(json.dumps(payload) + "\n") + +print(json.dumps({{ + "hookSpecificOutput": {{ + "hookEventName": "PreToolUse", + "permissionDecision": "deny", + "permissionDecisionReason": reason + }} +}})) +"#, + log_path = log_path.display(), + matcher_json = matcher_json, + reason_json = reason_json, + ); + let hooks = json!({ + "hooks": { + "PreToolUse": [{ + "matcher": matcher, + "hooks": [{ + "type": "command", + "command": format!("python3 {}", script_path.display()), + "statusMessage": "running dynamic pre tool use hook", + }] + }] + } + }); + + fs::write(&script_path, script).context("write dynamic pre tool use hook script")?; + fs::write(home.join("hooks.json"), hooks.to_string()).context("write hooks.json")?; + Ok(()) +} + +fn write_post_tool_use_hook( + home: &Path, + matcher: &str, + additional_context: Option<&str>, +) -> Result<()> { + let script_path = home.join("post_tool_use_hook.py"); + let log_path = home.join("post_tool_use_hook_log.jsonl"); + let additional_context_json = + serde_json::to_string(&additional_context).context("serialize post context")?; + let script = format!( + r#"import json +from pathlib import Path +import sys + +additional_context = {additional_context_json} +payload = json.load(sys.stdin) + +with Path(r"{log_path}").open("a", encoding="utf-8") as handle: + handle.write(json.dumps(payload) + "\n") + +if additional_context is not None: + print(json.dumps({{ + "hookSpecificOutput": {{ + "hookEventName": "PostToolUse", + "additionalContext": additional_context + }} + }})) +"#, + log_path = log_path.display(), + additional_context_json = additional_context_json, + ); + let hooks = json!({ + "hooks": { + "PostToolUse": [{ + "matcher": matcher, + "hooks": [{ + "type": "command", + "command": format!("python3 {}", script_path.display()), + "statusMessage": "running dynamic post tool use hook", + }] + }] + } + }); + + fs::write(&script_path, script).context("write dynamic post tool use hook script")?; + fs::write(home.join("hooks.json"), hooks.to_string()).context("write hooks.json")?; + Ok(()) +} + +fn write_permission_request_hook(home: &Path, matcher: &str) -> Result<()> { + let script_path = home.join("permission_request_hook.py"); + let log_path = home.join("permission_request_hook_log.jsonl"); + let script = format!( + r#"import json +from pathlib import Path +import sys + +payload = json.load(sys.stdin) + +with Path(r"{log_path}").open("a", encoding="utf-8") as handle: + handle.write(json.dumps(payload) + "\n") + +print(json.dumps({{ + "hookSpecificOutput": {{ + "hookEventName": "PermissionRequest", + "decision": {{ + "behavior": "allow" + }} + }} +}})) +"#, + log_path = log_path.display(), + ); + let hooks = json!({ + "hooks": { + "PermissionRequest": [{ + "matcher": matcher, + "hooks": [{ + "type": "command", + "command": format!("python3 {}", script_path.display()), + "statusMessage": "running dynamic permission request hook", + }] + }] + } + }); + + fs::write(&script_path, script).context("write dynamic permission request hook script")?; + fs::write(home.join("hooks.json"), hooks.to_string()).context("write hooks.json")?; + Ok(()) +} + +fn read_hook_inputs(home: &Path, log_name: &str) -> Result> { + let log_path = home.join(log_name); + let contents = match fs::read_to_string(&log_path) { + Ok(contents) => contents, + Err(err) if err.kind() == ErrorKind::NotFound => return Ok(Vec::new()), + Err(err) => return Err(err).with_context(|| format!("read {}", log_path.display())), + }; + + contents + .lines() + .filter(|line| !line.trim().is_empty()) + .map(|line| serde_json::from_str(line).context("parse hook log line")) + .collect() +} + +async fn build_dynamic_tool_test( + server: &MockServer, + dynamic_tools: Vec, + pre_build_hook: F, +) -> Result +where + F: FnOnce(&Path) + Send + 'static, +{ + let base_test = test_codex() + .with_pre_build_hook(pre_build_hook) + .with_config(|config| { + if let Err(err) = config.features.enable(Feature::CodexHooks) { + panic!("test config should allow enabling codex hooks: {err}"); + } + }) + .build(server) + .await?; + let new_thread = base_test + .thread_manager + .start_thread_with_tools( + base_test.config.clone(), + codex_core::thread_store_from_config(&base_test.config), + dynamic_tools, + /*persist_extended_history*/ false, + ) + .await?; + let mut test = base_test; + test.codex = new_thread.thread; + test.session_configured = new_thread.session_configured; + Ok(test) +} + +async fn submit_dynamic_tool_turn( + test: &TestCodex, + prompt: &str, + approval_policy: AskForApproval, +) -> Result { + let (sandbox_policy, permission_profile) = + turn_permission_fields(PermissionProfile::Disabled, test.config.cwd.as_path()); + let session_model = test.session_configured.model.clone(); + test.codex + .submit(Op::UserTurn { + environments: None, + items: vec![UserInput::Text { + text: prompt.to_string(), + text_elements: Vec::new(), + }], + final_output_json_schema: None, + cwd: test.config.cwd.to_path_buf(), + approval_policy, + approvals_reviewer: None, + sandbox_policy, + permission_profile, + model: session_model, + effort: None, + summary: None, + service_tier: None, + collaboration_mode: None, + personality: None, + }) + .await?; + + Ok(wait_for_event_match(&test.codex, |event| match event { + EventMsg::TurnStarted(event) => Some(event.turn_id.clone()), + _ => None, + }) + .await) +} + +async fn wait_for_turn_to_finish_without_dynamic_request( + test: &TestCodex, + turn_id: &str, +) -> Result<()> { + tokio::time::timeout(Duration::from_secs(20), async { + loop { + let event = test.codex.next_event().await.context("next event")?; + match event.msg { + EventMsg::DynamicToolCallRequest(request) => { + anyhow::bail!( + "unexpected DynamicToolCallRequest for {} {:?}", + request.tool, + request.namespace + ); + } + EventMsg::TurnComplete(event) if event.turn_id == turn_id => return Ok(()), + EventMsg::TurnAborted(event) if event.turn_id.as_deref() == Some(turn_id) => { + return Ok(()); + } + _ => {} + } + } + }) + .await + .context("timeout waiting for turn to finish")? +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn pre_tool_use_blocks_plain_dynamic_tool_before_execution() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = start_mock_server().await; + let call_id = "pretooluse-dynamic-plain"; + let arguments = json!({ "job": "plain" }).to_string(); + let responses = mount_sse_sequence( + &server, + vec![ + sse(vec![ + ev_response_created("resp-1"), + ev_function_call(call_id, DYNAMIC_TOOL_NAME, &arguments), + ev_completed("resp-1"), + ]), + sse(vec![ + ev_response_created("resp-2"), + ev_assistant_message("msg-1", "plain dynamic hook blocked it"), + ev_completed("resp-2"), + ]), + ], + ) + .await; + + let block_reason = "blocked plain dynamic tool"; + let test = build_dynamic_tool_test( + &server, + vec![dynamic_tool(/*namespace*/ None, DYNAMIC_TOOL_NAME)], + move |home| { + if let Err(err) = write_pre_tool_use_hook(home, DYNAMIC_TOOL_NAME, block_reason) { + panic!("failed to write plain dynamic pre hook: {err}"); + } + }, + ) + .await?; + + let turn_id = submit_dynamic_tool_turn( + &test, + "call the plain dynamic tool with the pre hook", + AskForApproval::Never, + ) + .await?; + wait_for_turn_to_finish_without_dynamic_request(&test, &turn_id).await?; + + let requests = responses.requests(); + assert_eq!(requests.len(), 2); + let output_item = requests[1].function_call_output(call_id); + let output = output_item + .get("output") + .and_then(Value::as_str) + .expect("blocked plain dynamic tool output"); + assert!( + output.contains(&format!( + "Tool call blocked by PreToolUse hook: {block_reason}. Tool: {DYNAMIC_TOOL_NAME}" + )), + "blocked plain dynamic tool output should mention the reason and tool name", + ); + + let hook_inputs = read_hook_inputs(test.codex_home_path(), "pre_tool_use_hook_log.jsonl")?; + assert_eq!(hook_inputs.len(), 1); + assert_eq!( + json!({ + "hook_event_name": hook_inputs[0]["hook_event_name"], + "tool_name": hook_inputs[0]["tool_name"], + "tool_use_id": hook_inputs[0]["tool_use_id"], + "tool_input": hook_inputs[0]["tool_input"], + }), + json!({ + "hook_event_name": "PreToolUse", + "tool_name": DYNAMIC_TOOL_NAME, + "tool_use_id": call_id, + "tool_input": { "job": "plain" }, + }), + ); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn pre_tool_use_blocks_namespaced_dynamic_tool_with_dynamic_matcher() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = start_mock_server().await; + let call_id = "pretooluse-dynamic-namespaced"; + let arguments = json!({ "job": "namespaced" }).to_string(); + let responses = mount_sse_sequence( + &server, + vec![ + sse(vec![ + ev_response_created("resp-1"), + ev_function_call_with_namespace( + call_id, + DYNAMIC_NAMESPACE, + DYNAMIC_TOOL_NAME, + &arguments, + ), + ev_completed("resp-1"), + ]), + sse(vec![ + ev_response_created("resp-2"), + ev_assistant_message("msg-1", "namespaced dynamic hook blocked it"), + ev_completed("resp-2"), + ]), + ], + ) + .await; + + let block_reason = "blocked namespaced dynamic tool"; + let test = build_dynamic_tool_test( + &server, + vec![dynamic_tool(Some(DYNAMIC_NAMESPACE), DYNAMIC_TOOL_NAME)], + move |home| { + if let Err(err) = write_pre_tool_use_hook(home, DYNAMIC_HOOK_NAME, block_reason) { + panic!("failed to write namespaced dynamic pre hook: {err}"); + } + }, + ) + .await?; + + let turn_id = submit_dynamic_tool_turn( + &test, + "call the namespaced dynamic tool with the pre hook", + AskForApproval::Never, + ) + .await?; + wait_for_turn_to_finish_without_dynamic_request(&test, &turn_id).await?; + + let requests = responses.requests(); + assert_eq!(requests.len(), 2); + let output_item = requests[1].function_call_output(call_id); + let output = output_item + .get("output") + .and_then(Value::as_str) + .expect("blocked namespaced dynamic tool output"); + assert!( + output.contains(&format!( + "Tool call blocked by PreToolUse hook: {block_reason}. Tool: {DYNAMIC_HOOK_NAME}" + )), + "blocked namespaced dynamic tool output should mention the namespaced hook name", + ); + + let hook_inputs = read_hook_inputs(test.codex_home_path(), "pre_tool_use_hook_log.jsonl")?; + assert_eq!(hook_inputs.len(), 1); + assert_eq!( + json!({ + "hook_event_name": hook_inputs[0]["hook_event_name"], + "tool_name": hook_inputs[0]["tool_name"], + "tool_use_id": hook_inputs[0]["tool_use_id"], + "tool_input": hook_inputs[0]["tool_input"], + }), + json!({ + "hook_event_name": "PreToolUse", + "tool_name": DYNAMIC_HOOK_NAME, + "tool_use_id": call_id, + "tool_input": { "job": "namespaced" }, + }), + ); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn post_tool_use_records_namespaced_dynamic_payload_and_context() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = start_mock_server().await; + let call_id = "posttooluse-dynamic-namespaced"; + let arguments = json!({ "job": "post" }).to_string(); + let call_mock = mount_sse_once( + &server, + sse(vec![ + ev_response_created("resp-1"), + ev_function_call_with_namespace( + call_id, + DYNAMIC_NAMESPACE, + DYNAMIC_TOOL_NAME, + &arguments, + ), + ev_completed("resp-1"), + ]), + ) + .await; + let final_mock = mount_sse_once( + &server, + sse(vec![ + ev_response_created("resp-2"), + ev_assistant_message("msg-1", "dynamic post hook context observed"), + ev_completed("resp-2"), + ]), + ) + .await; + + let post_context = "Remember the dynamic post-tool note."; + let test = build_dynamic_tool_test( + &server, + vec![dynamic_tool(Some(DYNAMIC_NAMESPACE), DYNAMIC_TOOL_NAME)], + move |home| { + if let Err(err) = write_post_tool_use_hook(home, DYNAMIC_HOOK_NAME, Some(post_context)) + { + panic!("failed to write namespaced dynamic post hook: {err}"); + } + }, + ) + .await?; + + let turn_id = submit_dynamic_tool_turn( + &test, + "call the namespaced dynamic tool with the post hook", + AskForApproval::Never, + ) + .await?; + let request = wait_for_event_match(&test.codex, |event| match event { + EventMsg::DynamicToolCallRequest(request) => Some(request.clone()), + _ => None, + }) + .await; + assert_eq!(request.namespace.as_deref(), Some(DYNAMIC_NAMESPACE)); + assert_eq!(request.tool, DYNAMIC_TOOL_NAME); + assert_eq!(request.arguments, json!({ "job": "post" })); + + let content_items = vec![ + DynamicToolCallOutputContentItem::InputText { + text: "done".to_string(), + }, + DynamicToolCallOutputContentItem::InputImage { + image_url: "https://example.com/dynamic.png".to_string(), + }, + ]; + test.codex + .submit(Op::DynamicToolResponse { + id: request.call_id.clone(), + response: DynamicToolResponse { + content_items: content_items.clone(), + success: true, + }, + }) + .await?; + wait_for_event(&test.codex, |event| match event { + EventMsg::TurnComplete(event) => event.turn_id == turn_id, + _ => false, + }) + .await; + + call_mock.single_request(); + let final_request = final_mock.single_request(); + assert!( + final_request + .message_input_texts("developer") + .contains(&post_context.to_string()), + "follow-up request should include dynamic post tool use additional context", + ); + assert_eq!( + final_request.function_call_output(call_id)["output"], + json!([ + { + "type": "input_text", + "text": "done", + }, + { + "type": "input_image", + "image_url": "https://example.com/dynamic.png", + "detail": "high", + } + ]), + ); + + let hook_inputs = read_hook_inputs(test.codex_home_path(), "post_tool_use_hook_log.jsonl")?; + assert_eq!(hook_inputs.len(), 1); + assert_eq!( + json!({ + "hook_event_name": hook_inputs[0]["hook_event_name"], + "tool_name": hook_inputs[0]["tool_name"], + "tool_use_id": hook_inputs[0]["tool_use_id"], + "tool_input": hook_inputs[0]["tool_input"], + "tool_response": hook_inputs[0]["tool_response"], + }), + json!({ + "hook_event_name": "PostToolUse", + "tool_name": DYNAMIC_HOOK_NAME, + "tool_use_id": call_id, + "tool_input": { "job": "post" }, + "tool_response": [ + { + "type": "input_text", + "text": "done", + }, + { + "type": "input_image", + "image_url": "https://example.com/dynamic.png", + "detail": "high", + } + ], + }), + ); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn post_tool_use_does_not_fire_for_unsuccessful_dynamic_calls() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = start_mock_server().await; + let call_id = "posttooluse-dynamic-unsuccessful"; + let arguments = json!({ "job": "fail" }).to_string(); + mount_sse_once( + &server, + sse(vec![ + ev_response_created("resp-1"), + ev_function_call_with_namespace( + call_id, + DYNAMIC_NAMESPACE, + DYNAMIC_TOOL_NAME, + &arguments, + ), + ev_completed("resp-1"), + ]), + ) + .await; + let final_mock = mount_sse_once( + &server, + sse(vec![ + ev_response_created("resp-2"), + ev_assistant_message("msg-1", "dynamic failure observed"), + ev_completed("resp-2"), + ]), + ) + .await; + + let test = build_dynamic_tool_test( + &server, + vec![dynamic_tool(Some(DYNAMIC_NAMESPACE), DYNAMIC_TOOL_NAME)], + move |home| { + if let Err(err) = write_post_tool_use_hook( + home, + DYNAMIC_HOOK_NAME, + Some("should not reach the model"), + ) { + panic!("failed to write unsuccessful dynamic post hook: {err}"); + } + }, + ) + .await?; + + let turn_id = submit_dynamic_tool_turn( + &test, + "call the namespaced dynamic tool and fail it", + AskForApproval::Never, + ) + .await?; + let request = wait_for_event_match(&test.codex, |event| match event { + EventMsg::DynamicToolCallRequest(request) => Some(request.clone()), + _ => None, + }) + .await; + test.codex + .submit(Op::DynamicToolResponse { + id: request.call_id, + response: DynamicToolResponse { + content_items: vec![DynamicToolCallOutputContentItem::InputText { + text: "tool failed".to_string(), + }], + success: false, + }, + }) + .await?; + wait_for_event(&test.codex, |event| match event { + EventMsg::TurnComplete(event) => event.turn_id == turn_id, + _ => false, + }) + .await; + + let final_request = final_mock.single_request(); + assert_eq!( + final_request.function_call_output(call_id)["output"], + json!("tool failed"), + ); + assert!( + !final_request + .message_input_texts("developer") + .contains(&"should not reach the model".to_string()), + "unsuccessful dynamic tools should not inject PostToolUse context", + ); + assert!( + read_hook_inputs(test.codex_home_path(), "post_tool_use_hook_log.jsonl")?.is_empty(), + "unsuccessful dynamic tools should not trigger PostToolUse hooks", + ); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn post_tool_use_does_not_fire_for_canceled_dynamic_calls() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = start_mock_server().await; + let call_id = "posttooluse-dynamic-canceled"; + let arguments = json!({ "job": "cancel" }).to_string(); + let call_mock = mount_sse_once( + &server, + sse(vec![ + ev_response_created("resp-1"), + ev_function_call_with_namespace( + call_id, + DYNAMIC_NAMESPACE, + DYNAMIC_TOOL_NAME, + &arguments, + ), + ev_completed("resp-1"), + ]), + ) + .await; + + let test = build_dynamic_tool_test( + &server, + vec![dynamic_tool(Some(DYNAMIC_NAMESPACE), DYNAMIC_TOOL_NAME)], + move |home| { + if let Err(err) = write_post_tool_use_hook(home, DYNAMIC_HOOK_NAME, Some("ignored")) { + panic!("failed to write canceled dynamic post hook: {err}"); + } + }, + ) + .await?; + + let turn_id = submit_dynamic_tool_turn( + &test, + "start the namespaced dynamic tool and then interrupt it", + AskForApproval::Never, + ) + .await?; + let request = wait_for_event_match(&test.codex, |event| match event { + EventMsg::DynamicToolCallRequest(request) => Some(request.clone()), + _ => None, + }) + .await; + assert_eq!(request.call_id, call_id); + test.codex.submit(Op::Interrupt).await?; + wait_for_event(&test.codex, |event| match event { + EventMsg::TurnAborted(event) => event.turn_id.as_deref() == Some(turn_id.as_str()), + _ => false, + }) + .await; + + call_mock.single_request(); + assert!( + read_hook_inputs(test.codex_home_path(), "post_tool_use_hook_log.jsonl")?.is_empty(), + "canceled dynamic tools should not trigger PostToolUse hooks", + ); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn dynamic_tools_do_not_trigger_permission_request_hooks() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = start_mock_server().await; + let call_id = "permissionrequest-dynamic"; + let arguments = json!({ "job": "approve-me" }).to_string(); + mount_sse_once( + &server, + sse(vec![ + ev_response_created("resp-1"), + ev_function_call_with_namespace( + call_id, + DYNAMIC_NAMESPACE, + DYNAMIC_TOOL_NAME, + &arguments, + ), + ev_completed("resp-1"), + ]), + ) + .await; + let final_mock = mount_sse_once( + &server, + sse(vec![ + ev_response_created("resp-2"), + ev_assistant_message("msg-1", "dynamic tool completed without approval hooks"), + ev_completed("resp-2"), + ]), + ) + .await; + + let test = build_dynamic_tool_test( + &server, + vec![dynamic_tool(Some(DYNAMIC_NAMESPACE), DYNAMIC_TOOL_NAME)], + move |home| { + if let Err(err) = write_permission_request_hook(home, DYNAMIC_HOOK_NAME) { + panic!("failed to write dynamic permission request hook: {err}"); + } + }, + ) + .await?; + + let turn_id = submit_dynamic_tool_turn( + &test, + "run the namespaced dynamic tool under unless-trusted approvals", + AskForApproval::UnlessTrusted, + ) + .await?; + let request = wait_for_event_match(&test.codex, |event| match event { + EventMsg::DynamicToolCallRequest(request) => Some(request.clone()), + _ => None, + }) + .await; + test.codex + .submit(Op::DynamicToolResponse { + id: request.call_id, + response: DynamicToolResponse { + content_items: vec![DynamicToolCallOutputContentItem::InputText { + text: "still-ran".to_string(), + }], + success: true, + }, + }) + .await?; + wait_for_event(&test.codex, |event| match event { + EventMsg::TurnComplete(event) => event.turn_id == turn_id, + _ => false, + }) + .await; + + assert_eq!( + final_mock.single_request().function_call_output(call_id)["output"], + json!("still-ran"), + ); + assert!( + read_hook_inputs(test.codex_home_path(), "permission_request_hook_log.jsonl")?.is_empty(), + "dynamic tools should not start triggering PermissionRequest hooks in this pass", + ); + + Ok(()) +} diff --git a/codex-rs/core/tests/suite/mod.rs b/codex-rs/core/tests/suite/mod.rs index fb96e23c8b..dd2c6776ed 100644 --- a/codex-rs/core/tests/suite/mod.rs +++ b/codex-rs/core/tests/suite/mod.rs @@ -48,6 +48,8 @@ mod hierarchical_agents; #[cfg(not(target_os = "windows"))] mod hooks; #[cfg(not(target_os = "windows"))] +mod hooks_dynamic; +#[cfg(not(target_os = "windows"))] mod hooks_mcp; mod image_rollout; mod items; diff --git a/codex-rs/hooks/src/events/common.rs b/codex-rs/hooks/src/events/common.rs index de3f3292ac..b41775e5f1 100644 --- a/codex-rs/hooks/src/events/common.rs +++ b/codex-rs/hooks/src/events/common.rs @@ -194,6 +194,14 @@ mod tests { fn literal_matcher_uses_exact_matching() { assert!(matches_matcher(Some("Bash"), Some("Bash"))); assert!(!matches_matcher(Some("Bash"), Some("BashOutput"))); + assert!(matches_matcher( + Some("dynamic__codex_app__automation_update"), + Some("dynamic__codex_app__automation_update") + )); + assert!(!matches_matcher( + Some("dynamic__codex_app"), + Some("dynamic__codex_app__automation_update") + )); assert!(matches_matcher( Some("mcp__memory__create_entities"), Some("mcp__memory__create_entities") @@ -228,6 +236,23 @@ mod tests { assert_eq!(validate_matcher_pattern("mcp__memory__.*"), Ok(())); } + #[test] + fn dynamic_matchers_support_regex_wildcards() { + assert!(matches_matcher( + Some("dynamic__codex_app__.*"), + Some("dynamic__codex_app__automation_update") + )); + assert!(matches_matcher( + Some("dynamic__.*__automation_update"), + Some("dynamic__codex_app__automation_update") + )); + assert!(!matches_matcher( + Some("dynamic__other_app__.*"), + Some("dynamic__codex_app__automation_update") + )); + assert_eq!(validate_matcher_pattern("dynamic__codex_app__.*"), Ok(())); + } + #[test] fn matcher_supports_anchored_regexes() { assert!(matches_matcher(Some("^Bash$"), Some("Bash")));