Compare commits

...

7 Commits

Author SHA1 Message Date
Andrei Eternal
7dc4444292 hooks: align dynamic tool names with Responses namespaces 2026-05-01 19:49:28 -07:00
Andrei Eternal
560e493482 hooks: harden dynamic tool hook identities 2026-05-01 19:41:02 -07:00
Andrei Eternal
ffbdf76418 codex: forward bazel runfiles env for plugin MCP tests 2026-05-01 19:39:56 -07:00
Andrei Eternal
a83cd92900 codex: fix dynamic hook tests after main merge 2026-05-01 19:39:56 -07:00
Andrei Eternal
060e2fdcab codex: harden dynamic hook naming 2026-05-01 19:39:56 -07:00
Andrei Eternal
c98b0d1925 codex: add dynamic tool hook support 2026-05-01 19:39:56 -07:00
Andrei Eternal
bacaeb7060 app-server: align dynamic tool identifiers with Responses API 2026-05-01 19:39:17 -07:00
13 changed files with 1503 additions and 36 deletions

View File

@@ -1309,6 +1309,12 @@ If the session approval policy uses `Granular` with `request_permissions: false`
`dynamicTools` on `thread/start` and the corresponding `item/tool/call` request/response flow are experimental APIs. To enable them, set `initialize.params.capabilities.experimentalApi = true`.
Dynamic tool identifiers follow the same constraints as Responses function tools:
- `name` must match `^[a-zA-Z0-9_-]+$` and be between 1 and 128 characters.
- `namespace`, when present, must match `^[a-zA-Z0-9_-]+$` and be between 1 and 64 characters.
- `namespace` must not collide with reserved Responses runtime namespaces such as `functions`, `multi_tool_use`, `file_search`, `web`, `browser`, `image_gen`, `computer`, `container`, `terminal`, `python`, `python_user_visible`, `api_tool`, `tool_search`, or `submodel_delegator`.
Each dynamic tool may set `deferLoading`. When omitted, it defaults to `false`. Set it to `true` to keep the tool registered and callable by runtime features such as `code_mode`, while excluding it from the model-facing tool list sent on ordinary turns. When `tool_search` is available, deferred dynamic tools are searchable and can be exposed by a matching search result.
When a dynamic tool is invoked during a turn, the server sends an `item/tool/call` JSON-RPC request to the client:

View File

