mirror of
https://github.com/openai/codex.git
synced 2026-05-26 22:15:13 +00:00
## Summary This PR moves Codex backend request authentication from direct bearer-token handling to `AuthProvider`. The new `codex-auth-provider` crate defines the shared request-auth trait. `CodexAuth::provider()` returns a provider that can apply all headers needed for the selected auth mode. This lets ChatGPT token auth and AgentIdentity auth share the same callsite path: - ChatGPT token auth applies bearer auth plus account/FedRAMP headers where needed. - AgentIdentity auth applies AgentAssertion plus account/FedRAMP headers where needed. Reference old stack: https://github.com/openai/codex/pull/17387/changes ## Callsite Migration | Area | Change | | --- | --- | | backend-client | accepts an `AuthProvider` instead of a raw token/header | | chatgpt client/connectors | applies auth through `CodexAuth::provider()` | | cloud tasks | keeps Codex-backend gating, applies auth through provider | | cloud requirements | uses Codex-backend auth checks and provider headers | | app-server remote control | applies provider headers for backend calls | | MCP Apps/connectors | gates on `uses_codex_backend()` and keys caches from generic account getters | | model refresh | treats AgentIdentity as Codex-backend auth | | OpenAI file upload path | rejects non-Codex-backend auth before applying headers | | core client setup | keeps model-provider auth flow and allows AgentIdentity through provider-backed OpenAI auth | ## Stack 1. https://github.com/openai/codex/pull/18757: full revert 2. https://github.com/openai/codex/pull/18871: isolated Agent Identity crate 3. https://github.com/openai/codex/pull/18785: explicit AgentIdentity auth mode and startup task allocation 4. This PR: migrate Codex backend auth callsites through AuthProvider 5. https://github.com/openai/codex/pull/18904: accept AgentIdentity JWTs and load `CODEX_AGENT_IDENTITY` ## Testing Tests: targeted Rust checks, cargo-shear, Bazel lock check, and CI.
472 lines
17 KiB
Rust
472 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> {
|
|
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");
|
|
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"));
|
|
}
|
|
}
|