//! 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, upload those local files 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::codex::Session; use crate::codex::TurnContext; use codex_api::CoreAuthProvider; use codex_api::upload_local_file; use codex_login::CodexAuth; 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, openai_file_input_params: Option<&[String]>, ) -> Result, 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, String> { match value { JsonValue::String(path_or_file_ref) => { let rewritten = build_uploaded_local_argument_value( turn_context, auth, field_name, /*index*/ None, path_or_file_ref, ) .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(path_or_file_ref) = item.as_str() else { return Ok(None); }; let rewritten = build_uploaded_local_argument_value( turn_context, auth, field_name, Some(index), path_or_file_ref, ) .await?; rewritten_values.push(rewritten); } Ok(Some(JsonValue::Array(rewritten_values))) } _ => Ok(None), } } async fn build_uploaded_local_argument_value( turn_context: &TurnContext, auth: Option<&CodexAuth>, field_name: &str, index: Option, file_path: &str, ) -> Result { 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(), ); }; let token_data = auth .get_token_data() .map_err(|error| format!("failed to read ChatGPT auth for file upload: {error}"))?; let upload_auth = CoreAuthProvider { token: Some(token_data.access_token), account_id: token_data.account_id, is_fedramp_account: auth.is_fedramp_account(), }; let uploaded = upload_local_file( turn_context.config.chatgpt_base_url.trim_end_matches('/'), &upload_auth, &resolved_path, ) .await .map_err(|error| 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}"), })?; 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::codex::make_session_and_context; use codex_utils_absolute_path::AbsolutePathBuf; use pretty_assertions::assert_eq; use std::sync::Arc; use tempfile::tempdir; #[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_local_argument_value_uploads_local_file_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"); turn_context.cwd = AbsolutePathBuf::try_from(dir.path()).expect("absolute 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( &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 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"); turn_context.cwd = AbsolutePathBuf::try_from(dir.path()).expect("absolute 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"); turn_context.cwd = AbsolutePathBuf::try_from(dir.path()).expect("absolute 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")); } }