@@ -8969,6 +8969,53 @@ fn config_load_error(err: &std::io::Error) -> JSONRPCErrorError {
}
fn validate_dynamic_tools(tools: &[ApiDynamicToolSpec]) -> Result<(), String> {
const DYNAMIC_TOOL_NAME_MAX_LEN: usize = 128;
const DYNAMIC_TOOL_NAMESPACE_MAX_LEN: usize = 64;
const DYNAMIC_TOOL_IDENTIFIER_PATTERN: &str = "^[a-zA-Z0-9_-]+$";
const RESERVED_RESPONSES_NAMESPACES: &[&str] = &[
"api_tool",
"browser",
"computer",
"container",
"file_search",
"functions",
"image_gen",
"multi_tool_use",
"python",
"python_user_visible",
"submodel_delegator",
"terminal",
"tool_search",
"web",
];
fn escape_identifier_for_error(value: &str) -> String {
value.escape_default().to_string()
}
fn validate_dynamic_tool_identifier(
value: &str,
label: &str,
max_len: usize,
) -> Result<(), String> {
if !value
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-'))
{
return Err(format!(
"{label} must match {DYNAMIC_TOOL_IDENTIFIER_PATTERN} to match Responses API: {}",
escape_identifier_for_error(value),
));
}
if value.chars().count() > max_len {
return Err(format!(
"{label} must be at most {max_len} characters to match Responses API: {}",
escape_identifier_for_error(value),
));
}
Ok(())
}
let mut seen = HashSet::new();
for tool in tools {
let name = tool.name.trim();
@@ -8978,9 +9025,10 @@ fn validate_dynamic_tools(tools: &[ApiDynamicToolSpec]) -> Result<(), String> {
if name != tool.name {
return Err(format!(
"dynamic tool name has leading/trailing whitespace: {}",
tool.name
escape_identifier_for_error(&tool.name),
));
}
validate_dynamic_tool_identifier(name, "dynamic tool name", DYNAMIC_TOOL_NAME_MAX_LEN)?;
if name == "mcp" || name.starts_with("mcp__") {
return Err(format!("dynamic tool name is reserved: {name}"));
}
@@ -8994,13 +9042,25 @@ fn validate_dynamic_tools(tools: &[ApiDynamicToolSpec]) -> Result<(), String> {
if Some(namespace) != tool.namespace.as_deref() {
return Err(format!(
"dynamic tool namespace has leading/trailing whitespace for {name}: {namespace}",
name = escape_identifier_for_error(name),
namespace = escape_identifier_for_error(namespace),
));
}
validate_dynamic_tool_identifier(
namespace,
"dynamic tool namespace",
DYNAMIC_TOOL_NAMESPACE_MAX_LEN,
)?;
if namespace == "mcp" || namespace.starts_with("mcp__") {
return Err(format!(
"dynamic tool namespace is reserved for {name}: {namespace}"
));
}
if RESERVED_RESPONSES_NAMESPACES.contains(&namespace) {
return Err(format!(
"dynamic tool namespace collides with a reserved Responses API namespace for {name}: {namespace}",
));
}
}
if !seen.insert((namespace, name)) {
if let Some(namespace) = namespace {
@@ -10141,6 +10201,22 @@ mod tests {
validate_dynamic_tools(&tools).expect("valid schema");
}
#[test]
fn validate_dynamic_tools_accepts_responses_compatible_identifiers() {
let tools = vec![ApiDynamicToolSpec {
namespace: Some("Codex-App_2".to_string()),
name: "lookup-ticket_2".to_string(),
description: "test".to_string(),
input_schema: json!({
"type": "object",
"properties": {},
"additionalProperties": false
}),
defer_loading: true,
}];
validate_dynamic_tools(&tools).expect("valid schema");
}
#[test]
fn validate_dynamic_tools_rejects_duplicate_name_in_same_namespace() {
let tools = vec![
@@ -10244,6 +10320,104 @@ mod tests {
assert!(err.contains("reserved"), "unexpected error: {err}");
}
#[test]
fn validate_dynamic_tools_rejects_name_not_supported_by_responses() {
let tools = vec![ApiDynamicToolSpec {
namespace: None,
name: "lookup.ticket".to_string(),
description: "test".to_string(),
input_schema: json!({
"type": "object",
"properties": {},
"additionalProperties": false
}),
defer_loading: false,
}];
let err = validate_dynamic_tools(&tools).expect_err("invalid name");
assert!(err.contains("lookup.ticket"), "unexpected error: {err}");
assert!(
err.contains("Responses API") && err.contains("^[a-zA-Z0-9_-]+$"),
"unexpected error: {err}"
);
}
#[test]
fn validate_dynamic_tools_rejects_namespace_not_supported_by_responses() {
let tools = vec![ApiDynamicToolSpec {
namespace: Some("codex.app".to_string()),
name: "lookup_ticket".to_string(),
description: "test".to_string(),
input_schema: json!({
"type": "object",
"properties": {},
"additionalProperties": false
}),
defer_loading: true,
}];
let err = validate_dynamic_tools(&tools).expect_err("invalid namespace");
assert!(err.contains("codex.app"), "unexpected error: {err}");
assert!(
err.contains("Responses API") && err.contains("^[a-zA-Z0-9_-]+$"),
"unexpected error: {err}"
);
}
#[test]
fn validate_dynamic_tools_rejects_name_longer_than_responses_limit() {
let long_name = "a".repeat(129);
let tools = vec![ApiDynamicToolSpec {
namespace: None,
name: long_name.clone(),
description: "test".to_string(),
input_schema: json!({
"type": "object",
"properties": {},
"additionalProperties": false
}),
defer_loading: false,
}];
let err = validate_dynamic_tools(&tools).expect_err("name too long");
assert!(err.contains("at most 128"), "unexpected error: {err}");
assert!(err.contains(&long_name), "unexpected error: {err}");
}
#[test]
fn validate_dynamic_tools_rejects_namespace_longer_than_responses_limit() {
let long_namespace = "a".repeat(65);
let tools = vec![ApiDynamicToolSpec {
namespace: Some(long_namespace.clone()),
name: "lookup_ticket".to_string(),
description: "test".to_string(),
input_schema: json!({
"type": "object",
"properties": {},
"additionalProperties": false
}),
defer_loading: true,
}];
let err = validate_dynamic_tools(&tools).expect_err("namespace too long");
assert!(err.contains("at most 64"), "unexpected error: {err}");
assert!(err.contains(&long_namespace), "unexpected error: {err}");
}
#[test]
fn validate_dynamic_tools_rejects_reserved_responses_namespace() {
let tools = vec![ApiDynamicToolSpec {
namespace: Some("functions".to_string()),
name: "lookup_ticket".to_string(),
description: "test".to_string(),
input_schema: json!({
"type": "object",
"properties": {},
"additionalProperties": false
}),
defer_loading: true,
}];
let err = validate_dynamic_tools(&tools).expect_err("reserved Responses namespace");
assert!(err.contains("functions"), "unexpected error: {err}");
assert!(err.contains("Responses API"), "unexpected error: {err}");
}
#[test]
fn summary_from_stored_thread_preserves_millisecond_precision() {
let created_at =

View File

@@ -239,6 +239,46 @@ async fn thread_start_rejects_hidden_dynamic_tools_without_namespace() -> Result
Ok(())
}
#[tokio::test]
async fn thread_start_rejects_dynamic_tools_not_supported_by_responses() -> Result<()> {
let server = MockServer::start().await;
let codex_home = TempDir::new()?;
create_config_toml(codex_home.path(), &server.uri())?;
let mut mcp = McpProcess::new(codex_home.path()).await?;
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
let dynamic_tool = DynamicToolSpec {
namespace: Some("codex.app".to_string()),
name: "lookup.ticket".to_string(),
description: "Invalid dynamic tool".to_string(),
input_schema: json!({
"type": "object",
"properties": {},
"additionalProperties": false,
}),
defer_loading: false,
};
let thread_req = mcp
.send_thread_start_request(ThreadStartParams {
dynamic_tools: Some(vec![dynamic_tool]),
..Default::default()
})
.await?;
let error = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_error_message(RequestId::Integer(thread_req)),
)
.await??;
assert_eq!(error.error.code, -32600);
assert!(error.error.message.contains("Responses API"));
assert!(error.error.message.contains("lookup.ticket"));
Ok(())
}
/// Exercises the full dynamic tool call path (server request, client response, model output).
#[tokio::test]
async fn dynamic_tool_call_round_trip_sends_text_content_items_to_model() -> Result<()> {

View File

@@ -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
{

View File

@@ -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_dynamic_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_dynamic_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("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",
}
])
);
}
}

View File

@@ -29,7 +29,7 @@ impl ToolHandler for McpHandler {
};
Some(PreToolUsePayload {
tool_name: HookToolName::new(invocation.tool_name.display()),
tool_name: HookToolName::for_mcp_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_mcp_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_mcp_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_mcp_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_mcp_tool(&codex_tools::ToolName::namespaced(
"mcp__filesystem__",
"read_file",
)),
tool_use_id: "call-mcp-post".to_string(),
tool_input: json!({
"path": {

View File

@@ -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,36 @@ impl HookToolName {
}
}
/// Builds the canonical hook-facing identity for dynamic tools.
///
/// Plain dynamic tools keep their plain tool name. Namespaced dynamic tools
/// flatten to `namespace__tool`, mirroring the namespaced form used across
/// the model-facing tool surface.
///
/// Each segment is escaped independently so the structural `__` separator
/// stays injective even when identifiers contain edge underscores or runs
/// of multiple underscores. Other bytes remain percent-encoded
/// defensively, though new dynamic tool registration already narrows the
/// upstream contract to Responses-compatible ASCII identifiers.
pub(crate) fn for_dynamic_tool(tool_name: &ToolName) -> Self {
match tool_name.namespace.as_deref() {
Some(namespace) => Self::new(format!(
"{}__{}",
encode_dynamic_segment(namespace),
encode_dynamic_segment(&tool_name.name),
)),
None => Self::new(encode_dynamic_segment(&tool_name.name)),
}
}
/// Builds the canonical hook-facing identity for MCP tools.
///
/// MCP tool names already use the stable fully qualified
/// `mcp__server__tool` form, so we preserve them verbatim.
pub(crate) fn for_mcp_tool(tool_name: &ToolName) -> Self {
Self::new(tool_name.display())
}
/// 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 +85,114 @@ impl HookToolName {
&self.matcher_aliases
}
}
fn encode_dynamic_segment(segment: &str) -> String {
let bytes = segment.as_bytes();
let mut encoded = String::with_capacity(segment.len());
for (index, byte) in bytes.iter().copied().enumerate() {
if should_preserve_dynamic_byte(bytes, index, byte) {
encoded.push(char::from(byte));
} else {
encoded.push('%');
encoded.push(char::from(HEX_DIGITS[usize::from(byte >> 4)]));
encoded.push(char::from(HEX_DIGITS[usize::from(byte & 0x0F)]));
}
}
encoded
}
fn should_preserve_dynamic_byte(bytes: &[u8], index: usize, byte: u8) -> bool {
match byte {
b'a'..=b'z' | b'A'..=b'Z' | b'0'..=b'9' | b'-' => true,
b'_' => {
index > 0
&& index + 1 < bytes.len()
&& (bytes[index - 1].is_ascii_alphanumeric() || bytes[index - 1] == b'-')
&& (bytes[index + 1].is_ascii_alphanumeric() || bytes[index + 1] == b'-')
&& bytes[index - 1] != b'_'
&& bytes[index + 1] != b'_'
}
_ => false,
}
}
const HEX_DIGITS: &[u8; 16] = b"0123456789ABCDEF";
#[cfg(test)]
mod tests {
use super::HookToolName;
use codex_tools::ToolName;
use pretty_assertions::assert_eq;
#[test]
fn for_dynamic_tool_keeps_plain_tool_names_plain() {
assert_eq!(
HookToolName::for_dynamic_tool(&ToolName::plain("tool_search")),
HookToolName::new("tool_search"),
);
}
#[test]
fn for_mcp_tool_keeps_mcp_names_stable() {
assert_eq!(
HookToolName::for_mcp_tool(&ToolName::namespaced("mcp__memory__", "create_entities",)),
HookToolName::new("mcp__memory__create_entities"),
);
}
#[test]
fn for_dynamic_tool_uses_namespace_separator_for_namespaced_tools() {
assert_eq!(
HookToolName::for_dynamic_tool(
&ToolName::namespaced("codex_app", "automation_update",)
),
HookToolName::new("codex_app__automation_update"),
);
}
#[test]
fn for_dynamic_tool_does_not_spoof_plain_namespaced_shapes() {
assert_eq!(
HookToolName::for_dynamic_tool(
&ToolName::namespaced("mcp__filesystem__", "read_file",)
),
HookToolName::new("mcp%5F%5Ffilesystem%5F%5F__read_file"),
);
}
#[test]
fn for_dynamic_tool_escapes_ambiguous_delimiters() {
let first = HookToolName::for_dynamic_tool(&ToolName::namespaced("foo__bar", "baz"));
let second = HookToolName::for_dynamic_tool(&ToolName::namespaced("foo", "bar__baz"));
assert_eq!(first, HookToolName::new("foo%5F%5Fbar__baz"));
assert_eq!(second, HookToolName::new("foo__bar%5F%5Fbaz"));
assert_ne!(first, second);
}
#[test]
fn for_dynamic_tool_escapes_edge_underscores_and_preserves_hyphens() {
assert_eq!(
HookToolName::for_dynamic_tool(&ToolName::namespaced("_google-drive", "update.file_",)),
HookToolName::new("%5Fgoogle-drive__update%2Efile%5F"),
);
}
#[test]
fn for_dynamic_tool_keeps_single_internal_underscores() {
assert_eq!(
HookToolName::for_dynamic_tool(&ToolName::namespaced("codex_app", "automation_update")),
HookToolName::new("codex_app__automation_update"),
);
}
#[test]
fn for_dynamic_tool_percent_encodes_unsupported_bytes_defensively() {
assert_eq!(
HookToolName::for_dynamic_tool(&ToolName::namespaced("", "")),
HookToolName::new("%E6%A4%9C__%E7%B4%A2"),
);
}
}

View File

@@ -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 }),
))
}
}

