//! Bridges Apps SDK-style `openai/fileParams` metadata into Codex's MCP flow.//!//! Strategy://! - Inspect `_meta["openai/fileParams"]` to discover which tool arguments are//! file inputs.//! - 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.//!//! Model-visible schema masking is owned by `codex-mcp` alongside MCP tool//! inventory, so this module only handles the execution-time argument rewrite.use crate::session::session::Session;use crate::session::turn_context::TurnContext;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( sess: &Session, turn_context: &TurnContext, arguments_value: Option<JsonValue>, openai_file_input_params: Option<&[String]>,) -> Result<Option<JsonValue>, String> { let Some(openai_file_input_params) = openai_file_input_params else { return Ok(arguments_value); }; let Some(arguments_value) = arguments_value else { return Ok(None); }; let Some(arguments) = arguments_value.as_object() else { return Ok(Some(arguments_value)); }; let auth = sess.services.auth_manager.auth().await; let mut rewritten_arguments = arguments.clone(); for field_name in openai_file_input_params { let Some(value) = arguments.get(field_name) else { continue; }; let Some(uploaded_value) = rewrite_argument_value_for_openai_files(turn_context, auth.as_ref(), field_name, value) .await? else { continue; }; rewritten_arguments.insert(field_name.clone(), uploaded_value); } if rewritten_arguments == *arguments { return Ok(Some(arguments_value)); } Ok(Some(JsonValue::Object(rewritten_arguments)))}async fn rewrite_argument_value_for_openai_files( turn_context: &TurnContext, auth: Option<&CodexAuth>, field_name: &str, value: &JsonValue,) -> Result<Option<JsonValue>, String> { match value { JsonValue::String(file_path) => { let rewritten = build_uploaded_argument_value( turn_context, auth, field_name, /*index*/ None, file_path, ) .await?; Ok(Some(rewritten)) } JsonValue::Array(values) => { let mut rewritten_values = Vec::with_capacity(values.len()); for (index, item) in values.iter().enumerate() { let Some(file_path) = item.as_str() else { return Ok(None); }; let rewritten = build_uploaded_argument_value( turn_context, auth, field_name, Some(index), file_path, ) .await?; rewritten_values.push(rewritten); } Ok(Some(JsonValue::Array(rewritten_values))) } _ => Ok(None), }}async fn build_uploaded_argument_value( turn_context: &TurnContext, auth: Option<&CodexAuth>, field_name: &str, index: Option<usize>, file_path: &str,) -> Result<JsonValue, String> { 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(), )); }; // TODO(anp): Resolve app tool file arguments using the selected environment's native path // convention so uploads can read relative paths from foreign environments. let native_environment_cwd = turn_environment .cwd() .to_abs_path() .map_err(|error| contextualize_error(error.to_string()))?; let resolved_path = native_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, "mime_type": uploaded.mime_type, "file_name": uploaded.file_name, "uri": uploaded.uri, "file_size_bytes": uploaded.file_size_bytes, }))}#[cfg(test)]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 codex_utils_path_uri::PathUri; 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), PathUri::from_abs_path(&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; let arguments = Some(serde_json::json!({ "file": "/tmp/codex-smoke-file.txt" })); let rewritten = rewrite_mcp_tool_arguments_for_openai_files( &session, &Arc::new(turn_context), arguments.clone(), /*openai_file_input_params*/ None, ) .await .expect("rewrite should succeed"); assert_eq!(rewritten, arguments); } #[tokio::test] async fn build_uploaded_argument_value_uploads_environment_file() { 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; let server = MockServer::start().await; Mock::given(method("POST")) .and(path("/backend-api/files")) .and(header("chatgpt-account-id", "account_id")) .and(body_json(serde_json::json!({ "file_name": "file_report.csv", "file_size": 5, "use_case": "codex", }))) .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ "file_id": "file_123", "upload_url": format!("{}/upload/file_123", server.uri()), }))) .expect(1) .mount(&server) .await; Mock::given(method("PUT")) .and(path("/upload/file_123")) .respond_with(ResponseTemplate::new(200)) .expect(1) .mount(&server) .await; Mock::given(method("POST")) .and(path("/backend-api/files/file_123/uploaded")) .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ "status": "success", "download_url": format!("{}/download/file_123", server.uri()), "file_name": "file_report.csv", "mime_type": "text/csv", "file_size_bytes": 5, }))) .expect(1) .mount(&server) .await; 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 local_path = dir.path().join("file_report.csv"); tokio::fs::write(&local_path, b"hello") .await .expect("write local file"); 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_argument_value( &turn_context, Some(&auth), "file", /*index*/ None, "file_report.csv", ) .await .expect("rewrite should upload the local file"); assert_eq!( rewritten, serde_json::json!({ "download_url": format!("{}/download/file_123", server.uri()), "file_id": "file_123", "mime_type": "text/csv", "file_name": "file_report.csv", "uri": "sediment://file_123", "file_size_bytes": 5, }) ); } #[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; use wiremock::MockServer; use wiremock::ResponseTemplate; use wiremock::matchers::body_json; use wiremock::matchers::header; use wiremock::matchers::method; use wiremock::matchers::path; let server = MockServer::start().await; Mock::given(method("POST")) .and(path("/backend-api/files")) .and(header("chatgpt-account-id", "account_id")) .and(body_json(serde_json::json!({ "file_name": "file_report.csv", "file_size": 5, "use_case": "codex", }))) .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ "file_id": "file_123", "upload_url": format!("{}/upload/file_123", server.uri()), }))) .expect(1) .mount(&server) .await; Mock::given(method("PUT")) .and(path("/upload/file_123")) .respond_with(ResponseTemplate::new(200)) .expect(1) .mount(&server) .await; Mock::given(method("POST")) .and(path("/backend-api/files/file_123/uploaded")) .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ "status": "success", "download_url": format!("{}/download/file_123", server.uri()), "file_name": "file_report.csv", "mime_type": "text/csv", "file_size_bytes": 5, }))) .expect(1) .mount(&server) .await; 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 local_path = dir.path().join("file_report.csv"); tokio::fs::write(&local_path, b"hello") .await .expect("write local file"); 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 = rewrite_argument_value_for_openai_files( &turn_context, Some(&auth), "file", &serde_json::json!("file_report.csv"), ) .await .expect("rewrite should succeed"); assert_eq!( rewritten, Some(serde_json::json!({ "download_url": format!("{}/download/file_123", server.uri()), "file_id": "file_123", "mime_type": "text/csv", "file_name": "file_report.csv", "uri": "sediment://file_123", "file_size_bytes": 5, })) ); } #[tokio::test] async fn rewrite_argument_value_for_openai_files_rewrites_array_paths() { 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; let server = MockServer::start().await; Mock::given(method("POST")) .and(path("/backend-api/files")) .and(header("chatgpt-account-id", "account_id")) .and(body_json(serde_json::json!({ "file_name": "one.csv", "file_size": 3, "use_case": "codex", }))) .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ "file_id": "file_1", "upload_url": format!("{}/upload/file_1", server.uri()), }))) .expect(1) .mount(&server) .await; Mock::given(method("POST")) .and(path("/backend-api/files")) .and(header("chatgpt-account-id", "account_id")) .and(body_json(serde_json::json!({ "file_name": "two.csv", "file_size": 3, "use_case": "codex", }))) .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ "file_id": "file_2", "upload_url": format!("{}/upload/file_2", server.uri()), }))) .expect(1) .mount(&server) .await; Mock::given(method("PUT")) .and(path("/upload/file_1")) .respond_with(ResponseTemplate::new(200)) .expect(1) .mount(&server) .await; Mock::given(method("PUT")) .and(path("/upload/file_2")) .respond_with(ResponseTemplate::new(200)) .expect(1) .mount(&server) .await; Mock::given(method("POST")) .and(path("/backend-api/files/file_1/uploaded")) .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ "status": "success", "download_url": format!("{}/download/file_1", server.uri()), "file_name": "one.csv", "mime_type": "text/csv", "file_size_bytes": 3, }))) .expect(1) .mount(&server) .await; Mock::given(method("POST")) .and(path("/backend-api/files/file_2/uploaded")) .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ "status": "success", "download_url": format!("{}/download/file_2", server.uri()), "file_name": "two.csv", "mime_type": "text/csv", "file_size_bytes": 3, }))) .expect(1) .mount(&server) .await; let (_, mut turn_context) = make_session_and_context().await; let auth = CodexAuth::create_dummy_chatgpt_auth_for_testing(); let dir = tempdir().expect("temp dir"); tokio::fs::write(dir.path().join("one.csv"), b"one") .await .expect("write first local file"); tokio::fs::write(dir.path().join("two.csv"), b"two") .await .expect("write second local file"); 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 = rewrite_argument_value_for_openai_files( &turn_context, Some(&auth), "files", &serde_json::json!(["one.csv", "two.csv"]), ) .await .expect("rewrite should succeed"); assert_eq!( rewritten, Some(serde_json::json!([ { "download_url": format!("{}/download/file_1", server.uri()), "file_id": "file_1", "mime_type": "text/csv", "file_name": "one.csv", "uri": "sediment://file_1", "file_size_bytes": 3, }, { "download_url": format!("{}/download/file_2", server.uri()), "file_id": "file_2", "mime_type": "text/csv", "file_name": "two.csv", "uri": "sediment://file_2", "file_size_bytes": 3, } ])) ); } #[tokio::test] async fn rewrite_mcp_tool_arguments_for_openai_files_surfaces_upload_failures() { let (mut session, turn_context) = make_session_and_context().await; session.services.auth_manager = crate::test_support::auth_manager_from_auth( CodexAuth::create_dummy_chatgpt_auth_for_testing(), ); let error = rewrite_mcp_tool_arguments_for_openai_files( &session, &turn_context, Some(serde_json::json!({ "file": "/definitely/missing/file.csv", })), Some(&["file".to_string()]), ) .await .expect_err("missing file should fail"); assert!(error.contains("failed to upload")); assert!(error.contains("file")); }}