From 614c2ca47a4cdc1d13a96b1662323eb0ae60bf3d Mon Sep 17 00:00:00 2001 From: Liang-Ting Jiang Date: Thu, 28 May 2026 14:10:53 -0700 Subject: [PATCH] Support Library uploads for Codex Apps --- codex-rs/codex-api/src/files.rs | 27 +++++--- codex-rs/core/src/mcp_openai_file.rs | 53 +++++++++++++-- codex-rs/core/src/mcp_tool_call.rs | 63 +++++++++++++++-- codex-rs/core/src/mcp_tool_call_tests.rs | 86 +++++++++++++++++++++++- 4 files changed, 205 insertions(+), 24 deletions(-) diff --git a/codex-rs/codex-api/src/files.rs b/codex-rs/codex-api/src/files.rs index d1e2840066..4e4aaf8239 100644 --- a/codex-rs/codex-api/src/files.rs +++ b/codex-rs/codex-api/src/files.rs @@ -98,6 +98,7 @@ pub async fn upload_local_file( base_url: &str, auth: &dyn AuthProvider, path: &Path, + store_in_oai_library: bool, ) -> Result { let metadata = tokio::fs::metadata(path) .await @@ -129,12 +130,17 @@ pub async fn upload_local_file( .unwrap_or("file") .to_string(); let create_url = format!("{}/files", base_url.trim_end_matches('/')); + let mut create_body = serde_json::json!({ + "file_name": file_name, + "file_size": metadata.len(), + "use_case": OPENAI_FILE_USE_CASE, + }); + if store_in_oai_library { + create_body["store_in_library"] = serde_json::Value::Bool(true); + } + let create_response = authorized_request(auth, reqwest::Method::POST, &create_url) - .json(&serde_json::json!({ - "file_name": file_name, - "file_size": metadata.len(), - "use_case": OPENAI_FILE_USE_CASE, - })) + .json(&create_body) .send() .await .map_err(|source| OpenAiFileError::Request { @@ -363,9 +369,14 @@ mod tests { 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 uploaded = upload_local_file( + &base_url, + &chatgpt_auth(), + &path, + /*store_in_oai_library*/ false, + ) + .await + .expect("upload succeeds"); assert_eq!(uploaded.file_id, "file_123"); assert_eq!(uploaded.uri, "sediment://file_123"); diff --git a/codex-rs/core/src/mcp_openai_file.rs b/codex-rs/core/src/mcp_openai_file.rs index ae44515c62..bd6b4d4728 100644 --- a/codex-rs/core/src/mcp_openai_file.rs +++ b/codex-rs/core/src/mcp_openai_file.rs @@ -3,6 +3,9 @@ //! Strategy: //! - Inspect `_meta["openai/fileParams"]` to discover which tool arguments are //! file inputs. +//! - Inspect the validated visible tool schema for the reserved +//! `save_to_openai_library: true` argument that opts uploaded local files into +//! Library storage. //! - 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. @@ -16,11 +19,17 @@ use codex_api::upload_local_file; use codex_login::CodexAuth; use serde_json::Value as JsonValue; +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct OpenAIFileInputParams { + pub(crate) names: Vec, + pub(crate) store_in_oai_library: bool, +} + 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]>, + openai_file_input_params: Option<&OpenAIFileInputParams>, ) -> Result, String> { let Some(openai_file_input_params) = openai_file_input_params else { return Ok(arguments_value); @@ -34,14 +43,21 @@ pub(crate) async fn rewrite_mcp_tool_arguments_for_openai_files( }; let auth = sess.services.auth_manager.auth().await; let mut rewritten_arguments = arguments.clone(); + let store_in_oai_library = + store_in_oai_library_for_openai_file_upload(arguments, openai_file_input_params); - for field_name in openai_file_input_params { + for field_name in &openai_file_input_params.names { 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? + let Some(uploaded_value) = rewrite_argument_value_for_openai_files( + turn_context, + auth.as_ref(), + field_name, + value, + store_in_oai_library, + ) + .await? else { continue; }; @@ -55,11 +71,23 @@ pub(crate) async fn rewrite_mcp_tool_arguments_for_openai_files( Ok(Some(JsonValue::Object(rewritten_arguments))) } +fn store_in_oai_library_for_openai_file_upload( + arguments: &serde_json::Map, + openai_file_input_params: &OpenAIFileInputParams, +) -> bool { + openai_file_input_params.store_in_oai_library + && arguments + .get("save_to_openai_library") + .and_then(JsonValue::as_bool) + .unwrap_or(false) +} + async fn rewrite_argument_value_for_openai_files( turn_context: &TurnContext, auth: Option<&CodexAuth>, field_name: &str, value: &JsonValue, + store_in_oai_library: bool, ) -> Result, String> { match value { JsonValue::String(path_or_file_ref) => { @@ -69,6 +97,7 @@ async fn rewrite_argument_value_for_openai_files( field_name, /*index*/ None, path_or_file_ref, + store_in_oai_library, ) .await?; Ok(Some(rewritten)) @@ -85,6 +114,7 @@ async fn rewrite_argument_value_for_openai_files( field_name, Some(index), path_or_file_ref, + store_in_oai_library, ) .await?; rewritten_values.push(rewritten); @@ -101,6 +131,7 @@ async fn build_uploaded_local_argument_value( field_name: &str, index: Option, file_path: &str, + store_in_oai_library: bool, ) -> Result { #[allow(deprecated)] let resolved_path = turn_context.resolve_path(Some(file_path.to_string())); @@ -119,6 +150,7 @@ async fn build_uploaded_local_argument_value( turn_context.config.chatgpt_base_url.trim_end_matches('/'), upload_auth.as_ref(), &resolved_path, + store_in_oai_library, ) .await .map_err(|error| match index { @@ -166,7 +198,7 @@ mod tests { } #[tokio::test] - async fn build_uploaded_local_argument_value_uploads_local_file_path() { + async fn build_uploaded_local_argument_value_honors_upload_options() { use wiremock::Mock; use wiremock::MockServer; use wiremock::ResponseTemplate; @@ -183,6 +215,7 @@ mod tests { "file_name": "file_report.csv", "file_size": 5, "use_case": "codex", + "store_in_library": true, }))) .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ "file_id": "file_123", @@ -232,6 +265,7 @@ mod tests { "file", /*index*/ None, "file_report.csv", + /*store_in_oai_library*/ true, ) .await .expect("rewrite should upload the local file"); @@ -314,6 +348,7 @@ mod tests { Some(&auth), "file", &serde_json::json!("file_report.csv"), + /*store_in_oai_library*/ false, ) .await .expect("rewrite should succeed"); @@ -431,6 +466,7 @@ mod tests { Some(&auth), "files", &serde_json::json!(["one.csv", "two.csv"]), + /*store_in_oai_library*/ false, ) .await .expect("rewrite should succeed"); @@ -470,7 +506,10 @@ mod tests { Some(serde_json::json!({ "file": "/definitely/missing/file.csv", })), - Some(&["file".to_string()]), + Some(&OpenAIFileInputParams { + names: vec!["file".to_string()], + store_in_oai_library: false, + }), ) .await .expect_err("missing file should fail"); diff --git a/codex-rs/core/src/mcp_tool_call.rs b/codex-rs/core/src/mcp_tool_call.rs index e514fa0d03..9a9bc91312 100644 --- a/codex-rs/core/src/mcp_tool_call.rs +++ b/codex-rs/core/src/mcp_tool_call.rs @@ -15,6 +15,7 @@ use crate::guardian::new_guardian_review_id; use crate::guardian::review_approval_request; use crate::guardian::routes_approval_to_guardian; use crate::hook_runtime::run_permission_request_hooks; +use crate::mcp_openai_file::OpenAIFileInputParams; use crate::mcp_openai_file::rewrite_mcp_tool_arguments_for_openai_files; use crate::mcp_tool_approval_templates::RenderedMcpToolApprovalParam; use crate::mcp_tool_approval_templates::render_mcp_tool_approval_template; @@ -328,7 +329,7 @@ async fn handle_approved_mcp_tool_call( sess, turn_context, arguments_value.clone(), - metadata.and_then(|metadata| metadata.openai_file_input_params.as_deref()), + metadata.and_then(|metadata| metadata.openai_file_input_params.as_ref()), ) .await; let tool_input = match &rewrite { @@ -976,13 +977,14 @@ pub(crate) struct McpToolApprovalMetadata { tool_description: Option, mcp_app_resource_uri: Option, codex_apps_meta: Option>, - openai_file_input_params: Option>, + openai_file_input_params: Option, } const MCP_TOOL_OPENAI_OUTPUT_TEMPLATE_META_KEY: &str = "openai/outputTemplate"; const MCP_TOOL_UI_RESOURCE_URI_META_KEY: &str = "ui/resourceUri"; const MCP_TOOL_PLUGIN_ID_META_KEY: &str = "plugin_id"; const MCP_TOOL_THREAD_ID_META_KEY: &str = "threadId"; +const MCP_TOOL_OPENAI_FILE_UPLOAD_SAVE_TO_OPENAI_LIBRARY_KEY: &str = "save_to_openai_library"; async fn custom_mcp_tool_approval_mode( sess: &Session, @@ -1464,6 +1466,7 @@ pub(crate) async fn lookup_mcp_tool_metadata( openai_file_input_params: openai_file_input_params_for_server( server, tool_info.tool.meta.as_deref(), + tool_info.tool.input_schema.as_ref(), ), }) } @@ -1471,10 +1474,58 @@ pub(crate) async fn lookup_mcp_tool_metadata( fn openai_file_input_params_for_server( server: &str, meta: Option<&serde_json::Map>, -) -> Option> { - (server == CODEX_APPS_MCP_SERVER_NAME) - .then_some(declared_openai_file_input_param_names(meta)) - .filter(|params| !params.is_empty()) + input_schema: &serde_json::Map, +) -> Option { + if server != CODEX_APPS_MCP_SERVER_NAME { + return None; + } + + let names = declared_openai_file_input_param_names(meta); + if names.is_empty() { + return None; + } + + Some(OpenAIFileInputParams { + names, + store_in_oai_library: visible_required_true_input_schema_arg( + input_schema, + MCP_TOOL_OPENAI_FILE_UPLOAD_SAVE_TO_OPENAI_LIBRARY_KEY, + ), + }) +} + +fn visible_required_true_input_schema_arg( + input_schema: &serde_json::Map, + argument_name: &str, +) -> bool { + let is_required = input_schema + .get("required") + .and_then(serde_json::Value::as_array) + .is_some_and(|required| { + required + .iter() + .any(|required_name| required_name.as_str() == Some(argument_name)) + }); + if !is_required { + return false; + } + + let Some(argument_schema) = input_schema + .get("properties") + .and_then(serde_json::Value::as_object) + .and_then(|properties| properties.get(argument_name)) + .and_then(serde_json::Value::as_object) + else { + return false; + }; + + argument_schema.get("const") == Some(&serde_json::Value::Bool(true)) + || argument_schema + .get("enum") + .and_then(serde_json::Value::as_array) + .is_some_and(|values| { + values.len() == 1 && values.first() == Some(&serde_json::Value::Bool(true)) + }) } fn get_mcp_app_resource_uri( diff --git a/codex-rs/core/src/mcp_tool_call_tests.rs b/codex-rs/core/src/mcp_tool_call_tests.rs index 534c2f0118..c7a36499d8 100644 --- a/codex-rs/core/src/mcp_tool_call_tests.rs +++ b/codex-rs/core/src/mcp_tool_call_tests.rs @@ -344,17 +344,97 @@ fn openai_file_params_are_only_honored_for_codex_apps() { "openai/fileParams": ["file"], }); let meta = meta.as_object(); + let input_schema = serde_json::json!({ + "type": "object", + "properties": {}, + }); + let input_schema = input_schema.as_object().expect("input schema object"); assert_eq!( - openai_file_input_params_for_server(CODEX_APPS_MCP_SERVER_NAME, meta), - Some(vec!["file".to_string()]) + openai_file_input_params_for_server(CODEX_APPS_MCP_SERVER_NAME, meta, input_schema), + Some(OpenAIFileInputParams { + names: vec!["file".to_string()], + store_in_oai_library: false, + }) ); assert_eq!( - openai_file_input_params_for_server("minimaltest", meta), + openai_file_input_params_for_server("minimaltest", meta, input_schema), None ); } +#[test] +fn openai_file_upload_requires_visible_required_true_arg() { + let meta = serde_json::json!({ + "openai/fileParams": ["file"], + }); + let meta = meta.as_object(); + let valid_input_schema_value = serde_json::json!({ + "type": "object", + "properties": { + "save_to_openai_library": { + "type": "boolean", + "const": true, + }, + }, + "required": ["save_to_openai_library"], + }); + let valid_input_schema = valid_input_schema_value + .as_object() + .expect("input schema object"); + + assert_eq!( + openai_file_input_params_for_server(CODEX_APPS_MCP_SERVER_NAME, meta, valid_input_schema,), + Some(OpenAIFileInputParams { + names: vec!["file".to_string()], + store_in_oai_library: true, + }) + ); + + for input_schema in [ + serde_json::json!({ + "type": "object", + "properties": { + "confirm": { + "type": "boolean", + "const": true, + }, + }, + "required": ["confirm"], + }), + serde_json::json!({ + "type": "object", + "properties": { + "save_to_openai_library": { + "type": "boolean", + "const": true, + }, + }, + }), + serde_json::json!({ + "type": "object", + "properties": { + "save_to_openai_library": { + "type": "boolean", + }, + }, + "required": ["save_to_openai_library"], + }), + ] { + assert_eq!( + openai_file_input_params_for_server( + CODEX_APPS_MCP_SERVER_NAME, + meta, + input_schema.as_object().expect("input schema object"), + ), + Some(OpenAIFileInputParams { + names: vec!["file".to_string()], + store_in_oai_library: false, + }) + ); + } +} + #[test] fn approval_required_when_read_only_false_and_destructive() { let annotations = annotations(Some(false), Some(true), /*open_world*/ None);