Files
codex/codex-rs/app-server/src/dynamic_tools.rs
T
rka-oai b294638bb5 [codex] reject remote images at app-server ingress (#29419)
## Stack

Stacked on #29417. Review and land that PR first.

## Summary

- reject HTTP(S) image URLs in the handlers for `turn/start` and
`turn/steer`
- validate `thread/inject_items` after its existing
JSON-to-`ResponseItem` conversion, so each item is deserialized once
- turn invalid dynamic-tool image responses into the existing
unsuccessful text fallback; the model receives the validation message as
the function output
- leave `thread/resume.history` compatible with legacy history; #29417
replaces remote images before model input
- continue accepting inline data URLs and `localImage` inputs
- keep this policy in app-server; this PR does not add a shared protocol
API or change core image preparation

## Test plan

- `just test -p codex-app-server -E
'test(/request_handlers_reject_remote_image_urls|dynamic_tool_remote_image_response_becomes_model_visible_error|dynamic_tool_call_round_trip_sends_content_items_to_model|turn_start_tracks_turn_event_analytics|standalone_image_edit_uses_recent_pathless_image/)'`
(5 passed)
- `just fix -p codex-app-server`
- `just fmt`
2026-06-23 00:43:56 +00:00

93 lines
3.1 KiB
Rust

use codex_app_server_protocol::DynamicToolCallOutputContentItem;
use codex_app_server_protocol::DynamicToolCallResponse;
use codex_core::CodexThread;
use codex_protocol::dynamic_tools::DynamicToolCallOutputContentItem as CoreDynamicToolCallOutputContentItem;
use codex_protocol::dynamic_tools::DynamicToolResponse as CoreDynamicToolResponse;
use codex_protocol::protocol::Op;
use std::sync::Arc;
use tokio::sync::oneshot;
use tracing::error;
use crate::image_url::REMOTE_IMAGE_URL_ERROR;
use crate::image_url::is_remote_image_url;
use crate::outgoing_message::ClientRequestResult;
use crate::server_request_error::is_turn_transition_server_request_error;
pub(crate) async fn on_call_response(
call_id: String,
receiver: oneshot::Receiver<ClientRequestResult>,
conversation: Arc<CodexThread>,
) {
let response = receiver.await;
let (response, _error) = match response {
Ok(Ok(value)) => decode_response(value),
Ok(Err(err)) if is_turn_transition_server_request_error(&err) => return,
Ok(Err(err)) => {
error!("request failed with client error: {err:?}");
fallback_response("dynamic tool request failed")
}
Err(err) => {
error!("request failed: {err:?}");
fallback_response("dynamic tool request failed")
}
};
let DynamicToolCallResponse {
content_items,
success,
} = response.clone();
let core_response = CoreDynamicToolResponse {
content_items: content_items
.into_iter()
.map(CoreDynamicToolCallOutputContentItem::from)
.collect(),
success,
};
if let Err(err) = conversation
.submit(Op::DynamicToolResponse {
id: call_id.clone(),
response: core_response,
})
.await
{
error!("failed to submit DynamicToolResponse: {err}");
}
}
fn decode_response(value: serde_json::Value) -> (DynamicToolCallResponse, Option<String>) {
match serde_json::from_value::<DynamicToolCallResponse>(value) {
Ok(response)
if response.content_items.iter().any(|item| {
matches!(
item,
DynamicToolCallOutputContentItem::InputImage { image_url }
if is_remote_image_url(image_url)
)
}) =>
{
error!(
message = REMOTE_IMAGE_URL_ERROR,
"dynamic tool response was invalid"
);
fallback_response(REMOTE_IMAGE_URL_ERROR)
}
Ok(response) => (response, None),
Err(err) => {
error!("failed to deserialize DynamicToolCallResponse: {err}");
fallback_response("dynamic tool response was invalid")
}
}
}
fn fallback_response(message: &str) -> (DynamicToolCallResponse, Option<String>) {
(
DynamicToolCallResponse {
content_items: vec![DynamicToolCallOutputContentItem::InputText {
text: message.to_string(),
}],
success: false,
},
Some(message.to_string()),
)
}