mirror of
https://github.com/openai/codex.git
synced 2026-05-09 13:52:41 +00:00
Compare commits
7 Commits
rust-v0.13
...
codex/dyna
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7dc4444292 | ||
|
|
560e493482 | ||
|
|
ffbdf76418 | ||
|
|
a83cd92900 | ||
|
|
060e2fdcab | ||
|
|
c98b0d1925 | ||
|
|
bacaeb7060 |
@@ -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:
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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<()> {
|
||||
|
||||
@@ -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_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",
|
||||
}
|
||||
])
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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"),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 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(())
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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")));
|
||||
|
||||
Reference in New Issue
Block a user