From 7844815bd75f8e20ffe3e9f031d261e2956a981c Mon Sep 17 00:00:00 2001 From: Vivian Fang Date: Fri, 13 Nov 2026 12:36:13 +0000 Subject: [PATCH] Add opt-in prompt image sanitization (#61341) ## What changed - Add the disabled-by-default `safe_image_decoder` feature to decode and re-encode prompt images without source metadata, with byte and decoded-pixel limits. - Apply JPEG orientation and supported ICC color conversion to sRGB, emitting PNG for PNG/GIF inputs and JPEG for JPEG/WebP inputs. - Use the selected decoder for image preparation, `view_image` validation, and MCP image validation. Include the decoder in image cache keys. - Preserve original inline images in history metadata so resume and fork can regenerate prepared images without repeated JPEG encoding changing their bytes. ## Testing Add regression tests for identical JPEG replay after history serialization and for preventing legacy cache entries from bypassing sanitization. GitOrigin-RevId: 718cd83d0a233e2eb8efd1a64c14d8944e44c350 --- MODULE.bazel.lock | 2 +- codex-rs/Cargo.lock | 5 +- .../app-server-exports-stable.json.zst | Bin 170462 -> 170570 bytes codex-rs/core/config.schema.json | 6 + codex-rs/core/src/guardian/request_budget.rs | 2 +- .../src/guardian/review_session_prompt.rs | 9 +- codex-rs/core/src/image_preparation.rs | 210 +++++++++++++----- codex-rs/core/src/image_preparation_tests.rs | 88 +++++++- codex-rs/core/src/session/inject.rs | 46 +--- codex-rs/core/src/session/mod.rs | 84 +++---- codex-rs/core/src/session/tests.rs | 9 +- codex-rs/core/src/tools/handlers/mcp.rs | 10 +- .../core/src/tools/handlers/view_image.rs | 31 ++- codex-rs/features/src/lib.rs | 8 + codex-rs/history/src/lib.rs | 19 ++ codex-rs/protocol/src/models.rs | 3 +- codex-rs/utils/image/Cargo.toml | 5 +- codex-rs/utils/image/src/error.rs | 2 + codex-rs/utils/image/src/image_tests.rs | 39 +++- codex-rs/utils/image/src/lib.rs | 103 ++++++--- codex-rs/utils/image/src/sanitizer.rs | 167 ++++++++++++++ 21 files changed, 632 insertions(+), 216 deletions(-) create mode 100644 codex-rs/utils/image/src/sanitizer.rs diff --git a/MODULE.bazel.lock b/MODULE.bazel.lock index bbbee2ddec..b0720896c6 100644 --- a/MODULE.bazel.lock +++ b/MODULE.bazel.lock @@ -1784,7 +1784,7 @@ "pkg-config_0.3.32": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"lazy_static\",\"req\":\"^1\"}],\"features\":{}}", "plain_0.2.3": "{\"dependencies\":[],\"features\":{}}", "plist_1.9.0": "{\"dependencies\":[{\"name\":\"base64\",\"req\":\"^0.22.0\"},{\"name\":\"indexmap\",\"req\":\"^2.1.0\"},{\"name\":\"quick_xml\",\"package\":\"quick-xml\",\"req\":\"^0.39.2\"},{\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0.2\"},{\"kind\":\"dev\",\"name\":\"serde_derive\",\"req\":\"^1.0.2\"},{\"kind\":\"dev\",\"name\":\"serde_yaml\",\"req\":\"^0.8.21\"},{\"features\":[\"parsing\",\"formatting\"],\"name\":\"time\",\"req\":\"^0.3.47\"}],\"features\":{\"default\":[\"serde\"],\"enable_unstable_features_that_may_break_with_minor_version_bumps\":[]}}", - "png_0.18.0": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"approx\",\"req\":\"^0.5.1\"},{\"name\":\"bitflags\",\"req\":\"^2.0\"},{\"kind\":\"dev\",\"name\":\"byteorder\",\"req\":\"^1.5.0\"},{\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"clap\",\"req\":\"^4.0\"},{\"name\":\"crc32fast\",\"req\":\"^1.2.0\"},{\"default_features\":false,\"features\":[\"cargo_bench_support\"],\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.7.0\"},{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.7.0\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"name\":\"fdeflate\",\"req\":\"^0.3.3\"},{\"name\":\"flate2\",\"req\":\"^1.0.35\"},{\"kind\":\"dev\",\"name\":\"glob\",\"req\":\"^0.3\"},{\"features\":[\"simd\"],\"name\":\"miniz_oxide\",\"req\":\"^0.8\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.9.2\"}],\"features\":{\"benchmarks\":[],\"unstable\":[\"crc32fast/nightly\"],\"zlib-rs\":[\"flate2/zlib-rs\"]}}", + "png_0.18.1": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"approx\",\"req\":\"^0.5.1\"},{\"name\":\"bitflags\",\"req\":\"^2.0\"},{\"kind\":\"dev\",\"name\":\"byteorder\",\"req\":\"^1.5.0\"},{\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"clap\",\"req\":\"^4.0\"},{\"name\":\"crc32fast\",\"req\":\"^1.2.0\"},{\"default_features\":false,\"features\":[\"cargo_bench_support\"],\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.7.0\"},{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.7.0\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"name\":\"fdeflate\",\"req\":\"^0.3.3\"},{\"name\":\"flate2\",\"req\":\"^1.0.35\"},{\"kind\":\"dev\",\"name\":\"glob\",\"req\":\"^0.3\"},{\"features\":[\"simd\"],\"name\":\"miniz_oxide\",\"req\":\"^0.8\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.9.2\"}],\"features\":{\"benchmarks\":[],\"unstable\":[\"crc32fast/nightly\"],\"zlib-rs\":[\"flate2/zlib-rs\"]}}", "polling_3.11.0": "{\"dependencies\":[{\"name\":\"cfg-if\",\"req\":\"^1\"},{\"name\":\"concurrent-queue\",\"req\":\"^2.2.0\",\"target\":\"cfg(windows)\"},{\"kind\":\"dev\",\"name\":\"easy-parallel\",\"req\":\"^3.1.0\"},{\"kind\":\"dev\",\"name\":\"fastrand\",\"req\":\"^2.0.0\"},{\"name\":\"hermit-abi\",\"req\":\"^0.5.0\",\"target\":\"cfg(target_os = \\\"hermit\\\")\"},{\"kind\":\"dev\",\"name\":\"libc\",\"req\":\"^0.2\",\"target\":\"cfg(unix)\"},{\"name\":\"pin-project-lite\",\"req\":\"^0.2.9\",\"target\":\"cfg(windows)\"},{\"default_features\":false,\"features\":[\"event\",\"fs\",\"pipe\",\"process\",\"std\",\"time\"],\"name\":\"rustix\",\"req\":\"^1.0.5\",\"target\":\"cfg(any(unix, target_os = \\\"fuchsia\\\", target_os = \\\"vxworks\\\"))\"},{\"kind\":\"dev\",\"name\":\"signal-hook\",\"req\":\"^0.3.17\",\"target\":\"cfg(all(unix, not(target_os=\\\"vita\\\")))\"},{\"kind\":\"dev\",\"name\":\"socket2\",\"req\":\"^0.6.0\"},{\"default_features\":false,\"name\":\"tracing\",\"optional\":true,\"req\":\"^0.1.37\"},{\"features\":[\"Wdk_Foundation\",\"Wdk_Storage_FileSystem\",\"Win32_Foundation\",\"Win32_Networking_WinSock\",\"Win32_Security\",\"Win32_Storage_FileSystem\",\"Win32_System_IO\",\"Win32_System_LibraryLoader\",\"Win32_System_Threading\",\"Win32_System_WindowsProgramming\"],\"name\":\"windows-sys\",\"req\":\"^0.61\",\"target\":\"cfg(windows)\"}],\"features\":{}}", "poly1305_0.8.0": "{\"dependencies\":[{\"name\":\"cpufeatures\",\"req\":\"^0.2\",\"target\":\"cfg(any(target_arch = \\\"x86_64\\\", target_arch = \\\"x86\\\"))\"},{\"kind\":\"dev\",\"name\":\"hex-literal\",\"req\":\"^0.3\"},{\"name\":\"opaque-debug\",\"req\":\"^0.3\"},{\"default_features\":false,\"name\":\"universal-hash\",\"req\":\"^0.5\"},{\"default_features\":false,\"name\":\"zeroize\",\"optional\":true,\"req\":\"^1\"}],\"features\":{\"std\":[\"universal-hash/std\"]}}", "polyval_0.6.2": "{\"dependencies\":[{\"name\":\"cfg-if\",\"req\":\"^1\"},{\"name\":\"cpufeatures\",\"req\":\"^0.2\",\"target\":\"cfg(any(target_arch = \\\"aarch64\\\", target_arch = \\\"x86_64\\\", target_arch = \\\"x86\\\"))\"},{\"kind\":\"dev\",\"name\":\"hex-literal\",\"req\":\"^0.3\"},{\"name\":\"opaque-debug\",\"req\":\"^0.3\"},{\"default_features\":false,\"name\":\"universal-hash\",\"req\":\"^0.5\"},{\"default_features\":false,\"name\":\"zeroize\",\"optional\":true,\"req\":\"^1\"}],\"features\":{\"std\":[\"universal-hash/std\"]}}", diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index fe050f7543..afdb72ad7f 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -5512,6 +5512,7 @@ dependencies = [ "divan", "image", "mime_guess", + "moxcms", "png", "thiserror 2.0.18", "tokio", @@ -12075,9 +12076,9 @@ dependencies = [ [[package]] name = "png" -version = "0.18.0" +version = "0.18.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97baced388464909d42d89643fe4361939af9b7ce7a31ee32a168f832a70f2a0" +checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" dependencies = [ "bitflags 2.13.1", "crc32fast", diff --git a/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-stable.json.zst b/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-stable.json.zst index 39004a5992f1d0bbb98ccfb32bcaf347ab697810..b08be3e4b067fe0df1d18eb0db40a777305cacfc 100644 GIT binary patch delta 24016 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Π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 0 && model_info.origin != ModelOrigin::Synthetic); - let image_mode = if unified_image_budget_enabled(¶ms.spawn_config.features, model_info) { - ImagePreparationMode::UnifiedBudget - } else { - ImagePreparationMode::DetailBased - }; + let image_mode = ImagePreparationMode::for_model(¶ms.spawn_config.features, model_info); let history_tokens = if context_window.is_some() && collected.sections.has_images() { let base_instructions = reviewer.session.get_base_instructions().await; reviewer_history @@ -121,7 +116,7 @@ impl codex_guardian_context::SyncImagePreparer for ReviewImagePreparer<'_> { detail: &mut Option, ) -> Option> { *detail = match normalize_output_image_detail(self.model_info, *detail) { - _ if self.mode == ImagePreparationMode::UnifiedBudget => Some(ImageDetail::Original), + _ if self.mode.unified_budget => Some(ImageDetail::Original), Some(ImageDetail::Low) => Some(ImageDetail::High), detail => detail, }; diff --git a/codex-rs/core/src/image_preparation.rs b/codex-rs/core/src/image_preparation.rs index 39915e7ab4..a014bc2113 100644 --- a/codex-rs/core/src/image_preparation.rs +++ b/codex-rs/core/src/image_preparation.rs @@ -1,4 +1,5 @@ use std::collections::HashMap; +use std::sync::Arc; use crate::config::ManagedFeatures; use crate::context::ContextualUserFragment; @@ -15,6 +16,9 @@ use codex_context_fragments::AnnotatedContent; use codex_context_fragments::set_annotated_content; use codex_context_fragments::to_annotated_content; use codex_features::Feature; +use codex_history::CodexHarnessMetadata; +use codex_history::ImageReplaySource; +use codex_history::ResponseItemEnvelope; use codex_protocol::models::ContentItem; use codex_protocol::models::ContentItemKind; use codex_protocol::models::FunctionCallOutputContentItem; @@ -23,8 +27,10 @@ use codex_protocol::models::ImageDetail; use codex_protocol::models::ImageReference; use codex_protocol::models::ResponseItem; use codex_protocol::openai_models::ModelInfo; +use codex_utils_cache::sha1_digest; use codex_utils_image::EncodedImage; use codex_utils_image::ImageProcessingError; +use codex_utils_image::PromptImageDecoder; use codex_utils_image::PromptImageMode; use codex_utils_image::data_url_from_bytes; use codex_utils_image::load_data_url_for_prompt; @@ -38,10 +44,37 @@ const UNSUPPORTED_LOW_DETAIL_PLACEHOLDER: &str = "image content omitted because const REMOTE_IMAGE_URL_PLACEHOLDER: &str = "image content omitted because remote image URLs are not supported"; -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub(crate) enum ImagePreparationMode { - DetailBased, - UnifiedBudget, +#[derive(Clone, Copy, Debug, Default)] +pub(crate) struct ImagePreparationMode { + pub(crate) unified_budget: bool, + pub(crate) decoder: PromptImageDecoder, + replay: bool, +} + +impl ImagePreparationMode { + pub(crate) fn for_replay(features: &ManagedFeatures) -> Self { + Self { + unified_budget: false, + decoder: prompt_image_decoder(features), + replay: true, + } + } + + pub(crate) fn for_model(features: &ManagedFeatures, model_info: &ModelInfo) -> Self { + Self { + unified_budget: unified_image_budget_enabled(features, model_info), + decoder: prompt_image_decoder(features), + replay: false, + } + } +} + +pub(crate) fn prompt_image_decoder(features: &ManagedFeatures) -> PromptImageDecoder { + if features.enabled(Feature::SafeImageDecoder) { + PromptImageDecoder::Sanitizer + } else { + PromptImageDecoder::Legacy + } } pub(crate) fn unified_image_budget_enabled( @@ -74,6 +107,15 @@ struct PreparedImageResize { prepared_height: u32, } +struct ImagePreparation<'a> { + mode: ImagePreparationMode, + image_store: &'a dyn AttachmentStore, + analytics: &'a mut Vec, + history: &'a mut Option, +} + +const REPLAY_IMAGE_DATA_URL_PREFIX: &str = "data:application/octet-stream;base64,"; + pub(crate) struct PreparedInlineImage { encoded: EncodedImage, effective_detail: ImageDetailSetting, @@ -115,9 +157,10 @@ impl ImagePreparationError { match self { ImagePreparationError::RemoteUrlUnsupported => REMOTE_IMAGE_URL_PLACEHOLDER, ImagePreparationError::UnsupportedLowDetail => UNSUPPORTED_LOW_DETAIL_PLACEHOLDER, - ImagePreparationError::Processing(ImageProcessingError::ImageTooLarge { .. }) => { - IMAGE_TOO_LARGE_PLACEHOLDER - } + ImagePreparationError::Processing( + ImageProcessingError::ImageTooLarge { .. } + | ImageProcessingError::ImageTooManyPixels { .. }, + ) => IMAGE_TOO_LARGE_PLACEHOLDER, ImagePreparationError::Processing(_) => IMAGE_PROCESSING_ERROR_PLACEHOLDER, } } @@ -126,16 +169,23 @@ impl ImagePreparationError { pub(crate) async fn prepare_response_items( thread_id: &str, ephemeral: bool, - items: &mut Vec, + items: &mut Vec, mode: ImagePreparationMode, resize_notice_mode: ImageResizeNoticeMode, image_store: &dyn AttachmentStore, ) -> Vec { - let mut metadata = Vec::new(); + let mut image_preparations = Vec::new(); let mut prepared_items = Vec::with_capacity(items.len()); - for mut item in std::mem::take(items) { - let mut annotated_content = to_annotated_content(&mut item); - let resize_notice = match &mut item { + for mut envelope in std::mem::take(items) { + let mut preparation = ImagePreparation { + mode, + image_store, + analytics: &mut image_preparations, + history: &mut envelope.metadata, + }; + let item = &mut envelope.item; + let mut annotated_content = to_annotated_content(item); + let resize_notice = match item { ResponseItem::Message { role, .. } => { let Some(mut content) = annotated_content.take() else { continue; @@ -153,12 +203,10 @@ pub(crate) async fn prepare_response_items( } else { ImageResizeNoticeMode::Disabled }, - &mut metadata, - mode, - image_store, + &mut preparation, ) .await; - let _ = set_annotated_content(&mut item, content); + let _ = set_annotated_content(item, content); (!resized_images.is_empty()).then(|| { ImageResizeNotice::new(ImageResizeNoticeSource::UserMessage, resized_images) }) @@ -172,9 +220,7 @@ pub(crate) async fn prepare_response_items( output, call_id.as_deref(), resize_notice_mode, - &mut metadata, - mode, - image_store, + &mut preparation, ) .await } @@ -187,9 +233,7 @@ pub(crate) async fn prepare_response_items( output, Some(call_id.as_str()), resize_notice_mode, - &mut metadata, - mode, - image_store, + &mut preparation, ) .await } @@ -209,25 +253,57 @@ pub(crate) async fn prepare_response_items( | ResponseItem::ContextCompaction { .. } | ResponseItem::Other => None, }; - prepared_items.push(item); + prepared_items.push(envelope); if let Some(resize_notice) = resize_notice { - prepared_items.push(ContextualUserFragment::into(resize_notice)); + prepared_items.push(ResponseItemEnvelope::new(ContextualUserFragment::into( + resize_notice, + ))); } } *items = prepared_items; - metadata + image_preparations +} + +fn replay_image_url<'a>( + image_url: &str, + detail: Option, + sources: &'a [ImageReplaySource], + mode: ImagePreparationMode, +) -> Option<&'a str> { + if sources.is_empty() { + return None; + } + let digest = sha1_digest(image_url.as_bytes()); + sources + .iter() + .find(|source| { + if source.detail != detail + || !source + .original_data_url + .starts_with(REPLAY_IMAGE_DATA_URL_PREFIX) + { + return false; + } + if source.prepared_url_digest.len() == 32 { + // Older builds wrote a 32-byte digest. Match their original by reproducing the recorded URL. + let mut original_detail = detail; + return resize_image(&source.original_data_url, &mut original_detail, mode) + .ok() + .flatten() + .is_some_and(|prepared| prepared.into_data_url() == image_url); + } + source.prepared_url_digest == digest + }) + .map(|source| source.original_data_url.as_str()) } -#[allow(clippy::too_many_arguments)] async fn prepare_tool_output( thread_id: &str, ephemeral: bool, output: &mut FunctionCallOutputPayload, item_id: Option<&str>, resize_notice_mode: ImageResizeNoticeMode, - metadata: &mut Vec, - mode: ImagePreparationMode, - image_store: &dyn AttachmentStore, + preparation: &mut ImagePreparation<'_>, ) -> Option { let content = output.content_items_mut()?; let resized_images = prepare_tool_output_content( @@ -239,9 +315,7 @@ async fn prepare_tool_output( item_id, }, resize_notice_mode, - metadata, - mode, - image_store, + preparation, ) .await; if resized_images.is_empty() { @@ -258,9 +332,7 @@ async fn prepare_message_content( items: &mut [AnnotatedContent], origin: ImageOrigin<'_>, resize_notice_mode: ImageResizeNoticeMode, - metadata: &mut Vec, - mode: ImagePreparationMode, - image_store: &dyn AttachmentStore, + preparation: &mut ImagePreparation<'_>, ) -> Vec { // File-backed images are already prepared and pass through while retaining their positions in // resize notices. @@ -273,7 +345,7 @@ async fn prepare_message_content( for item in items { if let ContentItem::InputImage { image, detail } = item.content_mut() { image_number += 1; - match prepare_image(image, detail, origin, metadata, mode, image_store).await { + match prepare_image(image, detail, origin, preparation).await { Ok(Some(resize)) if resize_notice_mode == ImageResizeNoticeMode::Enabled => { resized_images.push(ResizedImage { image_number, @@ -303,9 +375,7 @@ async fn prepare_tool_output_content( items: &mut [FunctionCallOutputContentItem], origin: ImageOrigin<'_>, resize_notice_mode: ImageResizeNoticeMode, - metadata: &mut Vec, - mode: ImagePreparationMode, - image_store: &dyn AttachmentStore, + preparation: &mut ImagePreparation<'_>, ) -> Vec { let image_count = items .iter() @@ -316,7 +386,7 @@ async fn prepare_tool_output_content( for item in items { if let FunctionCallOutputContentItem::InputImage { image, detail } = item { image_number += 1; - match prepare_image(image, detail, origin, metadata, mode, image_store).await { + match prepare_image(image, detail, origin, preparation).await { Ok(Some(resize)) if resize_notice_mode == ImageResizeNoticeMode::Enabled => { resized_images.push(ResizedImage { image_number, @@ -356,17 +426,25 @@ async fn prepare_image( image: &mut ImageReference, detail: &mut Option, origin: ImageOrigin<'_>, - metadata: &mut Vec, - mode: ImagePreparationMode, - image_store: &dyn AttachmentStore, + preparation: &mut ImagePreparation<'_>, ) -> Result, ImagePreparationError> { let ImageReference::Inline { image_url } = image else { return Ok(None); }; - let Some(prepared) = resize_image(image_url, detail, mode)? else { + let mode = preparation.mode; + // On resume or fork, sanitize the saved original. If there is no match, sanitize + // the inline image from history instead. + let replay_url = if mode.replay && mode.decoder == PromptImageDecoder::Sanitizer { + preparation.history.as_ref().and_then(|history| { + replay_image_url(image_url, *detail, &history.image_replay_sources, mode) + }) + } else { + None + }; + let Some(prepared) = resize_image(replay_url.unwrap_or(image_url), detail, mode)? else { return Ok(None); }; - metadata.push(prepared.metadata(origin)); + preparation.analytics.push(prepared.metadata(origin)); let resize = prepared.resize; let request = UploadRequest { thread_id: origin.thread_id.to_owned(), @@ -375,7 +453,7 @@ async fn prepare_image( tags: HashMap::new(), data: prepared.encoded.bytes.to_vec(), }; - *image = match image_store.upload(request).await { + let prepared_image = match preparation.image_store.upload(request).await { Ok(UploadResult::Inline { bytes }) => ImageReference::Inline { image_url: data_url_from_bytes(&prepared.encoded.mime, &bytes), }, @@ -387,6 +465,31 @@ async fn prepare_image( } } }; + // Keep the original so replay can reproduce the image the model saw. Encoding an + // already prepared JPEG again can change its bytes. If only the MIME label changed, + // the saved image already has the original payload. + if !mode.replay + && mode.decoder == PromptImageDecoder::Sanitizer + && let ImageReference::Inline { + image_url: prepared_url, + } = &prepared_image + && let Some((_, source_payload)) = image_url.split_once(',') + && let Some((_, prepared_payload)) = prepared_url.split_once(',') + && source_payload != prepared_payload + { + preparation + .history + .get_or_insert_default() + .image_replay_sources + .push(ImageReplaySource { + prepared_url_digest: sha1_digest(prepared_url.as_bytes()).to_vec(), + detail: *detail, + original_data_url: Arc::new(format!( + "{REPLAY_IMAGE_DATA_URL_PREFIX}{source_payload}" + )), + }); + } + *image = prepared_image; Ok(resize) } @@ -402,12 +505,13 @@ pub(crate) fn resize_image( return Ok(None); } - let (effective_detail, image_mode) = match mode { - ImagePreparationMode::UnifiedBudget => ( + let (effective_detail, image_mode) = if mode.unified_budget { + ( ImageDetailSetting::Original, PromptImageMode::ORIGINAL_DETAIL, - ), - ImagePreparationMode::DetailBased => match detail { + ) + } else { + match detail { None | Some(ImageDetail::Auto | ImageDetail::High) => { (ImageDetailSetting::High, PromptImageMode::HIGH_DETAIL) } @@ -416,9 +520,9 @@ pub(crate) fn resize_image( PromptImageMode::ORIGINAL_DETAIL, ), Some(ImageDetail::Low) => return Err(ImagePreparationError::UnsupportedLowDetail), - }, + } }; - let encoded = load_data_url_for_prompt(image_url, image_mode)?; + let encoded = load_data_url_for_prompt(image_url, image_mode, mode.decoder)?; let resize = ((encoded.source_width, encoded.source_height) != (encoded.width, encoded.height)) .then_some(PreparedImageResize { source_width: encoded.source_width, @@ -426,7 +530,7 @@ pub(crate) fn resize_image( prepared_width: encoded.width, prepared_height: encoded.height, }); - if mode == ImagePreparationMode::UnifiedBudget { + if mode.unified_budget { // Preserve accurate context-window accounting while older transports still require an // image detail field. Responses Lite removes this compatibility hint before sending. *detail = Some(ImageDetail::Original); diff --git a/codex-rs/core/src/image_preparation_tests.rs b/codex-rs/core/src/image_preparation_tests.rs index 75a0e2f794..aa30e1ed3d 100644 --- a/codex-rs/core/src/image_preparation_tests.rs +++ b/codex-rs/core/src/image_preparation_tests.rs @@ -9,6 +9,7 @@ use codex_attachment_store::IsResolvableFuture; use codex_attachment_store::ResolveFuture; use codex_attachment_store::ResolveRequest; use codex_attachment_store::UploadFuture; +use codex_history::RolloutItem; use codex_protocol::models::ContentItemKind; use codex_protocol::models::FunctionCallOutputBody; use codex_protocol::models::FunctionCallOutputPayload; @@ -26,6 +27,28 @@ use serde_json::json; use super::*; +async fn prepare_response_items( + thread_id: &str, + ephemeral: bool, + items: &mut Vec, + mode: ImagePreparationMode, + resize_notice_mode: ImageResizeNoticeMode, + image_store: &dyn AttachmentStore, +) -> Vec { + let mut envelopes = items.drain(..).map(ResponseItemEnvelope::new).collect(); + let metadata = super::prepare_response_items( + thread_id, + ephemeral, + &mut envelopes, + mode, + resize_notice_mode, + image_store, + ) + .await; + items.extend(envelopes.into_iter().map(ResponseItemEnvelope::into_item)); + metadata +} + pub(crate) struct FailingAttachmentStore; impl AttachmentStore for FailingAttachmentStore { @@ -70,6 +93,59 @@ fn decoded_image(image_url: &str) -> (Vec, DynamicImage) { (bytes, image) } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn replay_regenerates_the_recorded_jpeg_from_its_original() { + let pixels = image::RgbImage::from_fn(81, 57, |x, y| { + image::Rgb([(19 * x + y) as u8, (11 * y + x) as u8, (x * y) as u8]) + }); + let mut bytes = Cursor::new(Vec::new()); + DynamicImage::ImageRgb8(pixels) + .write_to(&mut bytes, ImageFormat::Jpeg) + .expect("encode original JPEG"); + let source = data_url_from_bytes("image/jpeg", &bytes.into_inner()); + let message = ResponseItem::Message { + status: None, + encrypted_content: None, + id: None, + role: "user".to_string(), + content: vec![ContentItem::InputImage { + image: ImageReference::Inline { image_url: source }, + detail: Some(ImageDetail::High), + }], + phase: None, + internal_chat_message_metadata_passthrough: None, + }; + let mut items = vec![ResponseItemEnvelope::new(message)]; + let prepare = async |items: &mut Vec, replay| { + super::prepare_response_items( + "image-preparation-thread", + /*ephemeral*/ false, + items, + ImagePreparationMode { + decoder: PromptImageDecoder::Sanitizer, + replay, + ..Default::default() + }, + ImageResizeNoticeMode::Disabled, + &InlineAttachmentStore, + ) + .await; + }; + prepare(&mut items, /*replay*/ false).await; + let recorded = items.pop().expect("prepared item"); + assert!(recorded.metadata.is_some()); + let json = + serde_json::to_string(&RolloutItem::ResponseItem(recorded.clone())).expect("persist item"); + let RolloutItem::ResponseItem(reloaded) = + serde_json::from_str(&json).expect("read persisted item") + else { + panic!("expected persisted response item"); + }; + let mut replayed = vec![reloaded]; + prepare(&mut replayed, /*replay*/ true).await; + assert_eq!(replayed, vec![recorded]); +} + #[tokio::test(flavor = "multi_thread")] async fn preparation_preserves_small_image_bytes_and_replaces_remote_urls() { let (data_url, original_bytes) = png_data_url(/*width*/ 64, /*height*/ 32); @@ -114,7 +190,7 @@ async fn preparation_preserves_small_image_bytes_and_replaces_remote_urls() { "image-preparation-thread", /*ephemeral*/ false, &mut items, - ImagePreparationMode::DetailBased, + ImagePreparationMode::default(), ImageResizeNoticeMode::Disabled, &InlineAttachmentStore, ) @@ -206,7 +282,7 @@ async fn detail_policies_apply_the_expected_budgets() { "image-preparation-thread", /*ephemeral*/ false, &mut items, - ImagePreparationMode::DetailBased, + ImagePreparationMode::default(), ImageResizeNoticeMode::Disabled, &InlineAttachmentStore, ) @@ -261,7 +337,7 @@ async fn preparation_reports_tool_output_item_id() { "image-preparation-thread", /*ephemeral*/ false, &mut items, - ImagePreparationMode::DetailBased, + ImagePreparationMode::default(), ImageResizeNoticeMode::Disabled, &InlineAttachmentStore, ) @@ -301,7 +377,7 @@ async fn upload_failure_keeps_resized_image_inline() { "image-preparation-thread", /*ephemeral*/ false, &mut items, - ImagePreparationMode::DetailBased, + ImagePreparationMode::default(), ImageResizeNoticeMode::Disabled, &FailingAttachmentStore, ) @@ -405,7 +481,7 @@ async fn resize_notices_count_file_backed_images_and_skip_failed_images() { "image-preparation-thread", /*ephemeral*/ false, &mut items, - ImagePreparationMode::DetailBased, + ImagePreparationMode::default(), ImageResizeNoticeMode::Enabled, &InlineAttachmentStore, ) @@ -602,7 +678,7 @@ async fn preparation_replaces_only_failed_tool_images_and_preserves_metadata() { "image-preparation-thread", /*ephemeral*/ false, &mut items, - ImagePreparationMode::DetailBased, + ImagePreparationMode::default(), ImageResizeNoticeMode::Disabled, &InlineAttachmentStore, ) diff --git a/codex-rs/core/src/session/inject.rs b/codex-rs/core/src/session/inject.rs index 0d2fed11db..7b478371fb 100644 --- a/codex-rs/core/src/session/inject.rs +++ b/codex-rs/core/src/session/inject.rs @@ -1,7 +1,6 @@ use super::TurnInput as PendingTurnInput; use super::session::Session; use super::turn_context::TurnContext; -use codex_analytics::ImagePreparationMetadata; use codex_features::Feature; use codex_history::ResponseItemEnvelope; use codex_protocol::error::CodexErr; @@ -126,24 +125,20 @@ impl Session { envelope } + #[tracing::instrument( + name = "record_conversation_items", + level = "trace", + skip_all, + fields(item_count = items.len()) + )] pub(crate) async fn record_annotated_conversation_items( &self, turn_context: &TurnContext, model_info: &ModelInfo, items: Vec, ) { - if items.iter().all(|item| item.metadata.is_none()) { - let items = items - .into_iter() - .map(ResponseItemEnvelope::into_item) - .collect::>(); - self.record_conversation_items(turn_context, model_info, &items) - .await; - return; - } - let (annotated_items, image_preparations) = self - .prepare_annotated_conversation_items_for_history(turn_context, model_info, items) + .prepare_conversation_items_for_history(turn_context, model_info, items) .await; self.record_prepared_conversation_items( turn_context, @@ -154,33 +149,6 @@ impl Session { .await; } - pub(crate) async fn prepare_annotated_conversation_items_for_history( - &self, - turn_context: &TurnContext, - model_info: &ModelInfo, - items: Vec, - ) -> (Vec, Vec) { - let mut annotated_items = Vec::with_capacity(items.len()); - let mut image_preparations = Vec::new(); - for envelope in items { - let (prepared_items, prepared_images) = self - .prepare_conversation_items_for_history( - turn_context, - model_info, - std::slice::from_ref(&envelope.item), - ) - .await; - image_preparations.extend(prepared_images); - - let mut metadata = envelope.metadata; - annotated_items.extend(prepared_items.into_iter().map(|item| ResponseItemEnvelope { - item, - metadata: metadata.take(), - })); - } - (annotated_items, image_preparations) - } - /// Injects items into active work, or records them without starting a turn. pub(crate) async fn inject_no_new_turn( &self, diff --git a/codex-rs/core/src/session/mod.rs b/codex-rs/core/src/session/mod.rs index 55e3c03c81..57d0b39a1f 100644 --- a/codex-rs/core/src/session/mod.rs +++ b/codex-rs/core/src/session/mod.rs @@ -49,7 +49,6 @@ use crate::exec_policy::default_policy_path; use crate::image_preparation::ImagePreparationMode; use crate::image_preparation::ImageResizeNoticeMode; use crate::image_preparation::prepare_response_items as prepare_image_response_items; -use crate::image_preparation::unified_image_budget_enabled; use crate::parse_turn_item; use crate::realtime_conversation::RealtimeConversationManager; use crate::realtime_history::RealtimeEventOrder; @@ -1999,38 +1998,22 @@ impl Session { .and_then(|context| context.direct_only_tool_namespaces.clone()) .map(|namespaces| (turn.turn_id.clone(), namespaces)) }); - // Keep the recorded rollout unchanged. Prepare its reconstructed history before - // installing it, so legacy media is processed once for this resume or fork and - // will be processed again if the rollout is reconstructed in a future session. - // Replay disables image-resize notices, so media preparation remains one-to-one. Keep - // the prior batch behavior and carry history-only metadata in a positional sidecar. - let (mut prepared_history, metadata): (Vec<_>, Vec<_>) = history - .into_iter() - .map(|envelope| (envelope.item, envelope.metadata)) - .unzip(); - // Replay must not upload or migrate recorded history. The inline store returns prepared - // inline bytes, while existing file references bypass preparation and remain unchanged. + // Prepare inline images in memory on resume or fork. Keep file references as they are. + // The inline store avoids uploads, and replay leaves the saved history untouched. + // Skip resize notices here so replay doesn't add messages to the conversation. // Bound replay future size now that image preparation can await storage. let _ = Box::pin(prepare_image_response_items( &self.thread_id.to_string(), turn_context.config.ephemeral, - &mut prepared_history, - ImagePreparationMode::DetailBased, + &mut history, + ImagePreparationMode::for_replay(&turn_context.config.features), ImageResizeNoticeMode::Disabled, &InlineAttachmentStore, )) .await; - prepare_audio_response_items(&mut prepared_history); - assert_eq!( - prepared_history.len(), - metadata.len(), - "replay media preparation must remain one-to-one when resize notices are disabled" - ); - history = prepared_history - .into_iter() - .zip(metadata) - .map(|(item, metadata)| ResponseItemEnvelope { item, metadata }) - .collect(); + for envelope in &mut history { + prepare_audio_response_items(std::slice::from_mut(&mut envelope.item)); + } file_audio::resolve(self, file_audio::file_ids(&history).cloned().collect()).await; // Ordinary replay needs no reviewer refresh. Only an opaque // checkpoint can make reconstruction depend on reviewer compatibility. @@ -3946,21 +3929,16 @@ impl Session { item.set_create_time_if_missing(Self::response_item_create_time()); } - /// Prepares media using the originating model and preserves existing item identity. + /// Prepares media for the model that received these items and keeps their existing IDs. + /// Each saved original stays with the item that contains the prepared image. pub(crate) async fn prepare_conversation_items_for_history( &self, turn_context: &TurnContext, model_info: &ModelInfo, - items: &[ResponseItem], - ) -> (Vec, Vec) { - let mut items = items.to_vec(); - let image_preparation_mode = - if unified_image_budget_enabled(&turn_context.config.features, model_info) { - ImagePreparationMode::UnifiedBudget - } else { - ImagePreparationMode::DetailBased - }; - let image_resize_notice_mode = if turn_context + mut items: Vec, + ) -> (Vec, Vec) { + let image_mode = ImagePreparationMode::for_model(&turn_context.config.features, model_info); + let resize_notices = if turn_context .config .features .enabled(Feature::ImageResizeNotice) @@ -3974,14 +3952,15 @@ impl Session { &self.thread_id.to_string(), turn_context.config.ephemeral, &mut items, - image_preparation_mode, - image_resize_notice_mode, + image_mode, + resize_notices, self.services.image_store.as_ref(), )) .await; - prepare_audio_response_items(&mut items); - // Most response items get their passthrough turn ID at the durable history boundary. - for item in &mut items { + for envelope in &mut items { + let item = &mut envelope.item; + prepare_audio_response_items(std::slice::from_mut(item)); + // Most response items get their passthrough turn ID at the durable history boundary. Self::stamp_response_item_for_history(item, &turn_context.sub_id); self.assign_missing_response_item_id(item); } @@ -4039,22 +4018,20 @@ impl Session { /// Appends to history, persists the prepared items, then notifies raw-item observers. /// Execution callers supply their captured model; standalone callers need no tool runtime. - #[tracing::instrument(level = "trace", skip_all, fields(item_count = items.len()))] pub(crate) async fn record_conversation_items( &self, turn_context: &TurnContext, model_info: &ModelInfo, items: &[ResponseItem], ) { - let (items, image_preparations) = self - .prepare_conversation_items_for_history(turn_context, model_info, items) - .await; - let items = items.into_iter().map(ResponseItemEnvelope::new).collect(); - self.record_prepared_conversation_items( + self.record_annotated_conversation_items( turn_context, model_info, - items, - image_preparations, + items + .iter() + .cloned() + .map(ResponseItemEnvelope::new) + .collect(), ) .await; } @@ -4696,14 +4673,15 @@ impl Session { ) { let communication = communication.into(); let response_item = Self::inter_agent_history_item(&communication, &turn_context.config); - let (items, _) = self + let (mut prepared, _) = self .prepare_conversation_items_for_history( turn_context, model_info, - std::slice::from_ref(&response_item), + vec![ResponseItemEnvelope::new(response_item)], ) .await; - let mut response_item = ResponseItemEnvelope::new(items[0].clone()); + let mut response_item = prepared.remove(0); + let items = [response_item.item.clone()]; // A send confirmed after the pending snapshot must not reach the rollout // before this boundary; older readers assign deliveries by physical order. let boundary = self @@ -5978,7 +5956,7 @@ impl Session { )]); } let (prepared_items, image_preparations) = self - .prepare_annotated_conversation_items_for_history( + .prepare_conversation_items_for_history( turn_context, model_info, vec![ResponseItemEnvelope { diff --git a/codex-rs/core/src/session/tests.rs b/codex-rs/core/src/session/tests.rs index 8076bb618e..85295c864b 100644 --- a/codex-rs/core/src/session/tests.rs +++ b/codex-rs/core/src/session/tests.rs @@ -390,14 +390,17 @@ async fn default_turn_context_assigns_missing_response_item_ids() { role: "developer".to_string(), tools: Arc::from([]), }, - ]; + ] + .into_iter() + .map(ResponseItemEnvelope::new) + .collect(); let (items, _) = session - .prepare_conversation_items_for_history(&turn_context, turn_context.model_info(), &items) + .prepare_conversation_items_for_history(&turn_context, turn_context.model_info(), items) .await; assert_eq!(items.len(), 3); for (item, prefix) in items.iter().zip(["msg_", "amsg_", "at_"]) { - let suffix = item.id().unwrap().strip_prefix(prefix).unwrap(); + let suffix = item.item.id().unwrap().strip_prefix(prefix).unwrap(); assert_eq!(Uuid::parse_str(suffix).unwrap().get_version_num(), 7); } } diff --git a/codex-rs/core/src/tools/handlers/mcp.rs b/codex-rs/core/src/tools/handlers/mcp.rs index b0a7e2c7ef..bd083a3ef7 100644 --- a/codex-rs/core/src/tools/handlers/mcp.rs +++ b/codex-rs/core/src/tools/handlers/mcp.rs @@ -6,6 +6,7 @@ use crate::context::NodeReplReviewEvidence; use crate::context::NodeReplReviewEvidenceMode; use crate::context::node_repl_review_evidence_mode; use crate::function_tool::FunctionCallError; +use crate::image_preparation::prompt_image_decoder; use crate::mcp_tool_call::handle_mcp_tool_call; use crate::original_image_detail::can_request_original_image_detail; use crate::session::session::Session; @@ -415,6 +416,7 @@ impl CoreToolRuntime for McpHandler { .and_then(Value::as_bool) == Some(true) }; + let decoder = prompt_image_decoder(&invocation.turn.config.features); let mut captured_image_bytes = 0_usize; let mut items = content .iter() @@ -458,8 +460,12 @@ impl CoreToolRuntime for McpHandler { .and_then(|detail| serde_json::from_value(detail.clone()).ok()); let image_url = format!("data:{};base64,{payload}", mime_type.to_ascii_lowercase()); - load_data_url_for_prompt_uncached(&image_url, PromptImageMode::Original) - .ok()?; + load_data_url_for_prompt_uncached( + &image_url, + PromptImageMode::Original, + decoder, + ) + .ok()?; captured_image_bytes = next_image_bytes; Some(UserInput::Image { image: codex_protocol::models::ImageReference::Inline { image_url }, diff --git a/codex-rs/core/src/tools/handlers/view_image.rs b/codex-rs/core/src/tools/handlers/view_image.rs index 4add23f950..67c04ba011 100644 --- a/codex-rs/core/src/tools/handlers/view_image.rs +++ b/codex-rs/core/src/tools/handlers/view_image.rs @@ -10,10 +10,14 @@ use codex_protocol::models::ImageDetail; use codex_protocol::models::ImageReference; use codex_protocol::models::ResponseInputItem; use codex_protocol::openai_models::InputModality; +use codex_utils_image::MAX_SANITIZED_IMAGE_BYTES; +use codex_utils_image::PromptImageDecoder; use codex_utils_image::data_url_from_bytes; use serde::Deserialize; use crate::function_tool::FunctionCallError; +use crate::image_preparation::ImagePreparationMode; +use crate::image_preparation::resize_image; use crate::original_image_detail::can_request_original_image_detail; use crate::tools::context::ToolEnvironments; use crate::tools::context::ToolInvocation; @@ -165,6 +169,16 @@ impl ViewImageHandler { "image path `{model_visible_path}` is not a file" ))); } + let mode = ImagePreparationMode::for_model( + &turn.config.features, + &step_context.settings.model_info, + ); + let sanitize = mode.decoder == PromptImageDecoder::Sanitizer; + let invalid_image = + || FunctionCallError::RespondToModel(VIEW_IMAGE_INVALID_MESSAGE.to_string()); + if sanitize && metadata.size > MAX_SANITIZED_IMAGE_BYTES as u64 { + return Err(invalid_image()); + } let file_bytes = fs .read_file(&path_uri, ReadFileOptions::default(), Some(&sandbox)) .await @@ -173,11 +187,12 @@ impl ViewImageHandler { "unable to read image at `{model_visible_path}`: {error}" )) })?; - // Reject non-images before their bytes can reach code mode without changing - // valid image bytes, metadata, or centralized image preparation. - image::load_from_memory(&file_bytes).map_err(|_| { - FunctionCallError::RespondToModel(VIEW_IMAGE_INVALID_MESSAGE.to_string()) - })?; + // Validate before passing the image to Code Mode or history. + if !sanitize { + image::load_from_memory(&file_bytes).map_err(|_| invalid_image())?; + } else if file_bytes.len() > MAX_SANITIZED_IMAGE_BYTES { + return Err(invalid_image()); + } let can_request_original_detail = can_request_original_image_detail(&step_context.settings.model_info); @@ -189,8 +204,12 @@ impl ViewImageHandler { DEFAULT_IMAGE_DETAIL }; - // The history insertion path owns image preparation and resizing. + // Return the original so history can resize it and report the change to the model. let image_url = data_url_from_bytes("application/octet-stream", &file_bytes); + if sanitize { + // Check the image now so view_image can report an error before returning it. + resize_image(&image_url, &mut Some(image_detail), mode).map_err(|_| invalid_image())?; + } let item = TurnItem::ImageView(ImageViewItem { id: call_id, diff --git a/codex-rs/features/src/lib.rs b/codex-rs/features/src/lib.rs index 37076ae34a..d6089604f9 100644 --- a/codex-rs/features/src/lib.rs +++ b/codex-rs/features/src/lib.rs @@ -404,6 +404,8 @@ pub enum Feature { OmitAppServerNotificationMedia, /// Tell the model when a prompt image was resized and include its dimensions. ImageResizeNotice, + /// Normalize prompt images with the reviewed image sanitizer. + SafeImageDecoder, /// Apply one shared pixel and token budget to every image, regardless of legacy detail hints. UnifiedImageBudget, /// Removed compatibility flag for always-on centralized image preparation. @@ -2046,6 +2048,12 @@ pub const FEATURES: &[FeatureSpec] = &[ stage: Stage::Stable, default_enabled: true, }, + FeatureSpec { + id: Feature::SafeImageDecoder, + key: "safe_image_decoder", + stage: Stage::UnderDevelopment, + default_enabled: false, + }, FeatureSpec { id: Feature::ResizeAllImages, key: "resize_all_images", diff --git a/codex-rs/history/src/lib.rs b/codex-rs/history/src/lib.rs index cee005424e..eacc5a49fe 100644 --- a/codex-rs/history/src/lib.rs +++ b/codex-rs/history/src/lib.rs @@ -75,6 +75,10 @@ pub struct CodexHarnessMetadata { #[serde(default, skip_serializing_if = "Option::is_none")] pub sampled_through_sequence: Option, + /// Original inline images to sanitize again on resume or fork. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub image_replay_sources: Vec, + /// Complete retained records actually delivered by this Guardian message. Host-only. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub guardian_sources: Vec, @@ -160,6 +164,21 @@ pub struct CodexHarnessMetadata { pub sender_user_messages: Option>, } +#[derive(Serialize, Deserialize, Clone, PartialEq, Eq, JsonSchema)] +pub struct ImageReplaySource { + pub prepared_url_digest: Vec, + pub detail: Option, + pub original_data_url: Arc, +} + +impl std::fmt::Debug for ImageReplaySource { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("ImageReplaySource") + .field("original_data_url_len", &self.original_data_url.len()) + .finish_non_exhaustive() + } +} + fn deserialize_mcp_attribution_checkpoint<'de, D>( deserializer: D, ) -> Result, D::Error> diff --git a/codex-rs/protocol/src/models.rs b/codex-rs/protocol/src/models.rs index 1973c2693f..827785df7c 100644 --- a/codex-rs/protocol/src/models.rs +++ b/codex-rs/protocol/src/models.rs @@ -2072,7 +2072,8 @@ pub fn local_image_content_items_with_label_number( ImageProcessingError::Read { .. } | ImageProcessingError::Encode { .. } | ImageProcessingError::InvalidDataUrl { .. } - | ImageProcessingError::ImageTooLarge { .. } => { + | ImageProcessingError::ImageTooLarge { .. } + | ImageProcessingError::ImageTooManyPixels { .. } => { vec![local_media_error_placeholder( path, &err, diff --git a/codex-rs/utils/image/Cargo.toml b/codex-rs/utils/image/Cargo.toml index 3ac43b6df0..2be40a8e2e 100644 --- a/codex-rs/utils/image/Cargo.toml +++ b/codex-rs/utils/image/Cargo.toml @@ -14,8 +14,9 @@ ignored = ["png"] [dependencies] base64 = { workspace = true } -image = { workspace = true, features = ["jpeg", "png", "gif", "webp"] } -png = { version = "0.18.0", features = ["zlib-rs"] } +image = { version = "=0.25.9", default-features = false, features = ["jpeg", "png", "gif", "webp"] } +moxcms = { version = "=0.7.11", features = ["options"] } +png = { version = "=0.18.1", features = ["zlib-rs"] } codex-utils-cache = { workspace = true } mime_guess = { workspace = true } thiserror = { workspace = true } diff --git a/codex-rs/utils/image/src/error.rs b/codex-rs/utils/image/src/error.rs index aaa3d4f2f3..5937a59516 100644 --- a/codex-rs/utils/image/src/error.rs +++ b/codex-rs/utils/image/src/error.rs @@ -27,6 +27,8 @@ pub enum ImageProcessingError { UnsupportedImageFormat { mime: String }, #[error("invalid image data URL: {reason}")] InvalidDataUrl { reason: String }, + #[error("image has too many decoded pixels ({pixels}; max {max})")] + ImageTooManyPixels { pixels: u64, max: u64 }, #[error("image {representation} is too large ({size} bytes; max {max} bytes)")] ImageTooLarge { representation: &'static str, diff --git a/codex-rs/utils/image/src/image_tests.rs b/codex-rs/utils/image/src/image_tests.rs index 693c0fefd4..2003f95ade 100644 --- a/codex-rs/utils/image/src/image_tests.rs +++ b/codex-rs/utils/image/src/image_tests.rs @@ -236,8 +236,12 @@ async fn data_url_processing_preserves_supported_source_bytes() { .replacen("data:", "DATA:", 1) .replacen(";base64,", ";BASE64,", 1); - let processed = load_data_url_for_prompt_uncached(&image_url, PromptImageMode::ResizeToFit) - .expect("process data URL image"); + let processed = load_data_url_for_prompt_uncached( + &image_url, + PromptImageMode::ResizeToFit, + PromptImageDecoder::Legacy, + ) + .expect("process data URL image"); assert_eq!(processed.width, 64); assert_eq!(processed.height, 32); @@ -251,8 +255,12 @@ async fn data_url_processing_converts_gif_to_png() { let gif_bytes = image_bytes(&image, ImageFormat::Gif); let image_url = data_url_from_bytes("image/gif", &gif_bytes); - let processed = load_data_url_for_prompt(&image_url, PromptImageMode::ResizeToFit) - .expect("process GIF data URL"); + let processed = load_data_url_for_prompt( + &image_url, + PromptImageMode::ResizeToFit, + PromptImageDecoder::Legacy, + ) + .expect("process GIF data URL"); assert_eq!(processed.mime, "image/png"); assert_eq!( @@ -270,7 +278,11 @@ fn data_url_processing_rejects_malformed_input() { "data:image/png;base64,not base64", ] { assert!(matches!( - load_data_url_for_prompt(image_url, PromptImageMode::ResizeToFit), + load_data_url_for_prompt( + image_url, + PromptImageMode::ResizeToFit, + PromptImageDecoder::Legacy, + ), Err(ImageProcessingError::InvalidDataUrl { .. }) )); } @@ -382,12 +394,29 @@ async fn reprocesses_updated_file_contents() { assert_ne!(second.bytes, first.bytes); } +#[test] +fn legacy_cache_entry_cannot_bypass_sanitization() { + let image = ImageBuffer::from_pixel(7, 11, Rgba([42, 64, 128, 255])); + let bytes = image_bytes_with_metadata(&image, ImageFormat::Jpeg, TEST_RGB_ICC_PROFILE); + let path = Path::new("oriented.jpg"); + let mode = PromptImageMode::Original; + + let legacy = load_for_prompt_bytes(path, bytes.clone(), mode).expect("load legacy image"); + let sanitized = + load_for_prompt_bytes_with_decoder(path, bytes, mode, PromptImageDecoder::Sanitizer) + .expect("sanitize image after warming the legacy cache"); + + assert_eq!((legacy.width, legacy.height), (7, 11)); + assert_eq!((sanitized.width, sanitized.height), (11, 7)); +} + #[tokio::test(flavor = "multi_thread")] async fn bounds_cache_by_encoded_byte_size() { let cache = ImageCache::new(NonZeroUsize::new(4).expect("non-zero cache capacity")); let key = |digest_byte| ImageCacheKey { digest: [digest_byte; 20], mode: PromptImageMode::Original, + decoder: PromptImageDecoder::Legacy, }; let image = |size| EncodedImage { bytes: vec![0; size].into(), diff --git a/codex-rs/utils/image/src/lib.rs b/codex-rs/utils/image/src/lib.rs index 58b5050ace..2ebaaf858e 100644 --- a/codex-rs/utils/image/src/lib.rs +++ b/codex-rs/utils/image/src/lib.rs @@ -29,9 +29,13 @@ pub const MAX_DIMENSION: u32 = 2048; /// This is a high sanity guard against pathological inputs, not a protocol /// requirement or target upload size. pub const MAX_PROMPT_IMAGE_INPUT_BYTES: usize = 1024 * 1024 * 1024; +/// Use the same byte limit for input and output so a saved image fits on replay. +/// At the pixel limit, 16-bit RGBA uses 81,920,000 bytes. Round up for PNG overhead. +pub const MAX_SANITIZED_IMAGE_BYTES: usize = 79 * 1024 * 1024; const MAX_IMAGE_CACHE_BYTES: usize = 64 * 1024 * 1024; pub mod error; +mod sanitizer; pub use crate::error::ImageProcessingError; @@ -64,6 +68,13 @@ pub enum PromptImageMode { ResizeWithLimits(PromptImageResizeLimits), } +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)] +pub enum PromptImageDecoder { + #[default] + Legacy, + Sanitizer, +} + #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub struct PromptImageResizeLimits { pub max_dimension: u32, @@ -92,6 +103,7 @@ struct ImageMetadata { struct ImageCacheKey { digest: [u8; 20], mode: PromptImageMode, + decoder: PromptImageDecoder, } type ImageCache = BlockingLruCache; @@ -104,18 +116,26 @@ pub fn load_for_prompt_bytes( file_bytes: Vec, mode: PromptImageMode, ) -> Result { - let path_buf = path.to_path_buf(); + load_for_prompt_bytes_with_decoder(path, file_bytes, mode, PromptImageDecoder::Legacy) +} +fn load_for_prompt_bytes_with_decoder( + path: &Path, + file_bytes: Vec, + mode: PromptImageMode, + decoder: PromptImageDecoder, +) -> Result { let key = ImageCacheKey { digest: sha1_digest(&file_bytes), mode, + decoder, }; if let Some(image) = IMAGE_CACHE.get(&key) { return Ok(image); } - let image = load_for_prompt_bytes_uncached(&path_buf, file_bytes, mode)?; + let image = load_for_prompt_bytes_uncached(path, file_bytes, mode, decoder)?; cache_image(&IMAGE_CACHE, key, image.clone(), MAX_IMAGE_CACHE_BYTES); Ok(image) } @@ -124,7 +144,11 @@ fn load_for_prompt_bytes_uncached( path: &Path, file_bytes: Vec, mode: PromptImageMode, + decoder: PromptImageDecoder, ) -> Result { + if decoder == PromptImageDecoder::Sanitizer { + return sanitizer::sanitize(path, &file_bytes, mode); + } let path_buf = path.to_path_buf(); (move || { let guessed_format = image::guess_format(&file_bytes) @@ -159,26 +183,7 @@ fn load_for_prompt_bytes_uncached( let (width, height) = dynamic.dimensions(); - let target_dimensions = match mode { - PromptImageMode::ResizeToFit if width > MAX_DIMENSION || height > MAX_DIMENSION => { - let resized = dynamic.resize(MAX_DIMENSION, MAX_DIMENSION, FilterType::Triangle); - Some((resized.width(), resized.height(), resized)) - } - PromptImageMode::ResizeWithLimits(limits) => { - let (target_width, target_height) = - prompt_image_output_dimensions_for_limits(width, height, limits); - if (target_width, target_height) == (width, height) { - None - } else { - let resized = - dynamic.resize_exact(target_width, target_height, FilterType::Triangle); - Some((target_width, target_height, resized)) - } - } - PromptImageMode::ResizeToFit | PromptImageMode::Original => None, - }; - - let encoded = if let Some((prepared_width, prepared_height, resized)) = target_dimensions { + let encoded = if let Some(resized) = resize_for_prompt(&dynamic, mode) { let target_format = format .filter(|format| can_preserve_source_bytes(*format)) .unwrap_or(ImageFormat::Png); @@ -189,8 +194,8 @@ fn load_for_prompt_bytes_uncached( mime, source_width: width, source_height: height, - width: prepared_width, - height: prepared_height, + width: resized.width(), + height: resized.height(), } } else { if let Some(format) = format.filter(|format| can_preserve_source_bytes(*format)) { @@ -244,22 +249,25 @@ fn cache_image(cache: &ImageCache, key: ImageCacheKey, image: EncodedImage, byte pub fn load_data_url_for_prompt( image_url: &str, mode: PromptImageMode, + decoder: PromptImageDecoder, ) -> Result { - load_data_url_for_prompt_with(image_url, mode, load_for_prompt_bytes) + let bytes = decode_data_url(image_url, decoder)?; + load_for_prompt_bytes_with_decoder(Path::new(""), bytes, mode, decoder) } pub fn load_data_url_for_prompt_uncached( image_url: &str, mode: PromptImageMode, + decoder: PromptImageDecoder, ) -> Result { - load_data_url_for_prompt_with(image_url, mode, load_for_prompt_bytes_uncached) + let bytes = decode_data_url(image_url, decoder)?; + load_for_prompt_bytes_uncached(Path::new(""), bytes, mode, decoder) } -fn load_data_url_for_prompt_with( +fn decode_data_url( image_url: &str, - mode: PromptImageMode, - load: impl FnOnce(&Path, Vec, PromptImageMode) -> Result, -) -> Result { + decoder: PromptImageDecoder, +) -> Result, ImageProcessingError> { let rest = image_url .get(..DATA_URL_PREFIX.len()) .filter(|prefix| prefix.eq_ignore_ascii_case(DATA_URL_PREFIX)) @@ -281,11 +289,18 @@ fn load_data_url_for_prompt_with( }); } - if encoded.len() > MAX_PROMPT_IMAGE_INPUT_BYTES { + let (max_bytes, max_base64_bytes) = match decoder { + PromptImageDecoder::Legacy => (MAX_PROMPT_IMAGE_INPUT_BYTES, MAX_PROMPT_IMAGE_INPUT_BYTES), + PromptImageDecoder::Sanitizer => ( + MAX_SANITIZED_IMAGE_BYTES, + MAX_SANITIZED_IMAGE_BYTES.div_ceil(3) * 4, + ), + }; + if encoded.len() > max_base64_bytes { return Err(ImageProcessingError::ImageTooLarge { representation: "base64 payload", size: encoded.len(), - max: MAX_PROMPT_IMAGE_INPUT_BYTES, + max: max_base64_bytes, }); } let file_bytes = @@ -294,15 +309,33 @@ fn load_data_url_for_prompt_with( .map_err(|source| ImageProcessingError::InvalidDataUrl { reason: format!("invalid base64 payload: {source}"), })?; - if file_bytes.len() > MAX_PROMPT_IMAGE_INPUT_BYTES { + if file_bytes.len() > max_bytes { return Err(ImageProcessingError::ImageTooLarge { representation: "decoded input", size: file_bytes.len(), - max: MAX_PROMPT_IMAGE_INPUT_BYTES, + max: max_bytes, }); } - load(Path::new(""), file_bytes, mode) + Ok(file_bytes) +} + +fn resize_for_prompt(image: &DynamicImage, mode: PromptImageMode) -> Option { + let (width, height) = image.dimensions(); + match mode { + PromptImageMode::ResizeToFit if width > MAX_DIMENSION || height > MAX_DIMENSION => { + Some(image.resize(MAX_DIMENSION, MAX_DIMENSION, FilterType::Triangle)) + } + PromptImageMode::ResizeWithLimits(limits) => { + let (target_width, target_height) = + prompt_image_output_dimensions_for_limits(width, height, limits); + if (target_width, target_height) == (width, height) { + return None; + } + Some(image.resize_exact(target_width, target_height, FilterType::Triangle)) + } + PromptImageMode::ResizeToFit | PromptImageMode::Original => None, + } } fn prompt_image_output_dimensions_for_limits( diff --git a/codex-rs/utils/image/src/sanitizer.rs b/codex-rs/utils/image/src/sanitizer.rs new file mode 100644 index 0000000000..505f1d6f1a --- /dev/null +++ b/codex-rs/utils/image/src/sanitizer.rs @@ -0,0 +1,167 @@ +//! Decode supported prompt images and write fresh PNG or JPEG bytes without the source metadata. + +use std::io::Cursor; +use std::path::Path; + +use image::DynamicImage; +use image::ImageDecoder; +use image::ImageFormat; +use image::ImageReader; +use image::codecs::jpeg::JpegEncoder; +use image::codecs::png::CompressionType; +use image::codecs::png::FilterType; +use image::codecs::png::PngEncoder; +use moxcms::ColorProfile; + +use crate::EncodedImage; +use crate::ImageProcessingError; +use crate::MAX_SANITIZED_IMAGE_BYTES; +use crate::PromptImageMode; +use crate::resize_for_prompt; + +// Original detail allows 10,000 patches of 32 × 32 pixels each. +const MAX_DECODED_PIXELS: u64 = 10_240_000; + +pub(super) fn sanitize( + path: &Path, + bytes: &[u8], + mode: PromptImageMode, +) -> Result { + if bytes.len() > MAX_SANITIZED_IMAGE_BYTES { + return Err(ImageProcessingError::ImageTooLarge { + representation: "input", + size: bytes.len(), + max: MAX_SANITIZED_IMAGE_BYTES, + }); + } + + let decode_error = |source| ImageProcessingError::decode_error(path, source); + let reader = ImageReader::new(Cursor::new(bytes)) + .with_guessed_format() + .map_err(|error| decode_error(error.into()))?; + let (format, output_format) = match reader.format() { + Some(format @ (ImageFormat::Png | ImageFormat::Gif)) => (format, ImageFormat::Png), + Some(format @ (ImageFormat::Jpeg | ImageFormat::WebP)) => (format, ImageFormat::Jpeg), + format => { + return Err(ImageProcessingError::UnsupportedImageFormat { + mime: format + .as_ref() + .map_or("unknown", ImageFormat::to_mime_type) + .to_string(), + }); + } + }; + + let (mut img, orientation, icc_profile) = std::panic::catch_unwind(|| { + let mut decoder = reader.into_decoder().map_err(decode_error)?; + let (width, height) = decoder.dimensions(); + let pixels = u64::from(width) * u64::from(height); + if pixels > MAX_DECODED_PIXELS { + return Err(ImageProcessingError::ImageTooManyPixels { + pixels, + max: MAX_DECODED_PIXELS, + }); + } + let icc_profile = decoder.icc_profile().ok().flatten(); + // Read EXIF orientation only for JPEG. + let orientation = if format == ImageFormat::Jpeg { + decoder.orientation().ok() + } else { + None + }; + let img = DynamicImage::from_decoder(decoder).map_err(decode_error)?; + Ok((img, orientation, icc_profile)) + }) + .map_err(|_| { + decode_error(image::ImageError::Decoding( + image::error::DecodingError::new( + format.into(), + "image decoder rejected malformed data", + ), + )) + })??; + + if let Some(orientation) = orientation { + img.apply_orientation(orientation); + } + let (source_width, source_height) = (img.width(), img.height()); + if format == ImageFormat::WebP { + // Drop WebP alpha before resizing because the JPEG output has no alpha channel. + img = img.into_rgb8().into(); + } + + if let Some(resized) = resize_for_prompt(&img, mode) { + img = resized; + } + + if let Some(icc_profile) = icc_profile + && let Some(new_img) = convert_color_profile(&img, &icc_profile) + { + img = new_img; + } + + let mut output = Vec::new(); + let encoded = if output_format == ImageFormat::Png { + img.write_with_encoder(PngEncoder::new_with_quality( + &mut output, + CompressionType::Fast, + FilterType::Adaptive, + )) + } else { + img.write_with_encoder(JpegEncoder::new_with_quality( + &mut output, + /*quality*/ 90, + )) + }; + encoded.map_err(|source| ImageProcessingError::Encode { + format: output_format, + source, + })?; + if output.len() > MAX_SANITIZED_IMAGE_BYTES { + return Err(ImageProcessingError::ImageTooLarge { + representation: "sanitized output", + size: output.len(), + max: MAX_SANITIZED_IMAGE_BYTES, + }); + } + + Ok(EncodedImage { + bytes: output.into(), + mime: output_format.to_mime_type().to_string(), + source_width, + source_height, + width: img.width(), + height: img.height(), + }) +} + +fn convert_color_profile(img: &DynamicImage, icc_profile: &[u8]) -> Option { + // Convert color profiles only for 8-bit RGB and RGBA images. + let layout = match img.color() { + image::ColorType::Rgba8 => moxcms::Layout::Rgba, + image::ColorType::Rgb8 => moxcms::Layout::Rgb, + _ => return None, + }; + + let parsed_icc_profile = ColorProfile::new_from_slice(icc_profile).ok()?; + let dest_profile = ColorProfile::new_srgb(); + + let transform_opts = moxcms::TransformOptions { + // Use floating point for more precise color conversion. + prefer_fixed_point: false, + ..Default::default() + }; + + let transform = parsed_icc_profile + .create_transform_8bit(layout, &dest_profile, layout, transform_opts) + .ok()?; + + let mut output = vec![0; img.as_bytes().len()]; + transform.transform(img.as_bytes(), &mut output).ok()?; + let (width, height) = (img.width(), img.height()); + if layout == moxcms::Layout::Rgba { + image::RgbaImage::from_raw(width, height, output).map(DynamicImage::ImageRgba8) + } else { + image::RgbImage::from_raw(width, height, output).map(DynamicImage::ImageRgb8) + } +}