mirror of
https://github.com/openai/codex.git
synced 2026-06-01 19:02:59 +00:00
codex: add dynamic tool hook support
This commit is contained in:
@@ -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
|
||||
{
|
||||
|
||||
@@ -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<PreToolUsePayload> {
|
||||
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<PostToolUsePayload> {
|
||||
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::<Vec<_>>();
|
||||
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<Value, FunctionCallError> {
|
||||
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<Value, FunctionCallError> {
|
||||
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",
|
||||
}
|
||||
])
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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"),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -177,10 +177,10 @@ impl Approvable<ApplyPatchRequest> for ApplyPatchRuntime {
|
||||
&self,
|
||||
req: &ApplyPatchRequest,
|
||||
) -> Option<PermissionRequestPayload> {
|
||||
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 }),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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<String>) -> 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))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
839
codex-rs/core/tests/suite/hooks_dynamic.rs
Normal file
839
codex-rs/core/tests/suite/hooks_dynamic.rs
Normal file
@@ -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<Vec<Value>> {
|
||||
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<F>(
|
||||
server: &MockServer,
|
||||
dynamic_tools: Vec<DynamicToolSpec>,
|
||||
pre_build_hook: F,
|
||||
) -> Result<TestCodex>
|
||||
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<String> {
|
||||
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(())
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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")));
|
||||
|
||||
Reference in New Issue
Block a user