Compare commits

...

1 Commits

Author SHA1 Message Date
Liang-Ting Jiang
3e791352a4 Surface Library uploads in computed MCP approval 2026-05-29 01:14:56 -07:00
5 changed files with 313 additions and 28 deletions

View File

@@ -98,6 +98,7 @@ pub async fn upload_local_file(
base_url: &str,
auth: &dyn AuthProvider,
path: &Path,
store_in_oai_library: bool,
) -> Result<UploadedOpenAiFile, OpenAiFileError> {
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");
@@ -377,4 +388,69 @@ mod tests {
assert_eq!(uploaded.mime_type, Some("text/plain".to_string()));
assert_eq!(finalize_attempts.load(Ordering::SeqCst), 2);
}
#[tokio::test]
async fn upload_local_file_can_request_oai_library_storage() {
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": "library-note.txt",
"file_size": 7,
"use_case": "codex",
"store_in_library": true,
})))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"file_id": "file_library",
"upload_url": format!("{}/upload/file_library", server.uri()),
})))
.expect(1)
.mount(&server)
.await;
Mock::given(method("PUT"))
.and(path("/upload/file_library"))
.and(header("content-length", "7"))
.respond_with(ResponseTemplate::new(200))
.expect(1)
.mount(&server)
.await;
Mock::given(method("POST"))
.and(path("/backend-api/files/file_library/uploaded"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"status": "success",
"download_url": format!("{}/download/file_library", server.uri()),
"file_name": "library-note.txt",
"mime_type": "text/plain",
"file_size_bytes": 7,
})))
.expect(1)
.mount(&server)
.await;
let base_url = base_url_for(&server);
let dir = TempDir::new().expect("temp dir");
let path = dir.path().join("library-note.txt");
tokio::fs::write(&path, b"library")
.await
.expect("write file");
let uploaded = upload_local_file(
&base_url,
&chatgpt_auth(),
&path,
/*store_in_oai_library*/ true,
)
.await
.expect("upload succeeds");
assert_eq!(uploaded.file_id, "file_library");
assert_eq!(uploaded.uri, "sediment://file_library");
assert_eq!(
uploaded.download_url,
format!("{}/download/file_library", server.uri())
);
server.verify().await;
}
}

View File

@@ -303,7 +303,7 @@ struct CodexAppsServerInfoDiskCache {
}
const CODEX_APPS_TOOLS_CACHE_DIR: &str = "cache/codex_apps_tools";
pub(crate) const CODEX_APPS_TOOLS_CACHE_SCHEMA_VERSION: u8 = 3;
pub(crate) const CODEX_APPS_TOOLS_CACHE_SCHEMA_VERSION: u8 = 4;
const CODEX_APPS_SERVER_INFO_CACHE_DIR: &str = "cache/codex_apps_server_info";
const CODEX_APPS_SERVER_INFO_CACHE_SCHEMA_VERSION: u8 = 1;

View File

