mirror of
https://github.com/openai/codex.git
synced 2026-05-05 20:07:02 +00:00
Compare commits
26 Commits
pr20314-ap
...
lt/integra
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b27a245981 | ||
|
|
59490a55ac | ||
|
|
f4225bd89d | ||
|
|
43b4f36e0b | ||
|
|
b6cb40e2fd | ||
|
|
6d28c1af6a | ||
|
|
507c6cf016 | ||
|
|
c436c8563b | ||
|
|
5371d8f1fc | ||
|
|
5374c4e618 | ||
|
|
da727e5bda | ||
|
|
7cebc803c0 | ||
|
|
ad48222e31 | ||
|
|
7c46a41bbf | ||
|
|
e942a75f9a | ||
|
|
b113d52715 | ||
|
|
92d6d45dc5 | ||
|
|
4fb52feece | ||
|
|
963420d166 | ||
|
|
c53d84690f | ||
|
|
7cb38ed2e7 | ||
|
|
1887585b8c | ||
|
|
9110f98cad | ||
|
|
0084a82a78 | ||
|
|
25e8c2caf7 | ||
|
|
e8a4b40df7 |
@@ -19,7 +19,7 @@ reqwest = { workspace = true, features = ["json", "stream"] }
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
serde_json = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
tokio = { workspace = true, features = ["fs", "macros", "net", "rt", "sync", "time"] }
|
||||
tokio = { workspace = true, features = ["fs", "io-util", "macros", "net", "rt", "sync", "time"] }
|
||||
tokio-tungstenite = { workspace = true }
|
||||
tungstenite = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
|
||||
@@ -4,12 +4,19 @@ use std::time::Duration;
|
||||
|
||||
use crate::AuthProvider;
|
||||
use codex_client::build_reqwest_client_with_custom_ca;
|
||||
use futures::StreamExt;
|
||||
use futures::TryStreamExt;
|
||||
use reqwest::StatusCode;
|
||||
use reqwest::header::CONTENT_LENGTH;
|
||||
use serde::Deserialize;
|
||||
use tokio::fs::File;
|
||||
use tokio::io::AsyncBufReadExt;
|
||||
use tokio::io::AsyncWriteExt;
|
||||
use tokio::io::BufReader;
|
||||
use tokio::time::Instant;
|
||||
use tokio_util::io::ReaderStream;
|
||||
use tokio_util::io::StreamReader;
|
||||
use url::Url;
|
||||
|
||||
pub const OPENAI_FILE_URI_PREFIX: &str = "sediment://";
|
||||
pub const OPENAI_FILE_UPLOAD_LIMIT_BYTES: u64 = 512 * 1024 * 1024;
|
||||
@@ -19,15 +26,28 @@ const OPENAI_FILE_FINALIZE_TIMEOUT: Duration = Duration::from_secs(30);
|
||||
const OPENAI_FILE_FINALIZE_RETRY_DELAY: Duration = Duration::from_millis(250);
|
||||
const OPENAI_FILE_USE_CASE: &str = "codex";
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Default)]
|
||||
pub struct OpenAiFileUploadOptions {
|
||||
pub store_in_library: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct UploadedOpenAiFile {
|
||||
pub file_id: String,
|
||||
pub uri: String,
|
||||
pub download_url: String,
|
||||
pub download_url: Option<String>,
|
||||
pub file_name: String,
|
||||
pub file_size_bytes: u64,
|
||||
pub mime_type: Option<String>,
|
||||
pub path: PathBuf,
|
||||
pub library_file_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct OpenAiFileDownloadInfo {
|
||||
pub download_url: String,
|
||||
pub file_name: Option<String>,
|
||||
pub mime_type: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
@@ -42,6 +62,18 @@ pub enum OpenAiFileError {
|
||||
#[source]
|
||||
source: std::io::Error,
|
||||
},
|
||||
#[error("failed to read OpenAI file response from {url}: {source}")]
|
||||
ReadResponse {
|
||||
url: String,
|
||||
#[source]
|
||||
source: std::io::Error,
|
||||
},
|
||||
#[error("path `{path}` cannot be written: {source}")]
|
||||
WriteFile {
|
||||
path: PathBuf,
|
||||
#[source]
|
||||
source: std::io::Error,
|
||||
},
|
||||
#[error(
|
||||
"file `{path}` is too large: {size_bytes} bytes exceeds the limit of {limit_bytes} bytes"
|
||||
)]
|
||||
@@ -68,6 +100,12 @@ pub enum OpenAiFileError {
|
||||
#[source]
|
||||
source: serde_json::Error,
|
||||
},
|
||||
#[error("failed to resolve OpenAI file URL `{url}`: {source}")]
|
||||
InvalidUrl {
|
||||
url: String,
|
||||
#[source]
|
||||
source: url::ParseError,
|
||||
},
|
||||
#[error("OpenAI file upload for `{file_id}` is not ready yet")]
|
||||
UploadNotReady { file_id: String },
|
||||
#[error("OpenAI file upload for `{file_id}` failed: {message}")]
|
||||
@@ -83,6 +121,7 @@ struct CreateFileResponse {
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
struct DownloadLinkResponse {
|
||||
#[serde(default = "download_link_success_status")]
|
||||
status: String,
|
||||
download_url: Option<String>,
|
||||
file_name: Option<String>,
|
||||
@@ -90,14 +129,102 @@ struct DownloadLinkResponse {
|
||||
error_message: Option<String>,
|
||||
}
|
||||
|
||||
fn download_link_success_status() -> String {
|
||||
"success".to_string()
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct ProcessUploadStreamStatus {
|
||||
event: Option<String>,
|
||||
message: Option<String>,
|
||||
#[serde(default)]
|
||||
extra: Option<ProcessUploadStreamExtra>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Deserialize)]
|
||||
struct ProcessUploadStreamExtra {
|
||||
#[serde(alias = "metadata_object_id", alias = "library_file_id")]
|
||||
library_file_id: Option<String>,
|
||||
#[serde(alias = "library_file_name")]
|
||||
file_name: Option<String>,
|
||||
mime_type: Option<String>,
|
||||
}
|
||||
|
||||
pub fn openai_file_uri(file_id: &str) -> String {
|
||||
format!("{OPENAI_FILE_URI_PREFIX}{file_id}")
|
||||
}
|
||||
|
||||
pub async fn download_openai_file(
|
||||
base_url: &str,
|
||||
auth: &impl AuthProvider,
|
||||
download_url: &str,
|
||||
) -> Result<Vec<u8>, OpenAiFileError> {
|
||||
let (resolved_url, response) =
|
||||
send_openai_file_download_request(base_url, auth, download_url).await?;
|
||||
response_bytes(resolved_url.as_str(), response).await
|
||||
}
|
||||
|
||||
pub async fn get_openai_file_download_info(
|
||||
base_url: &str,
|
||||
auth: &impl AuthProvider,
|
||||
file_id: &str,
|
||||
) -> Result<OpenAiFileDownloadInfo, OpenAiFileError> {
|
||||
let base_url = base_url.trim_end_matches('/');
|
||||
let download_link_url = format!("{base_url}/files/download/{file_id}");
|
||||
let response = authorized_request(auth, reqwest::Method::GET, &download_link_url)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|source| OpenAiFileError::Request {
|
||||
url: download_link_url.clone(),
|
||||
source,
|
||||
})?;
|
||||
let body = response_text(&download_link_url, response).await?;
|
||||
let payload: DownloadLinkResponse =
|
||||
serde_json::from_str(&body).map_err(|source| OpenAiFileError::Decode {
|
||||
url: download_link_url.clone(),
|
||||
source,
|
||||
})?;
|
||||
|
||||
if payload.status != "success" {
|
||||
return Err(OpenAiFileError::UploadFailed {
|
||||
file_id: file_id.to_string(),
|
||||
message: payload
|
||||
.error_message
|
||||
.unwrap_or_else(|| "download link resolution returned an error".to_string()),
|
||||
});
|
||||
}
|
||||
|
||||
let download_url = payload
|
||||
.download_url
|
||||
.ok_or_else(|| OpenAiFileError::UploadFailed {
|
||||
file_id: file_id.to_string(),
|
||||
message: "missing download_url".to_string(),
|
||||
})?;
|
||||
|
||||
Ok(OpenAiFileDownloadInfo {
|
||||
download_url,
|
||||
file_name: payload.file_name,
|
||||
mime_type: payload.mime_type,
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn download_openai_file_to_path(
|
||||
base_url: &str,
|
||||
auth: &impl AuthProvider,
|
||||
download_url: &str,
|
||||
path: &Path,
|
||||
max_size_bytes: u64,
|
||||
) -> Result<(), OpenAiFileError> {
|
||||
let (resolved_url, response) =
|
||||
send_openai_file_download_request(base_url, auth, download_url).await?;
|
||||
response_to_file(resolved_url.as_str(), response, path, max_size_bytes).await
|
||||
}
|
||||
|
||||
pub async fn upload_local_file(
|
||||
base_url: &str,
|
||||
auth: &dyn AuthProvider,
|
||||
path: &Path,
|
||||
options: &OpenAiFileUploadOptions,
|
||||
) -> Result<UploadedOpenAiFile, OpenAiFileError> {
|
||||
let metadata = tokio::fs::metadata(path)
|
||||
.await
|
||||
@@ -128,13 +255,18 @@ pub async fn upload_local_file(
|
||||
.and_then(|value| value.to_str())
|
||||
.unwrap_or("file")
|
||||
.to_string();
|
||||
let create_url = format!("{}/files", base_url.trim_end_matches('/'));
|
||||
let base_url = base_url.trim_end_matches('/');
|
||||
let mut create_request = serde_json::json!({
|
||||
"file_name": file_name,
|
||||
"file_size": metadata.len(),
|
||||
"use_case": OPENAI_FILE_USE_CASE,
|
||||
});
|
||||
if options.store_in_library {
|
||||
create_request["store_in_library"] = serde_json::json!(true);
|
||||
}
|
||||
let create_url = format!("{base_url}/files");
|
||||
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_request)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|source| OpenAiFileError::Request {
|
||||
@@ -184,11 +316,29 @@ pub async fn upload_local_file(
|
||||
});
|
||||
}
|
||||
|
||||
let finalize_url = format!(
|
||||
"{}/files/{}/uploaded",
|
||||
base_url.trim_end_matches('/'),
|
||||
create_payload.file_id,
|
||||
);
|
||||
if options.store_in_library {
|
||||
let processed =
|
||||
process_upload_stream(auth, base_url, &create_payload.file_id, &file_name).await?;
|
||||
let library_file_id =
|
||||
processed
|
||||
.library_file_id
|
||||
.ok_or_else(|| OpenAiFileError::UploadFailed {
|
||||
file_id: create_payload.file_id.clone(),
|
||||
message: "upload completed without creating a library_file_id".to_string(),
|
||||
})?;
|
||||
return Ok(UploadedOpenAiFile {
|
||||
file_id: create_payload.file_id.clone(),
|
||||
uri: openai_file_uri(&create_payload.file_id),
|
||||
download_url: None,
|
||||
file_name: processed.file_name.unwrap_or(file_name),
|
||||
file_size_bytes: metadata.len(),
|
||||
mime_type: processed.mime_type,
|
||||
path: path.to_path_buf(),
|
||||
library_file_id: Some(library_file_id),
|
||||
});
|
||||
}
|
||||
|
||||
let finalize_url = format!("{base_url}/files/{}/uploaded", create_payload.file_id);
|
||||
let finalize_started_at = Instant::now();
|
||||
loop {
|
||||
let finalize_response = authorized_request(auth, reqwest::Method::POST, &finalize_url)
|
||||
@@ -219,16 +369,17 @@ pub async fn upload_local_file(
|
||||
return Ok(UploadedOpenAiFile {
|
||||
file_id: create_payload.file_id.clone(),
|
||||
uri: openai_file_uri(&create_payload.file_id),
|
||||
download_url: finalize_payload.download_url.ok_or_else(|| {
|
||||
download_url: Some(finalize_payload.download_url.ok_or_else(|| {
|
||||
OpenAiFileError::UploadFailed {
|
||||
file_id: create_payload.file_id.clone(),
|
||||
message: "missing download_url".to_string(),
|
||||
}
|
||||
})?,
|
||||
})?),
|
||||
file_name: finalize_payload.file_name.unwrap_or(file_name),
|
||||
file_size_bytes: metadata.len(),
|
||||
mime_type: finalize_payload.mime_type,
|
||||
path: path.to_path_buf(),
|
||||
library_file_id: None,
|
||||
});
|
||||
}
|
||||
"retry" => {
|
||||
@@ -251,6 +402,107 @@ pub async fn upload_local_file(
|
||||
}
|
||||
}
|
||||
|
||||
async fn process_upload_stream(
|
||||
auth: &dyn AuthProvider,
|
||||
base_url: &str,
|
||||
file_id: &str,
|
||||
file_name: &str,
|
||||
) -> Result<ProcessUploadStreamExtra, OpenAiFileError> {
|
||||
let process_url = format!("{base_url}/files/process_upload_stream");
|
||||
let process_response = authorized_request(auth, reqwest::Method::POST, &process_url)
|
||||
.json(&serde_json::json!({
|
||||
"file_id": file_id,
|
||||
"file_name": file_name,
|
||||
"use_case": OPENAI_FILE_USE_CASE,
|
||||
"index_for_retrieval": false,
|
||||
"entry_surface": OPENAI_FILE_USE_CASE,
|
||||
"metadata": {
|
||||
"store_in_library": true,
|
||||
},
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|source| OpenAiFileError::Request {
|
||||
url: process_url.clone(),
|
||||
source,
|
||||
})?;
|
||||
let status = process_response.status();
|
||||
if !status.is_success() {
|
||||
let body = process_response.text().await.unwrap_or_default();
|
||||
return Err(OpenAiFileError::UnexpectedStatus {
|
||||
url: process_url,
|
||||
status,
|
||||
body,
|
||||
});
|
||||
}
|
||||
|
||||
let process_stream = process_response
|
||||
.bytes_stream()
|
||||
.map_err(std::io::Error::other);
|
||||
let process_reader = StreamReader::new(process_stream);
|
||||
let mut process_lines = BufReader::new(process_reader).lines();
|
||||
|
||||
let mut result = ProcessUploadStreamExtra::default();
|
||||
while let Some(line) =
|
||||
process_lines
|
||||
.next_line()
|
||||
.await
|
||||
.map_err(|source| OpenAiFileError::ReadResponse {
|
||||
url: process_url.clone(),
|
||||
source,
|
||||
})?
|
||||
{
|
||||
let line = line.trim();
|
||||
if line.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let ProcessUploadStreamStatus {
|
||||
event,
|
||||
message,
|
||||
extra,
|
||||
} = serde_json::from_str(line).map_err(|source| OpenAiFileError::Decode {
|
||||
url: process_url.clone(),
|
||||
source,
|
||||
})?;
|
||||
let extra = extra.unwrap_or_default();
|
||||
|
||||
if let Some(event) = event.as_deref()
|
||||
&& is_process_upload_stream_error_event(event)
|
||||
{
|
||||
return Err(OpenAiFileError::UploadFailed {
|
||||
file_id: file_id.to_string(),
|
||||
message: message
|
||||
.filter(|message| !message.is_empty())
|
||||
.unwrap_or_else(|| format!("process_upload_stream returned {event}")),
|
||||
});
|
||||
}
|
||||
|
||||
if result.library_file_id.is_none() {
|
||||
result.library_file_id = non_empty_string(extra.library_file_id);
|
||||
}
|
||||
if result.file_name.is_none() {
|
||||
result.file_name = non_empty_string(extra.file_name);
|
||||
}
|
||||
if result.mime_type.is_none() {
|
||||
result.mime_type = non_empty_string(extra.mime_type);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
fn non_empty_string(value: Option<String>) -> Option<String> {
|
||||
value.filter(|value| !value.is_empty())
|
||||
}
|
||||
|
||||
fn is_process_upload_stream_error_event(event: &str) -> bool {
|
||||
let event_tail = event.rsplit(['.', '_']).next().unwrap_or(event);
|
||||
matches!(
|
||||
event_tail,
|
||||
"error" | "failed" | "cancelled" | "canceled" | "unknown"
|
||||
)
|
||||
}
|
||||
|
||||
fn authorized_request(
|
||||
auth: &dyn AuthProvider,
|
||||
method: reqwest::Method,
|
||||
@@ -266,6 +518,154 @@ fn authorized_request(
|
||||
.headers(headers)
|
||||
}
|
||||
|
||||
async fn send_openai_file_download_request(
|
||||
base_url: &str,
|
||||
auth: &impl AuthProvider,
|
||||
download_url: &str,
|
||||
) -> Result<(Url, reqwest::Response), OpenAiFileError> {
|
||||
let resolved_url = Url::parse(download_url).map_err(|source| OpenAiFileError::InvalidUrl {
|
||||
url: download_url.to_string(),
|
||||
source,
|
||||
})?;
|
||||
let request_builder = if should_attach_auth_to_openai_file_url(&resolved_url, base_url) {
|
||||
let mut headers = http::HeaderMap::new();
|
||||
auth.add_auth_headers(&mut headers);
|
||||
build_reqwest_client_no_redirects()
|
||||
.request(reqwest::Method::GET, resolved_url.as_str())
|
||||
.timeout(OPENAI_FILE_REQUEST_TIMEOUT)
|
||||
.headers(headers)
|
||||
} else {
|
||||
build_reqwest_client()
|
||||
.request(reqwest::Method::GET, resolved_url.as_str())
|
||||
.timeout(OPENAI_FILE_REQUEST_TIMEOUT)
|
||||
};
|
||||
let response = request_builder
|
||||
.send()
|
||||
.await
|
||||
.map_err(|source| OpenAiFileError::Request {
|
||||
url: resolved_url.to_string(),
|
||||
source,
|
||||
})?;
|
||||
Ok((resolved_url, response))
|
||||
}
|
||||
|
||||
async fn response_text(url: &str, response: reqwest::Response) -> Result<String, OpenAiFileError> {
|
||||
let status = response.status();
|
||||
let body = response.text().await.unwrap_or_default();
|
||||
if !status.is_success() {
|
||||
return Err(OpenAiFileError::UnexpectedStatus {
|
||||
url: url.to_string(),
|
||||
status,
|
||||
body,
|
||||
});
|
||||
}
|
||||
Ok(body)
|
||||
}
|
||||
|
||||
async fn response_bytes(
|
||||
url: &str,
|
||||
response: reqwest::Response,
|
||||
) -> Result<Vec<u8>, OpenAiFileError> {
|
||||
let status = response.status();
|
||||
if !status.is_success() {
|
||||
let body = response.text().await.unwrap_or_default();
|
||||
return Err(OpenAiFileError::UnexpectedStatus {
|
||||
url: url.to_string(),
|
||||
status,
|
||||
body,
|
||||
});
|
||||
}
|
||||
let bytes = response
|
||||
.bytes()
|
||||
.await
|
||||
.map_err(|source| OpenAiFileError::Request {
|
||||
url: url.to_string(),
|
||||
source,
|
||||
})?;
|
||||
Ok(bytes.to_vec())
|
||||
}
|
||||
|
||||
async fn response_to_file(
|
||||
url: &str,
|
||||
response: reqwest::Response,
|
||||
path: &Path,
|
||||
max_size_bytes: u64,
|
||||
) -> Result<(), OpenAiFileError> {
|
||||
let status = response.status();
|
||||
if !status.is_success() {
|
||||
let body = response.text().await.unwrap_or_default();
|
||||
return Err(OpenAiFileError::UnexpectedStatus {
|
||||
url: url.to_string(),
|
||||
status,
|
||||
body,
|
||||
});
|
||||
}
|
||||
|
||||
if let Some(content_length) = response.content_length()
|
||||
&& content_length > max_size_bytes
|
||||
{
|
||||
return Err(OpenAiFileError::FileTooLarge {
|
||||
path: path.to_path_buf(),
|
||||
size_bytes: content_length,
|
||||
limit_bytes: max_size_bytes,
|
||||
});
|
||||
}
|
||||
|
||||
let mut file = File::create(path)
|
||||
.await
|
||||
.map_err(|source| OpenAiFileError::WriteFile {
|
||||
path: path.to_path_buf(),
|
||||
source,
|
||||
})?;
|
||||
let mut size_bytes = 0_u64;
|
||||
let mut stream = response.bytes_stream();
|
||||
|
||||
while let Some(chunk) = stream.next().await {
|
||||
let chunk = chunk.map_err(|source| OpenAiFileError::Request {
|
||||
url: url.to_string(),
|
||||
source,
|
||||
})?;
|
||||
size_bytes += chunk.len() as u64;
|
||||
if size_bytes > max_size_bytes {
|
||||
let _ = tokio::fs::remove_file(path).await;
|
||||
return Err(OpenAiFileError::FileTooLarge {
|
||||
path: path.to_path_buf(),
|
||||
size_bytes,
|
||||
limit_bytes: max_size_bytes,
|
||||
});
|
||||
}
|
||||
if let Err(source) = file.write_all(&chunk).await {
|
||||
let _ = tokio::fs::remove_file(path).await;
|
||||
return Err(OpenAiFileError::WriteFile {
|
||||
path: path.to_path_buf(),
|
||||
source,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
file.flush()
|
||||
.await
|
||||
.map_err(|source| OpenAiFileError::WriteFile {
|
||||
path: path.to_path_buf(),
|
||||
source,
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn should_attach_auth_to_openai_file_url(download_url: &Url, base_url: &str) -> bool {
|
||||
let Ok(base_url) = Url::parse(base_url) else {
|
||||
return false;
|
||||
};
|
||||
download_url
|
||||
.scheme()
|
||||
.eq_ignore_ascii_case(base_url.scheme())
|
||||
&& download_url.port_or_known_default() == base_url.port_or_known_default()
|
||||
&& match (download_url.host_str(), base_url.host_str()) {
|
||||
(Some(download_host), Some(base_host)) => download_host.eq_ignore_ascii_case(base_host),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn build_reqwest_client() -> reqwest::Client {
|
||||
build_reqwest_client_with_custom_ca(reqwest::Client::builder()).unwrap_or_else(|error| {
|
||||
tracing::warn!(error = %error, "failed to build OpenAI file upload client");
|
||||
@@ -273,6 +673,22 @@ fn build_reqwest_client() -> reqwest::Client {
|
||||
})
|
||||
}
|
||||
|
||||
fn build_reqwest_client_no_redirects() -> reqwest::Client {
|
||||
build_reqwest_client_with_custom_ca(
|
||||
reqwest::Client::builder().redirect(reqwest::redirect::Policy::none()),
|
||||
)
|
||||
.unwrap_or_else(|error| {
|
||||
tracing::warn!(error = %error, "failed to build OpenAI file upload client");
|
||||
reqwest::Client::builder()
|
||||
.redirect(reqwest::redirect::Policy::none())
|
||||
.build()
|
||||
.unwrap_or_else(|build_error| {
|
||||
tracing::warn!(error = %build_error, "failed to build no-redirect OpenAI file client");
|
||||
reqwest::Client::new()
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -363,18 +779,255 @@ 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,
|
||||
&OpenAiFileUploadOptions::default(),
|
||||
)
|
||||
.await
|
||||
.expect("upload succeeds");
|
||||
|
||||
assert_eq!(uploaded.file_id, "file_123");
|
||||
assert_eq!(uploaded.uri, "sediment://file_123");
|
||||
assert_eq!(
|
||||
uploaded.download_url,
|
||||
format!("{}/download/file_123", server.uri())
|
||||
Some(format!("{}/download/file_123", server.uri()))
|
||||
);
|
||||
assert_eq!(uploaded.file_name, "hello.txt");
|
||||
assert_eq!(uploaded.mime_type, Some("text/plain".to_string()));
|
||||
assert_eq!(finalize_attempts.load(Ordering::SeqCst), 2);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn download_openai_file_to_path_enforces_size_limit() {
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/download/file_123"))
|
||||
.and(header("authorization", "Bearer token"))
|
||||
.and(header("chatgpt-account-id", "account_id"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_bytes(b"hello".to_vec()))
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let dir = TempDir::new().expect("temp dir");
|
||||
let path = dir.path().join("downloaded.txt");
|
||||
|
||||
let error = download_openai_file_to_path(
|
||||
&base_url_for(&server),
|
||||
&chatgpt_auth(),
|
||||
&format!("{}/download/file_123", server.uri()),
|
||||
&path,
|
||||
/*max_size_bytes*/ 4,
|
||||
)
|
||||
.await
|
||||
.expect_err("download should fail");
|
||||
|
||||
assert!(matches!(
|
||||
error,
|
||||
OpenAiFileError::FileTooLarge {
|
||||
size_bytes: 5,
|
||||
limit_bytes: 4,
|
||||
..
|
||||
}
|
||||
));
|
||||
assert!(!path.exists());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_openai_file_download_info_defaults_missing_status_to_success() {
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/backend-api/files/download/file_123"))
|
||||
.and(header("authorization", "Bearer token"))
|
||||
.and(header("chatgpt-account-id", "account_id"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
|
||||
"download_url": format!("{}/download/file_123", server.uri()),
|
||||
"file_name": "hello.txt",
|
||||
"mime_type": "text/plain",
|
||||
})))
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let info =
|
||||
get_openai_file_download_info(&base_url_for(&server), &chatgpt_auth(), "file_123")
|
||||
.await
|
||||
.expect("download info should resolve");
|
||||
|
||||
assert_eq!(
|
||||
info,
|
||||
OpenAiFileDownloadInfo {
|
||||
download_url: format!("{}/download/file_123", server.uri()),
|
||||
file_name: Some("hello.txt".to_string()),
|
||||
mime_type: Some("text/plain".to_string()),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn authenticated_download_does_not_follow_redirects() {
|
||||
let server = MockServer::start().await;
|
||||
let redirected_server = MockServer::start().await;
|
||||
let redirected_hits = Arc::new(AtomicUsize::new(0));
|
||||
let redirected_hits_responder = Arc::clone(&redirected_hits);
|
||||
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/download/file_123"))
|
||||
.and(header("authorization", "Bearer token"))
|
||||
.and(header("chatgpt-account-id", "account_id"))
|
||||
.respond_with(ResponseTemplate::new(302).append_header(
|
||||
"location",
|
||||
format!("{}/redirected", redirected_server.uri()),
|
||||
))
|
||||
.mount(&server)
|
||||
.await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/redirected"))
|
||||
.respond_with(move |_request: &Request| {
|
||||
redirected_hits_responder.fetch_add(1, Ordering::SeqCst);
|
||||
ResponseTemplate::new(200).set_body_bytes(b"redirected".to_vec())
|
||||
})
|
||||
.mount(&redirected_server)
|
||||
.await;
|
||||
|
||||
let error = download_openai_file(
|
||||
&base_url_for(&server),
|
||||
&chatgpt_auth(),
|
||||
&format!("{}/download/file_123", server.uri()),
|
||||
)
|
||||
.await
|
||||
.expect_err("download should not follow redirect");
|
||||
|
||||
assert!(matches!(
|
||||
error,
|
||||
OpenAiFileError::UnexpectedStatus {
|
||||
status: StatusCode::FOUND,
|
||||
..
|
||||
}
|
||||
));
|
||||
assert_eq!(redirected_hits.load(Ordering::SeqCst), 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn upload_local_file_stores_library_file_with_process_upload_stream() {
|
||||
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": "hello.txt",
|
||||
"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", "upload_url": format!("{}/upload/file_123", server.uri())})),
|
||||
)
|
||||
.mount(&server)
|
||||
.await;
|
||||
Mock::given(method("PUT"))
|
||||
.and(path("/upload/file_123"))
|
||||
.and(header("content-length", "5"))
|
||||
.respond_with(ResponseTemplate::new(200))
|
||||
.mount(&server)
|
||||
.await;
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/backend-api/files/process_upload_stream"))
|
||||
.and(body_json(serde_json::json!({
|
||||
"file_id": "file_123",
|
||||
"file_name": "hello.txt",
|
||||
"use_case": "codex",
|
||||
"index_for_retrieval": false,
|
||||
"entry_surface": "codex",
|
||||
"metadata": {
|
||||
"store_in_library": true,
|
||||
},
|
||||
})))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_bytes(
|
||||
concat!(
|
||||
"{\"file_id\":\"file_123\",\"event\":\"indexing.completed\",\"message\":\"\",",
|
||||
"\"extra\":{\"metadata_object_id\":\"library_123\",",
|
||||
"\"library_file_name\":\"hello.txt\",\"mime_type\":\"text/plain\"}}\n",
|
||||
"{\"file_id\":\"file_123\",\"event\":\"completed\",",
|
||||
"\"message\":\"Succeeded processing file file_123\",",
|
||||
"\"progress\":100,\"extra\":null}\n",
|
||||
)
|
||||
.as_bytes()
|
||||
.to_vec(),
|
||||
))
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let base_url = base_url_for(&server);
|
||||
let dir = TempDir::new().expect("temp dir");
|
||||
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,
|
||||
&OpenAiFileUploadOptions {
|
||||
store_in_library: true,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("upload succeeds");
|
||||
|
||||
assert_eq!(uploaded.file_id, "file_123");
|
||||
assert_eq!(uploaded.uri, "sediment://file_123");
|
||||
assert_eq!(uploaded.download_url, None);
|
||||
assert_eq!(uploaded.file_name, "hello.txt");
|
||||
assert_eq!(uploaded.mime_type, Some("text/plain".to_string()));
|
||||
assert_eq!(uploaded.library_file_id, Some("library_123".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_attach_auth_only_for_same_origin() {
|
||||
let base_url = "https://chatgpt.com/backend-api";
|
||||
|
||||
assert!(should_attach_auth_to_openai_file_url(
|
||||
&Url::parse("https://chatgpt.com/files/file_123/content").expect("valid url"),
|
||||
base_url,
|
||||
));
|
||||
assert!(!should_attach_auth_to_openai_file_url(
|
||||
&Url::parse("http://chatgpt.com/files/file_123/content").expect("valid url"),
|
||||
base_url,
|
||||
));
|
||||
assert!(!should_attach_auth_to_openai_file_url(
|
||||
&Url::parse("https://chatgpt.com:8443/files/file_123/content").expect("valid url"),
|
||||
base_url,
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn process_upload_stream_fails_when_late_failed_event_is_seen() {
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/backend-api/files/process_upload_stream"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_bytes(
|
||||
concat!(
|
||||
"{\"file_id\":\"file_123\",\"event\":\"indexing.completed\",\"message\":\"\",",
|
||||
"\"extra\":{\"metadata_object_id\":\"library_123\"}}\n",
|
||||
"{\"file_id\":\"file_123\",\"event\":\"indexing.failed\",",
|
||||
"\"message\":\"indexing failed\",\"extra\":null}\n",
|
||||
)
|
||||
.as_bytes()
|
||||
.to_vec(),
|
||||
))
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let base_url = base_url_for(&server);
|
||||
let error = process_upload_stream(&chatgpt_auth(), &base_url, "file_123", "hello.txt")
|
||||
.await
|
||||
.expect_err("stream processing should fail");
|
||||
|
||||
assert!(matches!(
|
||||
error,
|
||||
OpenAiFileError::UploadFailed { ref file_id, ref message }
|
||||
if file_id == "file_123" && message == "indexing failed"
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,6 +57,12 @@ pub use crate::endpoint::ResponsesWebsocketClient;
|
||||
pub use crate::endpoint::ResponsesWebsocketConnection;
|
||||
pub use crate::endpoint::session_update_session_json;
|
||||
pub use crate::error::ApiError;
|
||||
pub use crate::files::OPENAI_FILE_UPLOAD_LIMIT_BYTES;
|
||||
pub use crate::files::OpenAiFileDownloadInfo;
|
||||
pub use crate::files::OpenAiFileUploadOptions;
|
||||
pub use crate::files::download_openai_file;
|
||||
pub use crate::files::download_openai_file_to_path;
|
||||
pub use crate::files::get_openai_file_download_info;
|
||||
pub use crate::files::upload_local_file;
|
||||
pub use crate::provider::Provider;
|
||||
pub use crate::provider::RetryConfig;
|
||||
|
||||
566
codex-rs/core/src/codex_apps_file_download.rs
Normal file
566
codex-rs/core/src/codex_apps_file_download.rs
Normal file
@@ -0,0 +1,566 @@
|
||||
use crate::mcp_openai_file::OPENAI_LIBRARY_CONNECTOR_ID;
|
||||
use crate::session::session::Session;
|
||||
use crate::session::turn_context::TurnContext;
|
||||
use codex_api::OPENAI_FILE_UPLOAD_LIMIT_BYTES;
|
||||
use codex_api::download_openai_file_to_path;
|
||||
use codex_api::get_openai_file_download_info;
|
||||
use codex_login::CodexAuth;
|
||||
use codex_model_provider::BearerAuthProvider;
|
||||
use codex_protocol::mcp::CallToolResult;
|
||||
use serde::Deserialize;
|
||||
use serde::Serialize;
|
||||
use serde_json::Map as JsonMap;
|
||||
use serde_json::Value as JsonValue;
|
||||
use tracing::warn;
|
||||
|
||||
const CODEX_APPS_FILE_DOWNLOAD_ARTIFACTS_DIR: &str = ".tmp/codex_apps_downloads";
|
||||
const CODEX_APPS_META_MATERIALIZE_FILE_DOWNLOAD_KEY: &str = "materialize_file_download";
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
struct CodexAppsFileDownloadPayload {
|
||||
file_id: String,
|
||||
#[serde(default)]
|
||||
file_name: Option<String>,
|
||||
file_uri: CodexAppsFileUri,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
struct CodexAppsFileUri {
|
||||
download_url: String,
|
||||
#[serde(default)]
|
||||
file_name: Option<String>,
|
||||
}
|
||||
|
||||
fn codex_apps_download_base_url(turn_context: &TurnContext) -> &str {
|
||||
turn_context.config.chatgpt_base_url.as_str()
|
||||
}
|
||||
|
||||
fn should_materialize_codex_apps_file_download(
|
||||
server: &str,
|
||||
connector_id: Option<&str>,
|
||||
codex_apps_meta: Option<&JsonMap<String, JsonValue>>,
|
||||
) -> bool {
|
||||
if server != codex_mcp::CODEX_APPS_MCP_SERVER_NAME {
|
||||
return false;
|
||||
}
|
||||
|
||||
if connector_id != Some(OPENAI_LIBRARY_CONNECTOR_ID) {
|
||||
return false;
|
||||
}
|
||||
|
||||
let Some(codex_apps_meta) = codex_apps_meta else {
|
||||
return false;
|
||||
};
|
||||
|
||||
codex_apps_meta
|
||||
.get(CODEX_APPS_META_MATERIALIZE_FILE_DOWNLOAD_KEY)
|
||||
.and_then(JsonValue::as_bool)
|
||||
== Some(true)
|
||||
}
|
||||
|
||||
pub(crate) async fn maybe_materialize_codex_apps_file_download_result(
|
||||
sess: &Session,
|
||||
turn_context: &TurnContext,
|
||||
server: &str,
|
||||
connector_id: Option<&str>,
|
||||
codex_apps_meta: Option<&JsonMap<String, JsonValue>>,
|
||||
mut result: CallToolResult,
|
||||
) -> CallToolResult {
|
||||
if !should_materialize_codex_apps_file_download(server, connector_id, codex_apps_meta)
|
||||
|| result.is_error == Some(true)
|
||||
{
|
||||
return result;
|
||||
}
|
||||
|
||||
let Some(payload) = extract_codex_apps_file_download_payload(&result) else {
|
||||
return result;
|
||||
};
|
||||
let download_base_url = codex_apps_download_base_url(turn_context);
|
||||
if result.structured_content.is_none()
|
||||
&& let Ok(structured_content) = serde_json::to_value(&payload)
|
||||
{
|
||||
result.structured_content = Some(structured_content);
|
||||
}
|
||||
|
||||
let auth = sess.services.auth_manager.auth().await;
|
||||
let Some(auth) = auth.as_ref() else {
|
||||
warn!(
|
||||
"skipping codex_apps file download materialization because ChatGPT auth is unavailable"
|
||||
);
|
||||
redact_codex_apps_file_download_url(&mut result);
|
||||
append_materialization_failure_message(&mut result);
|
||||
return result;
|
||||
};
|
||||
materialize_codex_apps_file_download_result_with_auth(
|
||||
turn_context,
|
||||
download_base_url,
|
||||
&sess.conversation_id.to_string(),
|
||||
auth,
|
||||
payload,
|
||||
result,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn materialize_codex_apps_file_download_result_with_auth(
|
||||
turn_context: &TurnContext,
|
||||
download_base_url: &str,
|
||||
session_id: &str,
|
||||
auth: &CodexAuth,
|
||||
payload: CodexAppsFileDownloadPayload,
|
||||
mut result: CallToolResult,
|
||||
) -> CallToolResult {
|
||||
let token_data = match auth.get_token_data() {
|
||||
Ok(token_data) => token_data,
|
||||
Err(error) => {
|
||||
warn!(error = %error, "failed to read ChatGPT auth for codex_apps file download materialization");
|
||||
redact_codex_apps_file_download_url(&mut result);
|
||||
append_materialization_failure_message(&mut result);
|
||||
return result;
|
||||
}
|
||||
};
|
||||
let auth_provider = BearerAuthProvider {
|
||||
token: Some(token_data.access_token),
|
||||
account_id: token_data.account_id,
|
||||
is_fedramp_account: auth.is_fedramp_account(),
|
||||
};
|
||||
let download_info =
|
||||
match get_openai_file_download_info(download_base_url, &auth_provider, &payload.file_id)
|
||||
.await
|
||||
{
|
||||
Ok(download_info) => download_info,
|
||||
Err(error) => {
|
||||
warn!(
|
||||
error = %error,
|
||||
file_id = payload.file_id,
|
||||
"failed to resolve trusted codex_apps file download link",
|
||||
);
|
||||
redact_codex_apps_file_download_url(&mut result);
|
||||
append_materialization_failure_message(&mut result);
|
||||
return result;
|
||||
}
|
||||
};
|
||||
let artifact_path = codex_apps_file_download_artifact_path(
|
||||
&turn_context.config.codex_home,
|
||||
session_id,
|
||||
&payload.file_id,
|
||||
download_info
|
||||
.file_name
|
||||
.as_deref()
|
||||
.or(payload.file_name.as_deref())
|
||||
.or(payload.file_uri.file_name.as_deref())
|
||||
.unwrap_or("downloaded_file"),
|
||||
);
|
||||
if let Some(parent) = artifact_path.parent()
|
||||
&& let Err(error) = tokio::fs::create_dir_all(parent.as_path()).await
|
||||
{
|
||||
warn!(
|
||||
error = %error,
|
||||
path = %parent.display(),
|
||||
"failed to create codex_apps file download artifact directory",
|
||||
);
|
||||
redact_codex_apps_file_download_url(&mut result);
|
||||
append_materialization_failure_message(&mut result);
|
||||
return result;
|
||||
}
|
||||
|
||||
if let Err(error) = download_openai_file_to_path(
|
||||
download_base_url,
|
||||
&auth_provider,
|
||||
&download_info.download_url,
|
||||
artifact_path.as_path(),
|
||||
OPENAI_FILE_UPLOAD_LIMIT_BYTES,
|
||||
)
|
||||
.await
|
||||
{
|
||||
warn!(
|
||||
error = %error,
|
||||
file_id = payload.file_id,
|
||||
path = %artifact_path.display(),
|
||||
"failed to materialize codex_apps file download via app-server",
|
||||
);
|
||||
redact_codex_apps_file_download_url(&mut result);
|
||||
append_materialization_failure_message(&mut result);
|
||||
return result;
|
||||
}
|
||||
|
||||
let local_path = artifact_path.to_string_lossy().to_string();
|
||||
if let Some(JsonValue::Object(map)) = result.structured_content.as_mut() {
|
||||
map.insert(
|
||||
"local_path".to_string(),
|
||||
JsonValue::String(local_path.clone()),
|
||||
);
|
||||
}
|
||||
redact_codex_apps_file_download_url(&mut result);
|
||||
result.content.push(serde_json::json!({
|
||||
"type": "text",
|
||||
"text": format!("Downloaded file to local path: {local_path}"),
|
||||
}));
|
||||
result
|
||||
}
|
||||
|
||||
fn redact_codex_apps_file_download_url(result: &mut CallToolResult) {
|
||||
if let Some(structured_content) = result.structured_content.as_mut() {
|
||||
redact_download_url_from_value(structured_content);
|
||||
}
|
||||
|
||||
for item in &mut result.content {
|
||||
let Some(item_object) = item.as_object_mut() else {
|
||||
continue;
|
||||
};
|
||||
let Some(text) = item_object.get("text").and_then(JsonValue::as_str) else {
|
||||
continue;
|
||||
};
|
||||
let Ok(mut payload) = serde_json::from_str::<JsonValue>(text) else {
|
||||
continue;
|
||||
};
|
||||
redact_download_url_from_value(&mut payload);
|
||||
if let Ok(serialized) = serde_json::to_string(&payload) {
|
||||
item_object.insert("text".to_string(), JsonValue::String(serialized));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn redact_download_url_from_value(value: &mut JsonValue) {
|
||||
let Some(object) = value.as_object_mut() else {
|
||||
return;
|
||||
};
|
||||
let Some(file_uri) = object
|
||||
.get_mut("file_uri")
|
||||
.and_then(JsonValue::as_object_mut)
|
||||
else {
|
||||
return;
|
||||
};
|
||||
file_uri.remove("download_url");
|
||||
}
|
||||
|
||||
fn append_materialization_failure_message(result: &mut CallToolResult) {
|
||||
result.content.push(serde_json::json!({
|
||||
"type": "text",
|
||||
"text": "Failed to materialize downloaded file to a local path.",
|
||||
}));
|
||||
}
|
||||
|
||||
fn extract_codex_apps_file_download_payload(
|
||||
result: &CallToolResult,
|
||||
) -> Option<CodexAppsFileDownloadPayload> {
|
||||
if let Some(structured_content) = result.structured_content.clone()
|
||||
&& let Ok(payload) =
|
||||
serde_json::from_value::<CodexAppsFileDownloadPayload>(structured_content)
|
||||
{
|
||||
return Some(payload);
|
||||
}
|
||||
|
||||
result
|
||||
.content
|
||||
.iter()
|
||||
.filter_map(|item| item.as_object())
|
||||
.find_map(|item| {
|
||||
let text = item.get("text")?.as_str()?;
|
||||
serde_json::from_str::<CodexAppsFileDownloadPayload>(text).ok()
|
||||
})
|
||||
}
|
||||
|
||||
fn codex_apps_file_download_artifact_path(
|
||||
codex_home: &codex_utils_absolute_path::AbsolutePathBuf,
|
||||
session_id: &str,
|
||||
file_id: &str,
|
||||
file_name: &str,
|
||||
) -> codex_utils_absolute_path::AbsolutePathBuf {
|
||||
codex_home
|
||||
.join(CODEX_APPS_FILE_DOWNLOAD_ARTIFACTS_DIR)
|
||||
.join(sanitize_path_component(session_id, "session"))
|
||||
.join(sanitize_path_component(file_id, "file"))
|
||||
.join(sanitize_file_name(file_name))
|
||||
}
|
||||
|
||||
fn sanitize_path_component(value: &str, fallback: &str) -> String {
|
||||
let sanitized: String = value
|
||||
.chars()
|
||||
.map(|ch| {
|
||||
if ch.is_ascii_alphanumeric() || ch == '-' || ch == '_' {
|
||||
ch
|
||||
} else {
|
||||
'_'
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
if sanitized.is_empty() {
|
||||
fallback.to_string()
|
||||
} else {
|
||||
sanitized
|
||||
}
|
||||
}
|
||||
|
||||
fn sanitize_file_name(value: &str) -> String {
|
||||
let sanitized: String = value
|
||||
.chars()
|
||||
.map(|ch| {
|
||||
if ch.is_ascii_alphanumeric() || ch == '-' || ch == '_' || ch == '.' {
|
||||
ch
|
||||
} else {
|
||||
'_'
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
if sanitized.is_empty() {
|
||||
"downloaded_file".to_string()
|
||||
} else {
|
||||
sanitized
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::session::tests::make_session_and_context;
|
||||
use codex_login::CodexAuth;
|
||||
use pretty_assertions::assert_eq;
|
||||
use std::sync::Arc;
|
||||
use wiremock::Mock;
|
||||
use wiremock::MockServer;
|
||||
use wiremock::Request;
|
||||
use wiremock::ResponseTemplate;
|
||||
use wiremock::matchers::header;
|
||||
use wiremock::matchers::method;
|
||||
use wiremock::matchers::path;
|
||||
|
||||
#[tokio::test]
|
||||
async fn codex_apps_file_download_materialization_adds_local_path_for_marked_tools() {
|
||||
let server = MockServer::start().await;
|
||||
let attacker_server = MockServer::start().await;
|
||||
let attacker_hits = Arc::new(std::sync::atomic::AtomicUsize::new(0));
|
||||
let attacker_hits_responder = Arc::clone(&attacker_hits);
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/backend-api/files/download/file_123"))
|
||||
.and(header("authorization", "Bearer Access Token"))
|
||||
.and(header("chatgpt-account-id", "account_id"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
|
||||
"status": "success",
|
||||
"download_url": format!("{}/download/file_123", server.uri()),
|
||||
"file_name": "trusted-name.txt",
|
||||
"mime_type": "text/plain",
|
||||
})))
|
||||
.mount(&server)
|
||||
.await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/download/file_123"))
|
||||
.and(header("authorization", "Bearer Access Token"))
|
||||
.and(header("chatgpt-account-id", "account_id"))
|
||||
.respond_with(
|
||||
ResponseTemplate::new(200)
|
||||
.insert_header("content-type", "text/plain")
|
||||
.set_body_bytes(b"downloaded contents".to_vec()),
|
||||
)
|
||||
.mount(&server)
|
||||
.await;
|
||||
Mock::given(method("GET"))
|
||||
.respond_with(move |_request: &Request| {
|
||||
attacker_hits_responder.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
|
||||
ResponseTemplate::new(200).set_body_bytes(b"attacker controlled".to_vec())
|
||||
})
|
||||
.mount(&attacker_server)
|
||||
.await;
|
||||
|
||||
let (_, mut turn_context) = make_session_and_context().await;
|
||||
let mut config = (*turn_context.config).clone();
|
||||
config.chatgpt_base_url = format!("{}/backend-api", server.uri());
|
||||
turn_context.config = Arc::new(config);
|
||||
let original_payload = serde_json::json!({
|
||||
"file_id": "file_123",
|
||||
"file_name": "testing-file.txt",
|
||||
"file_uri": {
|
||||
"download_url": format!("{}/download/file_123", attacker_server.uri()),
|
||||
"file_id": "file_123",
|
||||
"file_name": "testing-file.txt",
|
||||
"mime_type": "text/plain",
|
||||
}
|
||||
});
|
||||
let original = CallToolResult {
|
||||
content: vec![serde_json::json!({
|
||||
"type": "text",
|
||||
"text": original_payload.to_string(),
|
||||
})],
|
||||
structured_content: Some(original_payload),
|
||||
is_error: Some(false),
|
||||
meta: None,
|
||||
};
|
||||
|
||||
let result = materialize_codex_apps_file_download_result_with_auth(
|
||||
&turn_context,
|
||||
turn_context.config.chatgpt_base_url.as_str(),
|
||||
"session-1",
|
||||
&CodexAuth::create_dummy_chatgpt_auth_for_testing(),
|
||||
serde_json::from_value(
|
||||
original
|
||||
.structured_content
|
||||
.clone()
|
||||
.expect("structured content should exist"),
|
||||
)
|
||||
.expect("download payload"),
|
||||
original,
|
||||
)
|
||||
.await;
|
||||
|
||||
let local_path = result
|
||||
.structured_content
|
||||
.as_ref()
|
||||
.and_then(|value| value.get("local_path"))
|
||||
.and_then(JsonValue::as_str)
|
||||
.expect("local_path in structured content");
|
||||
assert!(local_path.contains("codex_apps_downloads"));
|
||||
assert!(local_path.ends_with("trusted-name.txt"));
|
||||
let structured_download_url = result
|
||||
.structured_content
|
||||
.as_ref()
|
||||
.and_then(|value| value.get("file_uri"))
|
||||
.and_then(|value| value.get("download_url"));
|
||||
assert_eq!(structured_download_url, None);
|
||||
let saved = tokio::fs::read(local_path)
|
||||
.await
|
||||
.expect("saved local file should exist");
|
||||
assert_eq!(saved, b"downloaded contents".to_vec());
|
||||
assert_eq!(attacker_hits.load(std::sync::atomic::Ordering::SeqCst), 0);
|
||||
assert!(!result.content.iter().any(|block| {
|
||||
block
|
||||
.get("text")
|
||||
.and_then(JsonValue::as_str)
|
||||
.is_some_and(|text| text.contains(&attacker_server.uri()))
|
||||
}));
|
||||
assert!(result.content.iter().any(|block| {
|
||||
block.get("type").and_then(JsonValue::as_str) == Some("text")
|
||||
&& block
|
||||
.get("text")
|
||||
.and_then(JsonValue::as_str)
|
||||
.is_some_and(|text| text.contains("Downloaded file to local path:"))
|
||||
}));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn codex_apps_file_download_materialization_failure_redacts_download_url() {
|
||||
let server = MockServer::start().await;
|
||||
let attacker_server = MockServer::start().await;
|
||||
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/backend-api/files/download/file_123"))
|
||||
.and(header("authorization", "Bearer Access Token"))
|
||||
.and(header("chatgpt-account-id", "account_id"))
|
||||
.respond_with(ResponseTemplate::new(500).set_body_string("boom"))
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let (_, mut turn_context) = make_session_and_context().await;
|
||||
let mut config = (*turn_context.config).clone();
|
||||
config.chatgpt_base_url = format!("{}/backend-api", server.uri());
|
||||
turn_context.config = Arc::new(config);
|
||||
let original_payload = serde_json::json!({
|
||||
"file_id": "file_123",
|
||||
"file_name": "testing-file.txt",
|
||||
"file_uri": {
|
||||
"download_url": format!("{}/download/file_123", attacker_server.uri()),
|
||||
"file_name": "testing-file.txt",
|
||||
"mime_type": "text/plain",
|
||||
}
|
||||
});
|
||||
let original = CallToolResult {
|
||||
content: vec![serde_json::json!({
|
||||
"type": "text",
|
||||
"text": original_payload.to_string(),
|
||||
})],
|
||||
structured_content: Some(original_payload),
|
||||
is_error: Some(false),
|
||||
meta: None,
|
||||
};
|
||||
|
||||
let result = materialize_codex_apps_file_download_result_with_auth(
|
||||
&turn_context,
|
||||
turn_context.config.chatgpt_base_url.as_str(),
|
||||
"session-1",
|
||||
&CodexAuth::create_dummy_chatgpt_auth_for_testing(),
|
||||
serde_json::from_value(
|
||||
original
|
||||
.structured_content
|
||||
.clone()
|
||||
.expect("structured content should exist"),
|
||||
)
|
||||
.expect("download payload"),
|
||||
original,
|
||||
)
|
||||
.await;
|
||||
|
||||
assert!(
|
||||
result
|
||||
.structured_content
|
||||
.as_ref()
|
||||
.and_then(|value| value.get("local_path"))
|
||||
.is_none()
|
||||
);
|
||||
assert_eq!(
|
||||
result
|
||||
.structured_content
|
||||
.as_ref()
|
||||
.and_then(|value| value.get("file_uri"))
|
||||
.and_then(|value| value.get("download_url")),
|
||||
None,
|
||||
);
|
||||
assert!(!result.content.iter().any(|block| {
|
||||
block
|
||||
.get("text")
|
||||
.and_then(JsonValue::as_str)
|
||||
.is_some_and(|text| text.contains(&attacker_server.uri()))
|
||||
}));
|
||||
assert!(result.content.iter().any(|block| {
|
||||
block.get("type").and_then(JsonValue::as_str) == Some("text")
|
||||
&& block
|
||||
.get("text")
|
||||
.and_then(JsonValue::as_str)
|
||||
.is_some_and(|text| {
|
||||
text.contains("Failed to materialize downloaded file to a local path.")
|
||||
})
|
||||
}));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn codex_apps_file_download_materialization_is_noop_for_non_library_connector() {
|
||||
let (session, turn_context) = make_session_and_context().await;
|
||||
let original = CallToolResult {
|
||||
content: vec![serde_json::json!({
|
||||
"type": "text",
|
||||
"text": serde_json::json!({
|
||||
"file_id": "file_123",
|
||||
"file_name": "testing-file.txt",
|
||||
"file_uri": {
|
||||
"download_url": "https://attacker.example/download/file_123",
|
||||
"file_name": "testing-file.txt",
|
||||
}
|
||||
})
|
||||
.to_string(),
|
||||
})],
|
||||
structured_content: Some(serde_json::json!({
|
||||
"file_id": "file_123",
|
||||
"file_name": "testing-file.txt",
|
||||
"file_uri": {
|
||||
"download_url": "https://attacker.example/download/file_123",
|
||||
"file_name": "testing-file.txt",
|
||||
}
|
||||
})),
|
||||
is_error: Some(false),
|
||||
meta: None,
|
||||
};
|
||||
|
||||
let result = maybe_materialize_codex_apps_file_download_result(
|
||||
&session,
|
||||
&turn_context,
|
||||
codex_mcp::CODEX_APPS_MCP_SERVER_NAME,
|
||||
Some("connector_not_library"),
|
||||
Some(&serde_json::Map::from_iter([(
|
||||
CODEX_APPS_META_MATERIALIZE_FILE_DOWNLOAD_KEY.to_string(),
|
||||
JsonValue::Bool(true),
|
||||
)])),
|
||||
original.clone(),
|
||||
)
|
||||
.await;
|
||||
|
||||
assert_eq!(result, original);
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,7 @@ pub use codex_thread::CodexThread;
|
||||
pub use codex_thread::CodexThreadTurnContextOverrides;
|
||||
pub use codex_thread::ThreadConfigSnapshot;
|
||||
mod agent;
|
||||
mod codex_apps_file_download;
|
||||
mod codex_delegate;
|
||||
mod command_canonicalization;
|
||||
mod commit_attribution;
|
||||
|
||||
@@ -10,17 +10,24 @@
|
||||
//! 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::codex_apps_file_download::maybe_materialize_codex_apps_file_download_result;
|
||||
use crate::session::session::Session;
|
||||
use crate::session::turn_context::TurnContext;
|
||||
use codex_api::OpenAiFileUploadOptions;
|
||||
use codex_api::upload_local_file;
|
||||
use codex_login::CodexAuth;
|
||||
use codex_protocol::mcp::CallToolResult;
|
||||
use serde_json::Map as JsonMap;
|
||||
use serde_json::Value as JsonValue;
|
||||
|
||||
pub(crate) const OPENAI_LIBRARY_CONNECTOR_ID: &str = "connector_openai_library";
|
||||
|
||||
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]>,
|
||||
upload_options: Option<&OpenAiFileUploadOptions>,
|
||||
) -> Result<Option<JsonValue>, String> {
|
||||
let Some(openai_file_input_params) = openai_file_input_params else {
|
||||
return Ok(arguments_value);
|
||||
@@ -39,9 +46,14 @@ pub(crate) async fn rewrite_mcp_tool_arguments_for_openai_files(
|
||||
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,
|
||||
upload_options,
|
||||
)
|
||||
.await?
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
@@ -55,11 +67,31 @@ pub(crate) async fn rewrite_mcp_tool_arguments_for_openai_files(
|
||||
Ok(Some(JsonValue::Object(rewritten_arguments)))
|
||||
}
|
||||
|
||||
pub(crate) async fn postprocess_mcp_tool_result_for_openai_files(
|
||||
sess: &Session,
|
||||
turn_context: &TurnContext,
|
||||
server: &str,
|
||||
connector_id: Option<&str>,
|
||||
codex_apps_meta: Option<&JsonMap<String, JsonValue>>,
|
||||
result: CallToolResult,
|
||||
) -> CallToolResult {
|
||||
maybe_materialize_codex_apps_file_download_result(
|
||||
sess,
|
||||
turn_context,
|
||||
server,
|
||||
connector_id,
|
||||
codex_apps_meta,
|
||||
result,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn rewrite_argument_value_for_openai_files(
|
||||
turn_context: &TurnContext,
|
||||
auth: Option<&CodexAuth>,
|
||||
field_name: &str,
|
||||
value: &JsonValue,
|
||||
upload_options: Option<&OpenAiFileUploadOptions>,
|
||||
) -> Result<Option<JsonValue>, String> {
|
||||
match value {
|
||||
JsonValue::String(path_or_file_ref) => {
|
||||
@@ -69,6 +101,7 @@ async fn rewrite_argument_value_for_openai_files(
|
||||
field_name,
|
||||
/*index*/ None,
|
||||
path_or_file_ref,
|
||||
upload_options,
|
||||
)
|
||||
.await?;
|
||||
Ok(Some(rewritten))
|
||||
@@ -85,6 +118,7 @@ async fn rewrite_argument_value_for_openai_files(
|
||||
field_name,
|
||||
Some(index),
|
||||
path_or_file_ref,
|
||||
upload_options,
|
||||
)
|
||||
.await?;
|
||||
rewritten_values.push(rewritten);
|
||||
@@ -101,6 +135,7 @@ async fn build_uploaded_local_argument_value(
|
||||
field_name: &str,
|
||||
index: Option<usize>,
|
||||
file_path: &str,
|
||||
upload_options: Option<&OpenAiFileUploadOptions>,
|
||||
) -> Result<JsonValue, String> {
|
||||
let resolved_path = turn_context.resolve_path(Some(file_path.to_string()));
|
||||
let Some(auth) = auth else {
|
||||
@@ -114,10 +149,12 @@ async fn build_uploaded_local_argument_value(
|
||||
);
|
||||
}
|
||||
let upload_auth = codex_model_provider::auth_provider_from_auth(auth);
|
||||
let default_upload_options = OpenAiFileUploadOptions::default();
|
||||
let uploaded = upload_local_file(
|
||||
turn_context.config.chatgpt_base_url.trim_end_matches('/'),
|
||||
upload_auth.as_ref(),
|
||||
&resolved_path,
|
||||
upload_options.unwrap_or(&default_upload_options),
|
||||
)
|
||||
.await
|
||||
.map_err(|error| match index {
|
||||
@@ -126,14 +163,21 @@ async fn build_uploaded_local_argument_value(
|
||||
}
|
||||
None => format!("failed to upload `{file_path}` for `{field_name}`: {error}"),
|
||||
})?;
|
||||
Ok(serde_json::json!({
|
||||
let mut uploaded_value = serde_json::json!({
|
||||
"download_url": uploaded.download_url,
|
||||
"file_id": uploaded.file_id,
|
||||
"library_file_id": uploaded.library_file_id,
|
||||
"mime_type": uploaded.mime_type,
|
||||
"file_name": uploaded.file_name,
|
||||
"uri": uploaded.uri,
|
||||
"file_size_bytes": uploaded.file_size_bytes,
|
||||
}))
|
||||
});
|
||||
if uploaded.library_file_id.is_none()
|
||||
&& let Some(uploaded_object) = uploaded_value.as_object_mut()
|
||||
{
|
||||
uploaded_object.remove("library_file_id");
|
||||
}
|
||||
Ok(uploaded_value)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -157,6 +201,7 @@ mod tests {
|
||||
&Arc::new(turn_context),
|
||||
arguments.clone(),
|
||||
/*openai_file_input_params*/ None,
|
||||
/*upload_options*/ None,
|
||||
)
|
||||
.await
|
||||
.expect("rewrite should succeed");
|
||||
@@ -228,6 +273,7 @@ mod tests {
|
||||
"file",
|
||||
/*index*/ None,
|
||||
"file_report.csv",
|
||||
/*upload_options*/ None,
|
||||
)
|
||||
.await
|
||||
.expect("rewrite should upload the local file");
|
||||
@@ -307,6 +353,7 @@ mod tests {
|
||||
Some(&auth),
|
||||
"file",
|
||||
&serde_json::json!("file_report.csv"),
|
||||
/*upload_options*/ None,
|
||||
)
|
||||
.await
|
||||
.expect("rewrite should succeed");
|
||||
@@ -421,6 +468,7 @@ mod tests {
|
||||
Some(&auth),
|
||||
"files",
|
||||
&serde_json::json!(["one.csv", "two.csv"]),
|
||||
/*upload_options*/ None,
|
||||
)
|
||||
.await
|
||||
.expect("rewrite should succeed");
|
||||
@@ -461,6 +509,7 @@ mod tests {
|
||||
"file": "/definitely/missing/file.csv",
|
||||
})),
|
||||
Some(&["file".to_string()]),
|
||||
/*upload_options*/ None,
|
||||
)
|
||||
.await
|
||||
.expect_err("missing file should fail");
|
||||
|
||||
@@ -26,6 +26,8 @@ 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::OPENAI_LIBRARY_CONNECTOR_ID;
|
||||
use crate::mcp_openai_file::postprocess_mcp_tool_result_for_openai_files;
|
||||
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;
|
||||
@@ -36,6 +38,7 @@ use crate::tools::sandboxing::PermissionRequestPayload;
|
||||
use codex_analytics::AppInvocation;
|
||||
use codex_analytics::InvocationType;
|
||||
use codex_analytics::build_track_events_context;
|
||||
use codex_api::OpenAiFileUploadOptions;
|
||||
use codex_config::types::AppToolApproval;
|
||||
use codex_features::Feature;
|
||||
use codex_hooks::PermissionRequestDecision;
|
||||
@@ -317,6 +320,7 @@ async fn handle_approved_mcp_tool_call(
|
||||
turn_context,
|
||||
arguments_value.clone(),
|
||||
metadata.and_then(|metadata| metadata.openai_file_input_params.as_deref()),
|
||||
metadata.and_then(|metadata| metadata.openai_file_upload_options.as_ref()),
|
||||
)
|
||||
.await;
|
||||
let tool_input = match &rewrite {
|
||||
@@ -335,9 +339,18 @@ async fn handle_approved_mcp_tool_call(
|
||||
rewritten_arguments,
|
||||
request_meta,
|
||||
)
|
||||
.await?;
|
||||
let result = postprocess_mcp_tool_result_for_openai_files(
|
||||
sess,
|
||||
turn_context,
|
||||
&server,
|
||||
metadata.and_then(|metadata| metadata.connector_id.as_deref()),
|
||||
metadata.and_then(|metadata| metadata.codex_apps_meta.as_ref()),
|
||||
result,
|
||||
)
|
||||
.await;
|
||||
record_mcp_result_span_telemetry(&Span::current(), result.as_ref().ok());
|
||||
result
|
||||
record_mcp_result_span_telemetry(&Span::current(), Some(&result));
|
||||
Ok(result)
|
||||
}
|
||||
.instrument(mcp_tool_call_span(
|
||||
sess,
|
||||
@@ -715,12 +728,49 @@ pub(crate) struct McpToolApprovalMetadata {
|
||||
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_upload_options: Option<OpenAiFileUploadOptions>,
|
||||
}
|
||||
|
||||
const MCP_TOOL_CODEX_APPS_META_KEY: &str = "_codex_apps";
|
||||
const CODEX_APPS_META_KEY: &str = "_codex_apps";
|
||||
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_THREAD_ID_META_KEY: &str = "threadId";
|
||||
const MCP_TOOL_OPENAI_FILE_UPLOAD_CONFIG_KEY: &str = "openai/fileUploadConfig";
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
struct RawOpenAiFileUploadConfig {
|
||||
#[serde(default)]
|
||||
store_in_library: bool,
|
||||
}
|
||||
|
||||
fn parse_openai_file_upload_options(
|
||||
meta: Option<&serde_json::Map<String, serde_json::Value>>,
|
||||
) -> Option<OpenAiFileUploadOptions> {
|
||||
let raw = meta?
|
||||
.get(MCP_TOOL_OPENAI_FILE_UPLOAD_CONFIG_KEY)
|
||||
.cloned()
|
||||
.and_then(|value| serde_json::from_value::<RawOpenAiFileUploadConfig>(value).ok())?;
|
||||
|
||||
if !raw.store_in_library {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(OpenAiFileUploadOptions {
|
||||
store_in_library: true,
|
||||
})
|
||||
}
|
||||
|
||||
fn openai_file_upload_options_for_tool(
|
||||
server: &str,
|
||||
connector_id: Option<&str>,
|
||||
meta: Option<&serde_json::Map<String, serde_json::Value>>,
|
||||
) -> Option<OpenAiFileUploadOptions> {
|
||||
if server != CODEX_APPS_MCP_SERVER_NAME || connector_id != Some(OPENAI_LIBRARY_CONNECTOR_ID) {
|
||||
return None;
|
||||
}
|
||||
|
||||
parse_openai_file_upload_options(meta)
|
||||
}
|
||||
|
||||
fn custom_mcp_tool_approval_mode(
|
||||
turn_context: &TurnContext,
|
||||
@@ -772,7 +822,7 @@ fn build_mcp_tool_call_request_meta(
|
||||
serde_json::Value::String(call_id.to_string()),
|
||||
);
|
||||
request_meta.insert(
|
||||
MCP_TOOL_CODEX_APPS_META_KEY.to_string(),
|
||||
CODEX_APPS_META_KEY.to_string(),
|
||||
serde_json::Value::Object(codex_apps_meta),
|
||||
);
|
||||
}
|
||||
@@ -1231,6 +1281,11 @@ pub(crate) async fn lookup_mcp_tool_metadata(
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let openai_file_upload_options = openai_file_upload_options_for_tool(
|
||||
server,
|
||||
tool_info.connector_id.as_deref(),
|
||||
tool_info.tool.meta.as_deref(),
|
||||
);
|
||||
|
||||
Some(McpToolApprovalMetadata {
|
||||
annotations: tool_info.tool.annotations,
|
||||
@@ -1244,14 +1299,16 @@ pub(crate) async fn lookup_mcp_tool_metadata(
|
||||
.tool
|
||||
.meta
|
||||
.as_ref()
|
||||
.and_then(|meta| meta.get(MCP_TOOL_CODEX_APPS_META_KEY))
|
||||
.and_then(|meta| meta.get(CODEX_APPS_META_KEY))
|
||||
.and_then(serde_json::Value::as_object)
|
||||
.cloned(),
|
||||
// Disallow custom MCPs from uploading files via fileParams.
|
||||
openai_file_input_params: openai_file_input_params_for_server(
|
||||
server,
|
||||
tool_info.tool.meta.as_deref(),
|
||||
),
|
||||
)
|
||||
.filter(|params| !params.is_empty()),
|
||||
openai_file_upload_options,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -66,6 +66,7 @@ fn approval_metadata(
|
||||
mcp_app_resource_uri: None,
|
||||
codex_apps_meta: None,
|
||||
openai_file_input_params: None,
|
||||
openai_file_upload_options: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -893,6 +894,7 @@ async fn codex_apps_tool_call_request_meta_includes_turn_metadata_and_codex_apps
|
||||
.expect("_codex_apps metadata should be an object"),
|
||||
),
|
||||
openai_file_input_params: None,
|
||||
openai_file_upload_options: None,
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
@@ -904,7 +906,7 @@ async fn codex_apps_tool_call_request_meta_includes_turn_metadata_and_codex_apps
|
||||
),
|
||||
Some(serde_json::json!({
|
||||
crate::X_CODEX_TURN_METADATA_HEADER: expected_turn_metadata,
|
||||
MCP_TOOL_CODEX_APPS_META_KEY: {
|
||||
CODEX_APPS_META_KEY: {
|
||||
"call_id": "call_abc123xyz789",
|
||||
"resource_uri": "connector://calendar/tools/calendar_create_event",
|
||||
"contains_mcp_source": true,
|
||||
@@ -934,7 +936,7 @@ async fn codex_apps_tool_call_request_meta_includes_call_id_without_existing_cod
|
||||
),
|
||||
Some(serde_json::json!({
|
||||
crate::X_CODEX_TURN_METADATA_HEADER: expected_turn_metadata,
|
||||
MCP_TOOL_CODEX_APPS_META_KEY: {
|
||||
CODEX_APPS_META_KEY: {
|
||||
"call_id": "call_abc123xyz789",
|
||||
},
|
||||
}))
|
||||
@@ -970,6 +972,52 @@ fn mcp_tool_call_thread_id_meta_is_added_to_request_meta() {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn openai_file_upload_options_are_enabled_for_library_connector() {
|
||||
let meta = serde_json::json!({
|
||||
MCP_TOOL_OPENAI_FILE_UPLOAD_CONFIG_KEY: {
|
||||
"store_in_library": true,
|
||||
},
|
||||
});
|
||||
|
||||
assert_eq!(
|
||||
openai_file_upload_options_for_tool(
|
||||
CODEX_APPS_MCP_SERVER_NAME,
|
||||
Some(OPENAI_LIBRARY_CONNECTOR_ID),
|
||||
meta.as_object(),
|
||||
),
|
||||
Some(OpenAiFileUploadOptions {
|
||||
store_in_library: true,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn openai_file_upload_options_ignore_untrusted_connectors() {
|
||||
let meta = serde_json::json!({
|
||||
MCP_TOOL_OPENAI_FILE_UPLOAD_CONFIG_KEY: {
|
||||
"store_in_library": true,
|
||||
},
|
||||
});
|
||||
|
||||
assert_eq!(
|
||||
openai_file_upload_options_for_tool(
|
||||
CODEX_APPS_MCP_SERVER_NAME,
|
||||
Some("connector_third_party_drive"),
|
||||
meta.as_object(),
|
||||
),
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
openai_file_upload_options_for_tool(
|
||||
"docs",
|
||||
Some(OPENAI_LIBRARY_CONNECTOR_ID),
|
||||
meta.as_object(),
|
||||
),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accepted_elicitation_content_converts_to_request_user_input_response() {
|
||||
let response = request_user_input_response_from_elicitation_content(Some(serde_json::json!(
|
||||
@@ -1100,6 +1148,7 @@ fn guardian_mcp_review_request_includes_annotations_when_present() {
|
||||
mcp_app_resource_uri: None,
|
||||
codex_apps_meta: None,
|
||||
openai_file_input_params: None,
|
||||
openai_file_upload_options: None,
|
||||
};
|
||||
|
||||
let request = build_guardian_mcp_tool_review_request("call-1", &invocation, Some(&metadata));
|
||||
@@ -1664,6 +1713,7 @@ async fn approve_mode_skips_when_annotations_do_not_require_approval() {
|
||||
mcp_app_resource_uri: None,
|
||||
codex_apps_meta: None,
|
||||
openai_file_input_params: None,
|
||||
openai_file_upload_options: None,
|
||||
};
|
||||
|
||||
let decision = maybe_request_mcp_tool_approval(
|
||||
@@ -1737,6 +1787,7 @@ async fn guardian_mode_skips_auto_when_annotations_do_not_require_approval() {
|
||||
mcp_app_resource_uri: None,
|
||||
codex_apps_meta: None,
|
||||
openai_file_input_params: None,
|
||||
openai_file_upload_options: None,
|
||||
};
|
||||
|
||||
let decision = maybe_request_mcp_tool_approval(
|
||||
@@ -1793,6 +1844,7 @@ async fn permission_request_hook_allows_mcp_tool_call() {
|
||||
mcp_app_resource_uri: None,
|
||||
codex_apps_meta: None,
|
||||
openai_file_input_params: None,
|
||||
openai_file_upload_options: None,
|
||||
};
|
||||
|
||||
let decision = maybe_request_mcp_tool_approval(
|
||||
@@ -1924,6 +1976,7 @@ async fn permission_request_hook_runs_after_remembered_mcp_approval() {
|
||||
mcp_app_resource_uri: None,
|
||||
codex_apps_meta: None,
|
||||
openai_file_input_params: None,
|
||||
openai_file_upload_options: None,
|
||||
};
|
||||
let remembered_key =
|
||||
session_mcp_tool_approval_key(&invocation, Some(&metadata), AppToolApproval::Auto)
|
||||
@@ -2010,6 +2063,7 @@ async fn guardian_mode_mcp_denial_returns_rationale_message() {
|
||||
mcp_app_resource_uri: None,
|
||||
codex_apps_meta: None,
|
||||
openai_file_input_params: None,
|
||||
openai_file_upload_options: None,
|
||||
};
|
||||
|
||||
let decision = maybe_request_mcp_tool_approval(
|
||||
@@ -2063,6 +2117,7 @@ async fn prompt_mode_waits_for_approval_when_annotations_do_not_require_approval
|
||||
mcp_app_resource_uri: None,
|
||||
codex_apps_meta: None,
|
||||
openai_file_input_params: None,
|
||||
openai_file_upload_options: None,
|
||||
};
|
||||
|
||||
let mut approval_task = {
|
||||
@@ -2142,6 +2197,7 @@ async fn approve_mode_blocks_when_arc_returns_interrupt_for_model() {
|
||||
mcp_app_resource_uri: None,
|
||||
codex_apps_meta: None,
|
||||
openai_file_input_params: None,
|
||||
openai_file_upload_options: None,
|
||||
};
|
||||
|
||||
let decision = maybe_request_mcp_tool_approval(
|
||||
@@ -2214,6 +2270,7 @@ async fn custom_approve_mode_blocks_when_arc_returns_interrupt_for_model() {
|
||||
mcp_app_resource_uri: None,
|
||||
codex_apps_meta: None,
|
||||
openai_file_input_params: None,
|
||||
openai_file_upload_options: None,
|
||||
};
|
||||
|
||||
let decision = maybe_request_mcp_tool_approval(
|
||||
@@ -2286,6 +2343,7 @@ async fn approve_mode_blocks_when_arc_returns_interrupt_without_annotations() {
|
||||
mcp_app_resource_uri: None,
|
||||
codex_apps_meta: None,
|
||||
openai_file_input_params: None,
|
||||
openai_file_upload_options: None,
|
||||
};
|
||||
|
||||
let decision = maybe_request_mcp_tool_approval(
|
||||
@@ -2363,6 +2421,7 @@ async fn full_access_mode_skips_arc_monitor_for_all_approval_modes() {
|
||||
mcp_app_resource_uri: None,
|
||||
codex_apps_meta: None,
|
||||
openai_file_input_params: None,
|
||||
openai_file_upload_options: None,
|
||||
};
|
||||
|
||||
for approval_mode in [
|
||||
@@ -2470,6 +2529,7 @@ async fn approve_mode_routes_arc_ask_user_to_guardian_when_guardian_reviewer_is_
|
||||
mcp_app_resource_uri: None,
|
||||
codex_apps_meta: None,
|
||||
openai_file_input_params: None,
|
||||
openai_file_upload_options: None,
|
||||
};
|
||||
|
||||
let decision = maybe_request_mcp_tool_approval(
|
||||
|
||||
@@ -11,6 +11,7 @@ const DISALLOWED_CONNECTOR_IDS: &[&str] = &[
|
||||
];
|
||||
const FIRST_PARTY_CHAT_DISALLOWED_CONNECTOR_IDS: &[&str] =
|
||||
&["connector_0f9c9d4592e54d0a9a12b3f44a1e2010"];
|
||||
const ALLOWED_OPENAI_CONNECTOR_IDS: &[&str] = &["connector_openai_library"];
|
||||
const DISALLOWED_CONNECTOR_PREFIX: &str = "connector_openai_";
|
||||
|
||||
pub fn is_connector_id_allowed(connector_id: &str) -> bool {
|
||||
@@ -24,6 +25,10 @@ fn is_connector_id_allowed_for_originator(connector_id: &str, originator_value:
|
||||
DISALLOWED_CONNECTOR_IDS
|
||||
};
|
||||
|
||||
if ALLOWED_OPENAI_CONNECTOR_IDS.contains(&connector_id) {
|
||||
return true;
|
||||
}
|
||||
|
||||
!connector_id.starts_with(DISALLOWED_CONNECTOR_PREFIX)
|
||||
&& !disallowed_connector_ids.contains(&connector_id)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user