feat: retroactive image placeholder to prevent poisoning (#6774)

If an image can't be read by the API, it will poison the entire history,
preventing any new turn on the conversation.
This detect such cases and replace the image by a placeholder
This commit is contained in:
jif-oai
2025-12-03 11:35:56 +00:00
committed by GitHub
parent 42ae738f67
commit 51307eaf07
7 changed files with 171 additions and 8 deletions

View File

@@ -518,6 +518,32 @@ pub fn sse_response(body: String) -> ResponseTemplate {
.set_body_raw(body, "text/event-stream")
}
pub async fn mount_response_once(server: &MockServer, response: ResponseTemplate) -> ResponseMock {
let (mock, response_mock) = base_mock();
mock.respond_with(response)
.up_to_n_times(1)
.mount(server)
.await;
response_mock
}
pub async fn mount_response_once_match<M>(
server: &MockServer,
matcher: M,
response: ResponseTemplate,
) -> ResponseMock
where
M: wiremock::Match + Send + Sync + 'static,
{
let (mock, response_mock) = base_mock();
mock.and(matcher)
.respond_with(response)
.up_to_n_times(1)
.mount(server)
.await;
response_mock
}
fn base_mock() -> (MockBuilder, ResponseMock) {
let response_mock = ResponseMock::new();
let mock = Mock::given(method("POST"))

View File

@@ -474,3 +474,82 @@ async fn view_image_tool_errors_when_file_missing() -> anyhow::Result<()> {
Ok(())
}
#[cfg(not(debug_assertions))]
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn replaces_invalid_local_image_after_bad_request() -> anyhow::Result<()> {
skip_if_no_network!(Ok(()));
let server = start_mock_server().await;
const INVALID_IMAGE_ERROR: &str =
"The image data you provided does not represent a valid image";
let invalid_image_mock = responses::mount_response_once_match(
&server,
body_string_contains("\"input_image\""),
ResponseTemplate::new(400)
.insert_header("content-type", "text/plain")
.set_body_string(INVALID_IMAGE_ERROR),
)
.await;
let success_response = sse(vec![
ev_response_created("resp-2"),
ev_assistant_message("msg-1", "done"),
ev_completed("resp-2"),
]);
let completion_mock = responses::mount_sse_once(&server, success_response).await;
let TestCodex {
codex,
cwd,
session_configured,
..
} = test_codex().build(&server).await?;
let rel_path = "assets/poisoned.png";
let abs_path = cwd.path().join(rel_path);
if let Some(parent) = abs_path.parent() {
std::fs::create_dir_all(parent)?;
}
let image = ImageBuffer::from_pixel(1024, 512, Rgba([10u8, 20, 30, 255]));
image.save(&abs_path)?;
let session_model = session_configured.model.clone();
codex
.submit(Op::UserTurn {
items: vec![UserInput::LocalImage {
path: abs_path.clone(),
}],
final_output_json_schema: None,
cwd: cwd.path().to_path_buf(),
approval_policy: AskForApproval::Never,
sandbox_policy: SandboxPolicy::DangerFullAccess,
model: session_model,
effort: None,
summary: ReasoningSummary::Auto,
})
.await?;
wait_for_event(&codex, |event| matches!(event, EventMsg::TaskComplete(_))).await;
let first_body = invalid_image_mock.single_request().body_json();
assert!(
find_image_message(&first_body).is_some(),
"initial request should include the uploaded image"
);
let second_request = completion_mock.single_request();
let second_body = second_request.body_json();
assert!(
find_image_message(&second_body).is_none(),
"second request should replace the invalid image"
);
let user_texts = second_request.message_input_texts("user");
assert!(user_texts.iter().any(|text| text == "Invalid image"));
Ok(())
}