From 7baf7e467e9bd8a34772c14a1fd5edbe9039bea1 Mon Sep 17 00:00:00 2001 From: pakrym-oai Date: Tue, 16 Jun 2026 11:27:46 -0700 Subject: [PATCH] [codex] Route MCP file uploads through environment filesystem (#27923) ## Why Codex Apps tools can mark arguments with `openai/fileParams`, but the execution path resolved and opened those files directly on the host. That bypassed the selected turn environment and prevented annotated file arguments from working with remote environments. ## What changed - resolve annotated file arguments against the primary turn environment - read file metadata and contents through that environment's sandboxed `ExecutorFileSystem` - reject files over the 512 MiB limit from metadata before reading or transferring them - retain the buffered upload-size check as defense in depth - make the OpenAI upload API accept a filename and buffered contents instead of owning local filesystem access - describe the model-visible argument as a path in the primary environment This builds on #27927, which added `size` to internal filesystem metadata. ## Testing - `just test -p codex-api upload_openai_file_returns_canonical_uri` - `just test -p codex-mcp tool_with_model_visible_input_schema_masks_file_params` - `just test -p codex-core mcp_openai_file` - `just test -p codex-core codex_apps_file_params_upload_environment_files_before_mcp_tool_call` --- codex-rs/Cargo.lock | 1 - codex-rs/codex-api/Cargo.toml | 1 - codex-rs/codex-api/src/files.rs | 92 ++++------- codex-rs/codex-api/src/lib.rs | 3 +- codex-rs/core/src/mcp_openai_file.rs | 157 +++++++++++++------ codex-rs/core/tests/suite/openai_file_mcp.rs | 115 +++++++++----- 6 files changed, 218 insertions(+), 151 deletions(-) diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index c8e1f823f..59cea06ed 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -1946,7 +1946,6 @@ dependencies = [ "schemars 0.8.22", "serde", "serde_json", - "tempfile", "thiserror 2.0.18", "tokio", "tokio-test", diff --git a/codex-rs/codex-api/Cargo.toml b/codex-rs/codex-api/Cargo.toml index 3b34b1569..72aa66a81 100644 --- a/codex-rs/codex-api/Cargo.toml +++ b/codex-rs/codex-api/Cargo.toml @@ -32,7 +32,6 @@ url = { workspace = true } anyhow = { workspace = true } assert_matches = { workspace = true } pretty_assertions = { workspace = true } -tempfile = { workspace = true } tokio-test = { workspace = true } wiremock = { workspace = true } reqwest = { workspace = true } diff --git a/codex-rs/codex-api/src/files.rs b/codex-rs/codex-api/src/files.rs index d1e284006..8bcf1a4d0 100644 --- a/codex-rs/codex-api/src/files.rs +++ b/codex-rs/codex-api/src/files.rs @@ -1,15 +1,13 @@ -use std::path::Path; -use std::path::PathBuf; use std::time::Duration; use crate::AuthProvider; +use bytes::Bytes; use codex_client::build_reqwest_client_with_custom_ca; +use futures::Stream; use reqwest::StatusCode; use reqwest::header::CONTENT_LENGTH; use serde::Deserialize; -use tokio::fs::File; use tokio::time::Instant; -use tokio_util::io::ReaderStream; pub const OPENAI_FILE_URI_PREFIX: &str = "sediment://"; pub const OPENAI_FILE_UPLOAD_LIMIT_BYTES: u64 = 512 * 1024 * 1024; @@ -27,26 +25,15 @@ pub struct UploadedOpenAiFile { pub file_name: String, pub file_size_bytes: u64, pub mime_type: Option, - pub path: PathBuf, } #[derive(Debug, thiserror::Error)] pub enum OpenAiFileError { - #[error("path `{path}` does not exist")] - MissingPath { path: PathBuf }, - #[error("path `{path}` is not a file")] - NotAFile { path: PathBuf }, - #[error("path `{path}` cannot be read: {source}")] - ReadFile { - path: PathBuf, - #[source] - source: std::io::Error, - }, #[error( - "file `{path}` is too large: {size_bytes} bytes exceeds the limit of {limit_bytes} bytes" + "file `{file_name}` is too large: {size_bytes} bytes exceeds the limit of {limit_bytes} bytes" )] FileTooLarge { - path: PathBuf, + file_name: String, size_bytes: u64, limit_bytes: u64, }, @@ -94,45 +81,26 @@ pub fn openai_file_uri(file_id: &str) -> String { format!("{OPENAI_FILE_URI_PREFIX}{file_id}") } -pub async fn upload_local_file( +pub async fn upload_openai_file( base_url: &str, auth: &dyn AuthProvider, - path: &Path, + file_name: String, + file_size_bytes: u64, + contents: impl Stream> + Send + 'static, ) -> Result { - let metadata = tokio::fs::metadata(path) - .await - .map_err(|source| match source.kind() { - std::io::ErrorKind::NotFound => OpenAiFileError::MissingPath { - path: path.to_path_buf(), - }, - _ => OpenAiFileError::ReadFile { - path: path.to_path_buf(), - source, - }, - })?; - if !metadata.is_file() { - return Err(OpenAiFileError::NotAFile { - path: path.to_path_buf(), - }); - } - if metadata.len() > OPENAI_FILE_UPLOAD_LIMIT_BYTES { + if file_size_bytes > OPENAI_FILE_UPLOAD_LIMIT_BYTES { return Err(OpenAiFileError::FileTooLarge { - path: path.to_path_buf(), - size_bytes: metadata.len(), + file_name, + size_bytes: file_size_bytes, limit_bytes: OPENAI_FILE_UPLOAD_LIMIT_BYTES, }); } - let file_name = path - .file_name() - .and_then(|value| value.to_str()) - .unwrap_or("file") - .to_string(); let create_url = format!("{}/files", base_url.trim_end_matches('/')); let create_response = authorized_request(auth, reqwest::Method::POST, &create_url) .json(&serde_json::json!({ - "file_name": file_name, - "file_size": metadata.len(), + "file_name": file_name.as_str(), + "file_size": file_size_bytes, "use_case": OPENAI_FILE_USE_CASE, })) .send() @@ -156,18 +124,12 @@ pub async fn upload_local_file( source, })?; - let upload_file = File::open(path) - .await - .map_err(|source| OpenAiFileError::ReadFile { - path: path.to_path_buf(), - source, - })?; let upload_response = build_reqwest_client() .put(&create_payload.upload_url) .timeout(OPENAI_FILE_REQUEST_TIMEOUT) .header("x-ms-blob-type", "BlockBlob") - .header(CONTENT_LENGTH, metadata.len()) - .body(reqwest::Body::wrap_stream(ReaderStream::new(upload_file))) + .header(CONTENT_LENGTH, file_size_bytes) + .body(reqwest::Body::wrap_stream(contents)) .send() .await .map_err(|source| OpenAiFileError::Request { @@ -226,9 +188,8 @@ pub async fn upload_local_file( } })?, file_name: finalize_payload.file_name.unwrap_or(file_name), - file_size_bytes: metadata.len(), + file_size_bytes, mime_type: finalize_payload.mime_type, - path: path.to_path_buf(), }); } "retry" => { @@ -281,7 +242,6 @@ mod tests { use std::sync::Arc; use std::sync::atomic::AtomicUsize; use std::sync::atomic::Ordering; - use tempfile::TempDir; use wiremock::Mock; use wiremock::MockServer; use wiremock::Request; @@ -313,7 +273,7 @@ mod tests { } #[tokio::test] - async fn upload_local_file_returns_canonical_uri() { + async fn upload_openai_file_returns_canonical_uri() { let server = MockServer::start().await; Mock::given(method("POST")) .and(path("/backend-api/files")) @@ -359,13 +319,17 @@ mod tests { .await; let base_url = base_url_for(&server); - let dir = TempDir::new().expect("temp dir"); - let path = dir.path().join("hello.txt"); - tokio::fs::write(&path, b"hello").await.expect("write file"); - - let uploaded = upload_local_file(&base_url, &chatgpt_auth(), &path) - .await - .expect("upload succeeds"); + let contents = + futures::stream::iter([Ok::<_, std::io::Error>(Bytes::from_static(b"hello"))]); + let uploaded = upload_openai_file( + &base_url, + &chatgpt_auth(), + "hello.txt".to_string(), + /*file_size_bytes*/ 5, + contents, + ) + .await + .expect("upload succeeds"); assert_eq!(uploaded.file_id, "file_123"); assert_eq!(uploaded.uri, "sediment://file_123"); diff --git a/codex-rs/codex-api/src/lib.rs b/codex-rs/codex-api/src/lib.rs index cc9b58c03..df6723f02 100644 --- a/codex-rs/codex-api/src/lib.rs +++ b/codex-rs/codex-api/src/lib.rs @@ -65,7 +65,8 @@ pub use crate::endpoint::ResponsesWebsocketProbe; pub use crate::endpoint::SearchClient; pub use crate::endpoint::session_update_session_json; pub use crate::error::ApiError; -pub use crate::files::upload_local_file; +pub use crate::files::OPENAI_FILE_UPLOAD_LIMIT_BYTES; +pub use crate::files::upload_openai_file; pub use crate::images::ImageBackground; pub use crate::images::ImageData; pub use crate::images::ImageEditRequest; diff --git a/codex-rs/core/src/mcp_openai_file.rs b/codex-rs/core/src/mcp_openai_file.rs index ae44515c6..7c1e4d7c8 100644 --- a/codex-rs/core/src/mcp_openai_file.rs +++ b/codex-rs/core/src/mcp_openai_file.rs @@ -3,7 +3,8 @@ //! Strategy: //! - Inspect `_meta["openai/fileParams"]` to discover which tool arguments are //! file inputs. -//! - At tool execution time, upload those local files to OpenAI file storage +//! - At tool execution time, read those files from the primary environment, +//! upload them to OpenAI file storage, //! and rewrite only the declared arguments into the provided-file payload //! shape expected by the downstream Apps tool. //! @@ -12,8 +13,10 @@ use crate::session::session::Session; use crate::session::turn_context::TurnContext; -use codex_api::upload_local_file; +use codex_api::OPENAI_FILE_UPLOAD_LIMIT_BYTES; +use codex_api::upload_openai_file; use codex_login::CodexAuth; +use codex_utils_path_uri::PathUri; use serde_json::Value as JsonValue; pub(crate) async fn rewrite_mcp_tool_arguments_for_openai_files( @@ -62,13 +65,13 @@ async fn rewrite_argument_value_for_openai_files( value: &JsonValue, ) -> Result, String> { match value { - JsonValue::String(path_or_file_ref) => { - let rewritten = build_uploaded_local_argument_value( + JsonValue::String(file_path) => { + let rewritten = build_uploaded_argument_value( turn_context, auth, field_name, /*index*/ None, - path_or_file_ref, + file_path, ) .await?; Ok(Some(rewritten)) @@ -76,15 +79,15 @@ async fn rewrite_argument_value_for_openai_files( JsonValue::Array(values) => { let mut rewritten_values = Vec::with_capacity(values.len()); for (index, item) in values.iter().enumerate() { - let Some(path_or_file_ref) = item.as_str() else { + let Some(file_path) = item.as_str() else { return Ok(None); }; - let rewritten = build_uploaded_local_argument_value( + let rewritten = build_uploaded_argument_value( turn_context, auth, field_name, Some(index), - path_or_file_ref, + file_path, ) .await?; rewritten_values.push(rewritten); @@ -95,38 +98,70 @@ async fn rewrite_argument_value_for_openai_files( } } -async fn build_uploaded_local_argument_value( +async fn build_uploaded_argument_value( turn_context: &TurnContext, auth: Option<&CodexAuth>, field_name: &str, index: Option, file_path: &str, ) -> Result { - #[allow(deprecated)] - let resolved_path = turn_context.resolve_path(Some(file_path.to_string())); - let Some(auth) = auth else { - return Err( - "ChatGPT auth is required to upload local files for Codex Apps tools".to_string(), - ); - }; - if !auth.uses_codex_backend() { - return Err( - "ChatGPT auth is required to upload local files for Codex Apps tools".to_string(), - ); - } - let upload_auth = codex_model_provider::auth_provider_from_auth(auth); - let uploaded = upload_local_file( - turn_context.config.chatgpt_base_url.trim_end_matches('/'), - upload_auth.as_ref(), - &resolved_path, - ) - .await - .map_err(|error| match index { + let contextualize_error = |error: String| match index { Some(index) => { format!("failed to upload `{file_path}` for `{field_name}[{index}]`: {error}") } None => format!("failed to upload `{file_path}` for `{field_name}`: {error}"), - })?; + }; + let Some(auth) = auth else { + return Err("ChatGPT auth is required to upload files for Codex Apps tools".to_string()); + }; + if !auth.uses_codex_backend() { + return Err("ChatGPT auth is required to upload files for Codex Apps tools".to_string()); + } + let Some(turn_environment) = turn_context.environments.primary() else { + return Err(contextualize_error( + "no primary turn environment is available".to_string(), + )); + }; + let resolved_path = turn_environment.cwd().join(file_path); + let path_uri = PathUri::from_abs_path(&resolved_path); + let fs = turn_environment.environment.get_filesystem(); + let metadata = fs + .get_metadata(&path_uri, /*sandbox*/ None) + .await + .map_err(|error| contextualize_error(error.to_string()))?; + if !metadata.is_file { + return Err(contextualize_error(format!( + "path `{}` is not a file", + resolved_path.display() + ))); + } + if metadata.size > OPENAI_FILE_UPLOAD_LIMIT_BYTES { + return Err(contextualize_error(format!( + "file `{}` is too large: {} bytes exceeds the limit of {} bytes", + resolved_path.display(), + metadata.size, + OPENAI_FILE_UPLOAD_LIMIT_BYTES, + ))); + } + let contents = fs + .read_file_stream(&path_uri, /*sandbox*/ None) + .await + .map_err(|error| contextualize_error(error.to_string()))?; + let file_name = resolved_path + .file_name() + .and_then(|value| value.to_str()) + .unwrap_or("file") + .to_string(); + let upload_auth = codex_model_provider::auth_provider_from_auth(auth); + let uploaded = upload_openai_file( + turn_context.config.chatgpt_base_url.trim_end_matches('/'), + upload_auth.as_ref(), + file_name, + metadata.size, + contents, + ) + .await + .map_err(|error| contextualize_error(error.to_string()))?; Ok(serde_json::json!({ "download_url": uploaded.download_url, "file_id": uploaded.file_id, @@ -141,11 +176,29 @@ async fn build_uploaded_local_argument_value( mod tests { use super::*; use crate::session::tests::make_session_and_context; + use crate::session::turn_context::TurnEnvironment; use codex_utils_absolute_path::AbsolutePathBuf; use pretty_assertions::assert_eq; + use std::path::Path; use std::sync::Arc; use tempfile::tempdir; + fn set_primary_environment_cwd(turn_context: &mut TurnContext, cwd: &Path) { + let cwd = AbsolutePathBuf::try_from(cwd).expect("absolute path"); + turn_context.permission_profile = codex_protocol::models::PermissionProfile::Disabled; + let primary = turn_context + .environments + .turn_environments + .first_mut() + .expect("primary environment"); + *primary = TurnEnvironment::new( + primary.environment_id.clone(), + Arc::clone(&primary.environment), + cwd, + primary.shell.clone(), + ); + } + #[tokio::test] async fn openai_file_argument_rewrite_requires_declared_file_params() { let (session, turn_context) = make_session_and_context().await; @@ -166,7 +219,7 @@ mod tests { } #[tokio::test] - async fn build_uploaded_local_argument_value_uploads_local_file_path() { + async fn build_uploaded_argument_value_uploads_environment_file() { use wiremock::Mock; use wiremock::MockServer; use wiremock::ResponseTemplate; @@ -217,16 +270,13 @@ mod tests { tokio::fs::write(&local_path, b"hello") .await .expect("write local file"); - #[allow(deprecated)] - { - turn_context.cwd = AbsolutePathBuf::try_from(dir.path()).expect("absolute path"); - } + set_primary_environment_cwd(&mut turn_context, dir.path()); let mut config = (*turn_context.config).clone(); config.chatgpt_base_url = format!("{}/backend-api", server.uri()); turn_context.config = Arc::new(config); - let rewritten = build_uploaded_local_argument_value( + let rewritten = build_uploaded_argument_value( &turn_context, Some(&auth), "file", @@ -249,6 +299,31 @@ mod tests { ); } + #[tokio::test] + async fn build_uploaded_argument_value_rejects_oversized_file_before_reading() { + let (_, mut turn_context) = make_session_and_context().await; + let auth = CodexAuth::create_dummy_chatgpt_auth_for_testing(); + let dir = tempdir().expect("temp dir"); + let file_path = dir.path().join("oversized.bin"); + let file = std::fs::File::create(&file_path).expect("create sparse file"); + file.set_len(OPENAI_FILE_UPLOAD_LIMIT_BYTES + 1) + .expect("size sparse file"); + set_primary_environment_cwd(&mut turn_context, dir.path()); + + let error = build_uploaded_argument_value( + &turn_context, + Some(&auth), + "file", + /*index*/ None, + "oversized.bin", + ) + .await + .expect_err("oversized file should be rejected"); + + assert!(error.contains("is too large")); + assert!(error.contains(&(OPENAI_FILE_UPLOAD_LIMIT_BYTES + 1).to_string())); + } + #[tokio::test] async fn rewrite_argument_value_for_openai_files_rewrites_scalar_path() { use wiremock::Mock; @@ -301,10 +376,7 @@ mod tests { tokio::fs::write(&local_path, b"hello") .await .expect("write local file"); - #[allow(deprecated)] - { - turn_context.cwd = AbsolutePathBuf::try_from(dir.path()).expect("absolute path"); - } + set_primary_environment_cwd(&mut turn_context, dir.path()); let mut config = (*turn_context.config).clone(); config.chatgpt_base_url = format!("{}/backend-api", server.uri()); @@ -418,10 +490,7 @@ mod tests { tokio::fs::write(dir.path().join("two.csv"), b"two") .await .expect("write second local file"); - #[allow(deprecated)] - { - turn_context.cwd = AbsolutePathBuf::try_from(dir.path()).expect("absolute path"); - } + set_primary_environment_cwd(&mut turn_context, dir.path()); let mut config = (*turn_context.config).clone(); config.chatgpt_base_url = format!("{}/backend-api", server.uri()); diff --git a/codex-rs/core/tests/suite/openai_file_mcp.rs b/codex-rs/core/tests/suite/openai_file_mcp.rs index 1f7d26be5..edc14cead 100644 --- a/codex-rs/core/tests/suite/openai_file_mcp.rs +++ b/codex-rs/core/tests/suite/openai_file_mcp.rs @@ -7,6 +7,7 @@ use anyhow::Context; use anyhow::Result; use codex_protocol::models::PermissionProfile; use codex_protocol::protocol::AskForApproval; +use codex_utils_path_uri::PathUri; use core_test_support::apps_test_server::AppsTestServer; use core_test_support::apps_test_server::CALENDAR_EXTRACT_TEXT_TOOL_NAME; use core_test_support::apps_test_server::DIRECT_CALENDAR_EXTRACT_TEXT_TOOL as DOCUMENT_EXTRACT_HOOK_MATCHER; @@ -16,6 +17,7 @@ use core_test_support::apps_test_server::SEARCH_CALENDAR_NAMESPACE as DOCUMENT_E use core_test_support::apps_test_server::apps_enabled_builder; use core_test_support::apps_test_server::recorded_apps_tool_call_by_name; use core_test_support::hooks::trust_discovered_hooks; +use core_test_support::responses::ResponseMock; use core_test_support::responses::ev_assistant_message; use core_test_support::responses::ev_completed; use core_test_support::responses::ev_function_call_with_namespace; @@ -23,16 +25,20 @@ use core_test_support::responses::ev_response_created; 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::test_codex::TestCodex; use pretty_assertions::assert_eq; use serde_json::Value; use serde_json::json; use wiremock::Mock; +use wiremock::MockServer; use wiremock::ResponseTemplate; use wiremock::matchers::body_json; use wiremock::matchers::header; use wiremock::matchers::method; use wiremock::matchers::path; +const STREAMED_FILE_SIZE: usize = 13 * 1024 * 1024; + fn write_post_tool_use_hook(home: &Path) -> Result<()> { let script_path = home.join("post_tool_use_hook.py"); let log_path = home.join("post_tool_use_hook_log.jsonl"); @@ -82,17 +88,24 @@ fn read_post_tool_use_hook_inputs(home: &Path) -> Result> { .collect() } -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn codex_apps_file_params_upload_local_paths_before_mcp_tool_call() -> Result<()> { - let server = start_mock_server().await; - let apps_server = AppsTestServer::mount(&server).await?; +fn uploaded_file(server: &MockServer, file_size_bytes: u64) -> Value { + json!({ + "download_url": format!("{}/download/file_123", server.uri()), + "file_id": "file_123", + "mime_type": "text/plain", + "file_name": "report.txt", + "uri": "sediment://file_123", + "file_size_bytes": file_size_bytes, + }) +} +async fn mount_file_upload_mocks(server: &MockServer, file_size_bytes: u64) { Mock::given(method("POST")) .and(path("/files")) .and(header("chatgpt-account-id", "account_id")) .and(body_json(json!({ "file_name": "report.txt", - "file_size": 11, + "file_size": file_size_bytes, "use_case": "codex", }))) .respond_with(ResponseTemplate::new(200).set_body_json(json!({ @@ -100,14 +113,14 @@ async fn codex_apps_file_params_upload_local_paths_before_mcp_tool_call() -> Res "upload_url": format!("{}/upload/file_123", server.uri()), }))) .expect(1) - .mount(&server) + .mount(server) .await; Mock::given(method("PUT")) .and(path("/upload/file_123")) - .and(header("content-length", "11")) + .and(header("content-length", file_size_bytes.to_string())) .respond_with(ResponseTemplate::new(200)) .expect(1) - .mount(&server) + .mount(server) .await; Mock::given(method("POST")) .and(path("/files/file_123/uploaded")) @@ -116,20 +129,21 @@ async fn codex_apps_file_params_upload_local_paths_before_mcp_tool_call() -> Res "download_url": format!("{}/download/file_123", server.uri()), "file_name": "report.txt", "mime_type": "text/plain", - "file_size_bytes": 11, + "file_size_bytes": file_size_bytes, }))) .expect(1) - .mount(&server) + .mount(server) .await; +} - let call_id = "extract-call-1"; +async fn run_extract_turn(test: &TestCodex, server: &MockServer) -> Result { let mock = mount_sse_sequence( - &server, + server, vec![ sse(vec![ ev_response_created("resp-1"), ev_function_call_with_namespace( - call_id, + "extract-call-1", DOCUMENT_EXTRACT_NAMESPACE, DOCUMENT_EXTRACT_TOOL, &json!({"file": "report.txt"}).to_string(), @@ -145,17 +159,6 @@ async fn codex_apps_file_params_upload_local_paths_before_mcp_tool_call() -> Res ) .await; - let mut builder = apps_enabled_builder(apps_server.chatgpt_base_url.clone()) - .with_pre_build_hook(move |home| { - write_post_tool_use_hook(home) - .expect("failed to write apps file post tool use hook fixture"); - }) - .with_config(move |config| { - trust_discovered_hooks(config); - }); - let test = builder.build(&server).await?; - tokio::fs::write(test.cwd.path().join("report.txt"), b"hello world").await?; - test.submit_turn_with_approval_and_permission_profile( "Extract the report text with the app tool.", AskForApproval::Never, @@ -163,6 +166,29 @@ async fn codex_apps_file_params_upload_local_paths_before_mcp_tool_call() -> Res ) .await?; + Ok(mock) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn codex_apps_file_params_upload_environment_files_before_mcp_tool_call() -> Result<()> { + let server = start_mock_server().await; + let apps_server = AppsTestServer::mount(&server).await?; + mount_file_upload_mocks(&server, STREAMED_FILE_SIZE as u64).await; + + let mut builder = apps_enabled_builder(apps_server.chatgpt_base_url.clone()) + .with_workspace_setup(|cwd, fs| async move { + let report_path = PathUri::from_abs_path(&cwd.join("report.txt")); + fs.write_file( + &report_path, + vec![b'x'; STREAMED_FILE_SIZE], + /*sandbox*/ None, + ) + .await?; + Ok(()) + }); + let test = builder.build_with_remote_env(&server).await?; + let mock = run_extract_turn(&test, &server).await?; + let requests = mock.requests(); let body = requests[0].body_json(); let missing_tool_message = format!( @@ -184,14 +210,7 @@ async fn codex_apps_file_params_upload_local_paths_before_mcp_tool_call() -> Res assert_eq!( apps_tool_call.pointer("/params/arguments/file"), - Some(&json!({ - "download_url": format!("{}/download/file_123", server.uri()), - "file_id": "file_123", - "mime_type": "text/plain", - "file_name": "report.txt", - "uri": "sediment://file_123", - "file_size_bytes": 11, - })) + Some(&uploaded_file(&server, STREAMED_FILE_SIZE as u64)) ); assert_eq!( apps_tool_call.pointer("/params/_meta/_codex_apps"), @@ -203,18 +222,34 @@ async fn codex_apps_file_params_upload_local_paths_before_mcp_tool_call() -> Res })) ); + server.verify().await; + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn codex_apps_file_params_pass_uploaded_file_to_post_tool_use_hook() -> Result<()> { + let server = start_mock_server().await; + let apps_server = AppsTestServer::mount(&server).await?; + mount_file_upload_mocks(&server, /*file_size_bytes*/ 11).await; + + let mut builder = apps_enabled_builder(apps_server.chatgpt_base_url.clone()) + .with_pre_build_hook(move |home| { + if let Err(error) = write_post_tool_use_hook(home) { + panic!("failed to write apps file post tool use hook fixture: {error}"); + } + }) + .with_config(move |config| { + trust_discovered_hooks(config); + }); + let test = builder.build(&server).await?; + tokio::fs::write(test.cwd.path().join("report.txt"), b"hello world").await?; + let _responses = run_extract_turn(&test, &server).await?; + let hook_inputs = read_post_tool_use_hook_inputs(test.codex_home_path())?; assert_eq!(hook_inputs.len(), 1); assert_eq!( hook_inputs[0]["tool_input"]["file"], - json!({ - "download_url": format!("{}/download/file_123", server.uri()), - "file_id": "file_123", - "mime_type": "text/plain", - "file_name": "report.txt", - "uri": "sediment://file_123", - "file_size_bytes": 11, - }) + uploaded_file(&server, /*file_size_bytes*/ 11) ); server.verify().await;