@@ -3,6 +3,9 @@
//! Strategy:
//! - Inspect `_meta["openai/fileParams"]` to discover which tool arguments are
//! file inputs.
//! - Inspect validated `_meta["openai/fileUploadConfig"]` metadata to discover
//! host-computed Library storage for the approval action before local files
//! are uploaded.
//! - 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<String>,
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<JsonValue>,
openai_file_input_params: Option<&[String]>,
openai_file_input_params: Option<&OpenAIFileInputParams>,
) -> Result<Option<JsonValue>, 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(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,18 @@ 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(
openai_file_input_params: &OpenAIFileInputParams,
) -> bool {
openai_file_input_params.store_in_oai_library
}
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<Option<JsonValue>, String> {
match value {
JsonValue::String(path_or_file_ref) => {
@@ -69,6 +92,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 +109,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 +126,7 @@ async fn build_uploaded_local_argument_value(
field_name: &str,
index: Option<usize>,
file_path: &str,
store_in_oai_library: bool,
) -> Result<JsonValue, String> {
#[allow(deprecated)]
let resolved_path = turn_context.resolve_path(Some(file_path.to_string()));
@@ -119,6 +145,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 {
@@ -165,8 +192,25 @@ mod tests {
assert_eq!(rewritten, arguments);
}
#[test]
fn store_in_oai_library_comes_from_computed_approval_action() {
let openai_file_input_params = OpenAIFileInputParams {
names: Vec::new(),
store_in_oai_library: true,
};
assert!(store_in_oai_library_for_openai_file_upload(
&openai_file_input_params
));
assert!(!store_in_oai_library_for_openai_file_upload(
&OpenAIFileInputParams {
names: Vec::new(),
store_in_oai_library: false,
},
));
}
#[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 +227,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 +277,7 @@ mod tests {
"file",
/*index*/ None,
"file_report.csv",
/*store_in_oai_library*/ true,
)
.await
.expect("rewrite should upload the local file");
@@ -314,6 +360,7 @@ mod tests {
Some(&auth),
"file",
&serde_json::json!("file_report.csv"),
/*store_in_oai_library*/ false,
)
.await
.expect("rewrite should succeed");
@@ -431,6 +478,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 +518,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");

View File

@@ -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;
@@ -163,12 +164,19 @@ pub(crate) async fn handle_mcp_tool_call(
} else {
connectors::AppToolPolicy::default()
};
let approval_mode = if server == CODEX_APPS_MCP_SERVER_NAME {
let approval_mode = if metadata
.as_ref()
.and_then(|metadata| metadata.openai_file_input_params.as_ref())
.is_some_and(|params| params.store_in_oai_library)
{
AppToolApproval::Prompt
} else if server == CODEX_APPS_MCP_SERVER_NAME {
app_tool_policy.approval
} else {
custom_mcp_tool_approval_mode(sess.as_ref(), turn_context.as_ref(), &server, &tool_name)
.await
};
let approval_invocation = approval_visible_mcp_invocation(&invocation, metadata.as_ref());
if server == CODEX_APPS_MCP_SERVER_NAME && !app_tool_policy.enabled {
let result = notify_mcp_tool_call_skip(
@@ -204,7 +212,7 @@ pub(crate) async fn handle_mcp_tool_call(
sess.as_ref(),
turn_context.as_ref(),
&call_id,
invocation.clone(),
approval_invocation.clone(),
item_metadata.clone(),
)
.await;
@@ -213,7 +221,7 @@ pub(crate) async fn handle_mcp_tool_call(
&sess,
turn_context,
&call_id,
&invocation,
&approval_invocation,
&hook_tool_name,
metadata.as_ref(),
approval_mode,
@@ -328,7 +336,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 +984,15 @@ pub(crate) struct McpToolApprovalMetadata {
tool_description: Option<String>,
mcp_app_resource_uri: Option<String>,
codex_apps_meta: Option<serde_json::Map<String, serde_json::Value>>,
openai_file_input_params: Option<Vec<String>>,
openai_file_input_params: Option<OpenAIFileInputParams>,
}
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_CONFIG_META_KEY: &str = "openai/fileUploadConfig";
const MCP_TOOL_OPENAI_FILE_UPLOAD_STORE_IN_OAI_LIBRARY_KEY: &str = "store_in_oai_library";
async fn custom_mcp_tool_approval_mode(
sess: &Session,
@@ -1464,6 +1474,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 +1482,60 @@ pub(crate) async fn lookup_mcp_tool_metadata(
fn openai_file_input_params_for_server(
server: &str,
meta: Option<&serde_json::Map<String, serde_json::Value>>,
) -> Option<Vec<String>> {
(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<String, serde_json::Value>,
) -> Option<OpenAIFileInputParams> {
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: declared_openai_file_upload_store_in_oai_library(meta),
})
}
fn declared_openai_file_upload_store_in_oai_library(
meta: Option<&serde_json::Map<String, serde_json::Value>>,
) -> bool {
meta.and_then(|meta| meta.get(MCP_TOOL_OPENAI_FILE_UPLOAD_CONFIG_META_KEY))
.and_then(serde_json::Value::as_object)
.and_then(|config| config.get(MCP_TOOL_OPENAI_FILE_UPLOAD_STORE_IN_OAI_LIBRARY_KEY))
.and_then(serde_json::Value::as_bool)
.unwrap_or(false)
}
fn approval_visible_mcp_invocation(
invocation: &McpInvocation,
metadata: Option<&McpToolApprovalMetadata>,
) -> McpInvocation {
let Some(openai_file_input_params) =
metadata.and_then(|metadata| metadata.openai_file_input_params.as_ref())
else {
return invocation.clone();
};
if !openai_file_input_params.store_in_oai_library {
return invocation.clone();
}
let mut approval_arguments = invocation
.arguments
.as_ref()
.and_then(serde_json::Value::as_object)
.cloned()
.unwrap_or_default();
approval_arguments.insert(
MCP_TOOL_OPENAI_FILE_UPLOAD_STORE_IN_OAI_LIBRARY_KEY.to_string(),
serde_json::Value::Bool(true),
);
McpInvocation {
arguments: Some(serde_json::Value::Object(approval_arguments)),
..invocation.clone()
}
}
fn get_mcp_app_resource_uri(

View File

@@ -344,17 +344,114 @@ 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_config_marks_host_computed_library_upload() {
let valid_meta_value = serde_json::json!({
"openai/fileParams": ["file"],
"openai/fileUploadConfig": {
"store_in_oai_library": true,
},
});
let valid_meta = valid_meta_value.as_object();
let valid_input_schema_value = serde_json::json!({
"type": "object",
"properties": {},
});
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,
valid_meta,
valid_input_schema,
),
Some(OpenAIFileInputParams {
names: vec!["file".to_string()],
store_in_oai_library: true,
})
);
for (meta, input_schema) in [(
serde_json::json!({
"openai/fileParams": ["file"],
"openai/fileUploadConfig": {
"store_in_oai_library_arg": "store_in_oai_library",
},
}),
valid_input_schema_value.clone(),
)] {
assert_eq!(
openai_file_input_params_for_server(
CODEX_APPS_MCP_SERVER_NAME,
meta.as_object(),
input_schema.as_object().expect("input schema object"),
),
Some(OpenAIFileInputParams {
names: vec!["file".to_string()],
store_in_oai_library: false,
})
);
}
}
#[test]
fn approval_visible_mcp_invocation_surfaces_host_computed_library_upload() {
let invocation = McpInvocation {
server: CODEX_APPS_MCP_SERVER_NAME.to_string(),
tool: "library_create_library_file".to_string(),
arguments: Some(serde_json::json!({
"file": "/tmp/library.txt",
})),
};
let metadata = McpToolApprovalMetadata {
annotations: None,
connector_id: None,
connector_name: None,
connector_description: None,
plugin_id: None,
tool_title: None,
tool_description: None,
mcp_app_resource_uri: None,
codex_apps_meta: None,
openai_file_input_params: Some(OpenAIFileInputParams {
names: vec!["file".to_string()],
store_in_oai_library: true,
}),
};
assert_eq!(
approval_visible_mcp_invocation(&invocation, Some(&metadata)),
McpInvocation {
arguments: Some(serde_json::json!({
"file": "/tmp/library.txt",
"store_in_oai_library": true,
})),
..invocation
}
);
}
#[test]
fn approval_required_when_read_only_false_and_destructive() {
let annotations = annotations(Some(false), Some(true), /*open_world*/ None);