mirror of
https://github.com/openai/codex.git
synced 2026-10-26 18:38:47 +00:00
## Why `TurnContext::cwd` and `TurnContext::resolve_path` are being phased out in favor of using the selected turn environment cwd directly. Deprecating both APIs makes any new direct dependency visible while preserving the existing migration path for current callers. ## What Changed - Marked `TurnContext::cwd` and `TurnContext::resolve_path` as deprecated with guidance to use the selected turn environment cwd instead. - Added exact `#[allow(deprecated)]` suppressions at each existing direct usage site, including tests, rather than adding crate-wide suppression. - Kept the change behavior-preserving: current cwd reads, writes, and path resolution continue to use the same values. ## Verification - `just fmt` - `cargo check -p codex-core` - `cargo check -p codex-core --tests` - `git diff --check`
482 lines
17 KiB
Rust
482 lines
17 KiB
Rust
//! 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::session::session::Session;
|
|
use crate::session::turn_context::TurnContext;
|
|
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<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(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<usize>,
|
|
file_path: &str,
|
|
) -> Result<JsonValue, String> {
|
|
#[allow(deprecated)]
|
|
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(),
|
|
);
|
|
};
|
|
if !auth.uses_codex_backend() {
|
|
return Err(
|
|
"ChatGPT auth is required to upload local files for Codex Apps tools".to_string(),
|
|
);
|
|
}
|
|
let upload_auth = codex_model_provider::auth_provider_from_auth(auth);
|
|
let uploaded = upload_local_file(
|
|
turn_context.config.chatgpt_base_url.trim_end_matches('/'),
|
|
upload_auth.as_ref(),
|
|
&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::session::tests::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");
|
|
#[allow(deprecated)]
|
|
{
|
|
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");
|
|
#[allow(deprecated)]
|
|
{
|
|
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");
|
|
#[allow(deprecated)]
|
|
{
|
|
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"));
|
|
}
|
|
}
|