feat: add Codex Apps sediment file remapping (#15197)

## Summary
- bridge Codex Apps tools that declare `_meta["openai/fileParams"]`
through the OpenAI file upload flow
- mask those file params in model-visible tool schemas so the model
provides absolute local file paths instead of raw file payload objects
- rewrite those local file path arguments client-side into
`ProvidedFilePayload`-shaped objects before the normal MCP tool call

## Details
- applies to scalar and array file params declared in
`openai/fileParams`
- Codex uploads local files directly to the backend and uses the
uploaded file metadata to build the MCP tool arguments locally
- this PR is input-only

## Verification
- `just fmt`
- `cargo test -p codex-core mcp_tool_call -- --nocapture`

---------

Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
Casey Chow
2026-04-09 14:10:44 -04:00
committed by GitHub
parent 25a0f6784d
commit 244b15c95d
15 changed files with 1313 additions and 35 deletions

View File

@@ -22,6 +22,8 @@ const SEARCHABLE_TOOL_COUNT: usize = 100;
pub const CALENDAR_CREATE_EVENT_RESOURCE_URI: &str =
"connector://calendar/tools/calendar_create_event";
const CALENDAR_LIST_EVENTS_RESOURCE_URI: &str = "connector://calendar/tools/calendar_list_events";
pub const DOCUMENT_EXTRACT_TEXT_RESOURCE_URI: &str =
"connector://calendar/tools/calendar_extract_text";
#[derive(Clone)]
pub struct AppsTestServer {
@@ -235,6 +237,39 @@ impl Respond for CodexAppsJsonRpcResponder {
"connector_id": CONNECTOR_ID
}
}
},
{
"name": "calendar_extract_text",
"description": "Extract text from an uploaded document.",
"annotations": {
"readOnlyHint": false
},
"inputSchema": {
"type": "object",
"properties": {
"file": {
"type": "object",
"description": "Document file payload.",
"properties": {
"file_id": { "type": "string" }
},
"required": ["file_id"]
}
},
"required": ["file"],
"additionalProperties": false
},
"_meta": {
"connector_id": CONNECTOR_ID,
"connector_name": self.connector_name.clone(),
"connector_description": self.connector_description.clone(),
"openai/fileParams": ["file"],
"_codex_apps": {
"resource_uri": DOCUMENT_EXTRACT_TEXT_RESOURCE_URI,
"contains_mcp_source": true,
"connector_id": CONNECTOR_ID
}
}
}
],
"nextCursor": null
@@ -245,7 +280,7 @@ impl Respond for CodexAppsJsonRpcResponder {
.pointer_mut("/result/tools")
.and_then(Value::as_array_mut)
{
for index in 2..SEARCHABLE_TOOL_COUNT {
for index in 3..SEARCHABLE_TOOL_COUNT {
tools.push(json!({
"name": format!("calendar_timezone_option_{index}"),
"description": format!("Read timezone option {index}."),
@@ -283,6 +318,10 @@ impl Respond for CodexAppsJsonRpcResponder {
.pointer("/params/arguments/starts_at")
.and_then(Value::as_str)
.unwrap_or_default();
let file_id = body
.pointer("/params/arguments/file/file_id")
.and_then(Value::as_str)
.unwrap_or_default();
let codex_apps_meta = body.pointer("/params/_meta/_codex_apps").cloned();
ResponseTemplate::new(200).set_body_json(json!({
@@ -291,7 +330,7 @@ impl Respond for CodexAppsJsonRpcResponder {
"result": {
"content": [{
"type": "text",
"text": format!("called {tool_name} for {title} at {starts_at}")
"text": format!("called {tool_name} for {title} at {starts_at} with {file_id}")
}],
"structuredContent": {
"_codex_apps": codex_apps_meta,

View File

@@ -106,6 +106,7 @@ mod model_switching;
mod model_visible_layout;
mod models_cache_ttl;
mod models_etag_responses;
mod openai_file_mcp;
mod otel;
mod pending_input;
mod permissions_messages;

View File

@@ -0,0 +1,173 @@
#![cfg(not(target_os = "windows"))]
use anyhow::Result;
use codex_core::config::Config;
use codex_features::Feature;
use codex_login::CodexAuth;
use codex_protocol::protocol::AskForApproval;
use codex_protocol::protocol::SandboxPolicy;
use core_test_support::apps_test_server::AppsTestServer;
use core_test_support::apps_test_server::DOCUMENT_EXTRACT_TEXT_RESOURCE_URI;
use core_test_support::responses::ev_assistant_message;
use core_test_support::responses::ev_completed;
use core_test_support::responses::ev_function_call;
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::test_codex;
use pretty_assertions::assert_eq;
use serde_json::Value;
use serde_json::json;
use wiremock::Mock;
use wiremock::ResponseTemplate;
use wiremock::matchers::body_json;
use wiremock::matchers::header;
use wiremock::matchers::method;
use wiremock::matchers::path;
const DOCUMENT_EXTRACT_TOOL: &str = "mcp__codex_apps__calendar_extract_text";
fn configure_apps(config: &mut Config, chatgpt_base_url: &str) {
if let Err(err) = config.features.enable(Feature::Apps) {
panic!("test config should allow feature update: {err}");
}
config.chatgpt_base_url = chatgpt_base_url.to_string();
}
fn tool_by_name<'a>(body: &'a Value, name: &str) -> &'a Value {
body.get("tools")
.and_then(Value::as_array)
.and_then(|tools| {
tools.iter().find(|tool| {
tool.get("name").and_then(Value::as_str) == Some(name)
|| tool.get("type").and_then(Value::as_str) == Some(name)
})
})
.unwrap_or_else(|| panic!("missing tool {name} in /v1/responses request: {body:?}"))
}
#[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?;
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,
"use_case": "codex",
})))
.respond_with(ResponseTemplate::new(200).set_body_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"))
.and(header("content-length", "11"))
.respond_with(ResponseTemplate::new(200))
.expect(1)
.mount(&server)
.await;
Mock::given(method("POST"))
.and(path("/files/file_123/uploaded"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"status": "success",
"download_url": format!("{}/download/file_123", server.uri()),
"file_name": "report.txt",
"mime_type": "text/plain",
"file_size_bytes": 11,
})))
.expect(1)
.mount(&server)
.await;
let call_id = "extract-call-1";
let mock = mount_sse_sequence(
&server,
vec![
sse(vec![
ev_response_created("resp-1"),
ev_function_call(
call_id,
DOCUMENT_EXTRACT_TOOL,
&json!({"file": "report.txt"}).to_string(),
),
ev_completed("resp-1"),
]),
sse(vec![
ev_response_created("resp-2"),
ev_assistant_message("msg-1", "done"),
ev_completed("resp-2"),
]),
],
)
.await;
let mut builder = test_codex()
.with_auth(CodexAuth::create_dummy_chatgpt_auth_for_testing())
.with_config(move |config| configure_apps(config, apps_server.chatgpt_base_url.as_str()));
let test = builder.build(&server).await?;
tokio::fs::write(test.cwd.path().join("report.txt"), b"hello world").await?;
test.submit_turn_with_policies(
"Extract the report text with the app tool.",
AskForApproval::Never,
SandboxPolicy::DangerFullAccess,
)
.await?;
let requests = mock.requests();
let body = requests[0].body_json();
let extract_tool = tool_by_name(&body, DOCUMENT_EXTRACT_TOOL);
assert_eq!(
extract_tool.pointer("/parameters/properties/file"),
Some(&json!({
"type": "string",
"description": "Document file payload. This parameter expects an absolute local file path. If you want to upload a file, provide the absolute path to that file here."
}))
);
let apps_tool_call = server
.received_requests()
.await
.unwrap_or_default()
.into_iter()
.find_map(|request| {
let body: Value = serde_json::from_slice(&request.body).ok()?;
(request.url.path() == "/api/codex/apps"
&& body.get("method").and_then(Value::as_str) == Some("tools/call")
&& body.pointer("/params/name").and_then(Value::as_str)
== Some("calendar_extract_text"))
.then_some(body)
})
.expect("apps calendar_extract_text tools/call request should be recorded");
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,
}))
);
assert_eq!(
apps_tool_call.pointer("/params/_meta/_codex_apps"),
Some(&json!({
"resource_uri": DOCUMENT_EXTRACT_TEXT_RESOURCE_URI,
"contains_mcp_source": true,
"connector_id": "calendar",
}))
);
server.verify().await;
Ok(())
}