View File

@@ -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))
}
}

View 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 PLAIN_DYNAMIC_HOOK_NAME: &str = "automation_update";
const DYNAMIC_HOOK_NAME: &str = "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(),
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, PLAIN_DYNAMIC_HOOK_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: {PLAIN_DYNAMIC_HOOK_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": PLAIN_DYNAMIC_HOOK_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(())
}

View File

@@ -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;

View File

@@ -67,18 +67,34 @@ fn write_plugin_skill_plugin(home: &TempDir) -> std::path::PathBuf {
fn write_plugin_mcp_plugin(home: &TempDir, command: &str) {
let plugin_root = write_sample_plugin_manifest_and_config(home);
let runfiles_env_vars = [
"RUNFILES_DIR",
"RUNFILES_MANIFEST_FILE",
"RUNFILES_MANIFEST_ONLY",
"JAVA_RUNFILES",
"TEST_SRCDIR",
"TEST_WORKSPACE",
"TEST_BINARY",
]
.into_iter()
.filter(|name| std::env::var_os(name).is_some())
.collect::<Vec<_>>();
let mut sample_server = serde_json::json!({
"command": command,
"startup_timeout_sec": 60.0,
});
if !runfiles_env_vars.is_empty() {
sample_server["env_vars"] =
serde_json::to_value(runfiles_env_vars).expect("serialize runfiles env vars");
}
std::fs::write(
plugin_root.join(".mcp.json"),
format!(
r#"{{
"mcpServers": {{
"sample": {{
"command": "{command}",
"startup_timeout_sec": 60.0
}}
}}
}}"#
),
serde_json::to_string_pretty(&serde_json::json!({
"mcpServers": {
"sample": sample_server,
},
}))
.expect("serialize plugin mcp config"),
)
.expect("write plugin mcp config");
}

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) || is_exact_matcher(matcher) {
if is_match_all_matcher(matcher) || is_literal_tool_name_matcher(matcher) {
return Ok(());
}
regex::Regex::new(matcher).map(|_| ())
@@ -119,7 +119,7 @@ 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
Some(matcher) if is_literal_tool_name_matcher(matcher) => input
.map(|input| matcher.split('|').any(|candidate| candidate == input))
.unwrap_or(false),
Some(matcher) => input
@@ -147,10 +147,19 @@ fn is_match_all_matcher(matcher: &str) -> bool {
matcher.is_empty() || matcher == "*"
}
fn is_exact_matcher(matcher: &str) -> bool {
fn is_literal_tool_name_matcher(matcher: &str) -> bool {
matcher
.chars()
.all(|ch| ch.is_ascii_alphanumeric() || ch == '_' || ch == '|')
.split('|')
.all(|candidate| !candidate.is_empty() && !contains_regex_syntax(candidate))
}
fn contains_regex_syntax(candidate: &str) -> bool {
candidate.chars().any(|ch| {
matches!(
ch,
'\\' | '^' | '$' | '*' | '(' | ')' | '[' | ']' | '{' | '}'
)
})
}
#[cfg(test)]
@@ -194,6 +203,26 @@ 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("tool-search.v2"),
Some("tool-search.v2")
));
assert!(!matches_matcher(
Some("tool-search.v2"),
Some("tool-searchXv2")
));
assert!(matches_matcher(
Some("lookup+ticket"),
Some("lookup+ticket")
));
assert!(matches_matcher(
Some("codex_app__automation_update"),
Some("codex_app__automation_update")
));
assert!(!matches_matcher(
Some("codex_app"),
Some("codex_app__automation_update")
));
assert!(matches_matcher(
Some("mcp__memory__create_entities"),
Some("mcp__memory__create_entities")
@@ -202,6 +231,8 @@ mod tests {
Some("mcp__memory"),
Some("mcp__memory__create_entities")
));
assert_eq!(validate_matcher_pattern("tool-search.v2"), Ok(()));
assert_eq!(validate_matcher_pattern("lookup+ticket"), Ok(()));
assert_eq!(validate_matcher_pattern("mcp__memory"), Ok(()));
}
@@ -228,6 +259,40 @@ mod tests {
assert_eq!(validate_matcher_pattern("mcp__memory__.*"), Ok(()));
}
#[test]
fn namespaced_dynamic_matchers_support_regex_wildcards() {
assert!(matches_matcher(
Some("codex_app__.*"),
Some("codex_app__automation_update")
));
assert!(matches_matcher(
Some(".*__automation_update"),
Some("codex_app__automation_update")
));
assert!(!matches_matcher(
Some("other_app__.*"),
Some("codex_app__automation_update")
));
assert_eq!(validate_matcher_pattern("codex_app__.*"), Ok(()));
}
#[test]
fn dynamic_matchers_support_percent_encoded_names() {
assert!(matches_matcher(
Some("%E6%A4%9C%E7%B4%A2"),
Some("%E6%A4%9C%E7%B4%A2")
));
assert!(!matches_matcher(
Some("%E6%A4%9C%E7%B4%A2"),
Some("%E6%A4%9C%E7%B4%A2_extra")
));
assert!(matches_matcher(
Some("%E6%A4%9C.*"),
Some("%E6%A4%9C%E7%B4%A2")
));
assert_eq!(validate_matcher_pattern("%E6%A4%9C.*"), Ok(()));
}
#[test]
fn matcher_supports_anchored_regexes() {
assert!(matches_matcher(Some("^Bash$"), Some("Bash")));