Compare commits

...

9 Commits

Author SHA1 Message Date
colby-oai
99a8cd292e remove unused tempfile dep 2026-04-29 10:38:55 -04:00
colby-oai
444672628c remove now unused upload_local_file 2026-04-29 10:33:07 -04:00
colby-oai
7c7a417118 dont change UploadedOpenAiFile shape 2026-04-29 10:17:04 -04:00
colby-oai
2c7598fb66 move back to non-streaming approach 2026-04-29 09:30:48 -04:00
colby-oai
fa8bd083fe support symlinks 2026-04-28 17:25:01 -04:00
colby-oai
5055167b82 clean up redundant old check 2026-04-28 16:26:10 -04:00
colby-oai
a8ab08ff2a clean up redundant old check 2026-04-28 14:40:57 -04:00
colby-oai
1b65ab838f use fs abstraction 2026-04-28 14:21:56 -04:00
colby-oai
0d5a38b145 Respect filesystem read policy for fileParams uploads 2026-04-28 12:50:34 -04:00
5 changed files with 106 additions and 61 deletions

1
codex-rs/Cargo.lock generated
View File

@@ -1818,7 +1818,6 @@ dependencies = [
"reqwest",
"serde",
"serde_json",
"tempfile",
"thiserror 2.0.18",
"tokio",
"tokio-test",

View File

@@ -32,7 +32,6 @@ url = { workspace = true }
anyhow = { workspace = true }
assert_matches = { workspace = true }
pretty_assertions = { workspace = true }
tempfile = { workspace = true }
tokio-test = { workspace = true }
wiremock = { workspace = true }
reqwest = { workspace = true }

View File

@@ -1,4 +1,3 @@
use std::path::Path;
use std::path::PathBuf;
use std::time::Duration;
@@ -7,9 +6,7 @@ use codex_client::build_reqwest_client_with_custom_ca;
use reqwest::StatusCode;
use reqwest::header::CONTENT_LENGTH;
use serde::Deserialize;
use tokio::fs::File;
use tokio::time::Instant;
use tokio_util::io::ReaderStream;
pub const OPENAI_FILE_URI_PREFIX: &str = "sediment://";
pub const OPENAI_FILE_UPLOAD_LIMIT_BYTES: u64 = 512 * 1024 * 1024;
@@ -94,45 +91,46 @@ pub fn openai_file_uri(file_id: &str) -> String {
format!("{OPENAI_FILE_URI_PREFIX}{file_id}")
}
pub async fn upload_local_file(
pub async fn upload_file_bytes(
base_url: &str,
auth: &dyn AuthProvider,
path: &Path,
file_name: String,
contents: Vec<u8>,
) -> Result<UploadedOpenAiFile, OpenAiFileError> {
let metadata = tokio::fs::metadata(path)
.await
.map_err(|source| match source.kind() {
std::io::ErrorKind::NotFound => OpenAiFileError::MissingPath {
path: path.to_path_buf(),
},
_ => OpenAiFileError::ReadFile {
path: path.to_path_buf(),
source,
},
})?;
if !metadata.is_file() {
return Err(OpenAiFileError::NotAFile {
path: path.to_path_buf(),
});
}
if metadata.len() > OPENAI_FILE_UPLOAD_LIMIT_BYTES {
let file_size_bytes = contents.len() as u64;
let source_path = PathBuf::from(file_name.clone());
upload_file_body_with_source_path(
base_url,
auth,
file_name,
file_size_bytes,
reqwest::Body::from(contents),
source_path,
)
.await
}
async fn upload_file_body_with_source_path(
base_url: &str,
auth: &dyn AuthProvider,
file_name: String,
file_size_bytes: u64,
body: reqwest::Body,
source_path: PathBuf,
) -> Result<UploadedOpenAiFile, OpenAiFileError> {
if file_size_bytes > OPENAI_FILE_UPLOAD_LIMIT_BYTES {
return Err(OpenAiFileError::FileTooLarge {
path: path.to_path_buf(),
size_bytes: metadata.len(),
path: source_path.clone(),
size_bytes: file_size_bytes,
limit_bytes: OPENAI_FILE_UPLOAD_LIMIT_BYTES,
});
}
let file_name = path
.file_name()
.and_then(|value| value.to_str())
.unwrap_or("file")
.to_string();
let create_url = format!("{}/files", base_url.trim_end_matches('/'));
let create_response = authorized_request(auth, reqwest::Method::POST, &create_url)
.json(&serde_json::json!({
"file_name": file_name,
"file_size": metadata.len(),
"file_size": file_size_bytes,
"use_case": OPENAI_FILE_USE_CASE,
}))
.send()
@@ -156,18 +154,12 @@ pub async fn upload_local_file(
source,
})?;
let upload_file = File::open(path)
.await
.map_err(|source| OpenAiFileError::ReadFile {
path: path.to_path_buf(),
source,
})?;
let upload_response = build_reqwest_client()
.put(&create_payload.upload_url)
.timeout(OPENAI_FILE_REQUEST_TIMEOUT)
.header("x-ms-blob-type", "BlockBlob")
.header(CONTENT_LENGTH, metadata.len())
.body(reqwest::Body::wrap_stream(ReaderStream::new(upload_file)))
.header(CONTENT_LENGTH, file_size_bytes)
.body(body)
.send()
.await
.map_err(|source| OpenAiFileError::Request {
@@ -226,9 +218,9 @@ pub async fn upload_local_file(
}
})?,
file_name: finalize_payload.file_name.unwrap_or(file_name),
file_size_bytes: metadata.len(),
file_size_bytes,
mime_type: finalize_payload.mime_type,
path: path.to_path_buf(),
path: source_path,
});
}
"retry" => {
@@ -281,7 +273,6 @@ mod tests {
use std::sync::Arc;
use std::sync::atomic::AtomicUsize;
use std::sync::atomic::Ordering;
use tempfile::TempDir;
use wiremock::Mock;
use wiremock::MockServer;
use wiremock::Request;
@@ -313,7 +304,7 @@ mod tests {
}
#[tokio::test]
async fn upload_local_file_returns_canonical_uri() {
async fn upload_file_bytes_returns_canonical_uri() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/backend-api/files"))
@@ -359,13 +350,15 @@ mod tests {
.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)
.await
.expect("upload succeeds");
let uploaded = upload_file_bytes(
&base_url,
&chatgpt_auth(),
"hello.txt".to_string(),
b"hello".to_vec(),
)
.await
.expect("upload succeeds");
assert_eq!(uploaded.file_id, "file_123");
assert_eq!(uploaded.uri, "sediment://file_123");

View File

@@ -57,7 +57,7 @@ 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::upload_local_file;
pub use crate::files::upload_file_bytes;
pub use crate::provider::Provider;
pub use crate::provider::RetryConfig;
pub use crate::provider::is_azure_responses_provider;

View File

@@ -12,7 +12,7 @@
use crate::session::session::Session;
use crate::session::turn_context::TurnContext;
use codex_api::upload_local_file;
use codex_api::upload_file_bytes;
use codex_login::CodexAuth;
use serde_json::Value as JsonValue;
@@ -39,9 +39,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(
sess,
turn_context,
auth.as_ref(),
field_name,
value,
)
.await?
else {
continue;
};
@@ -56,6 +61,7 @@ pub(crate) async fn rewrite_mcp_tool_arguments_for_openai_files(
}
async fn rewrite_argument_value_for_openai_files(
sess: &Session,
turn_context: &TurnContext,
auth: Option<&CodexAuth>,
field_name: &str,
@@ -64,6 +70,7 @@ async fn rewrite_argument_value_for_openai_files(
match value {
JsonValue::String(path_or_file_ref) => {
let rewritten = build_uploaded_local_argument_value(
sess,
turn_context,
auth,
field_name,
@@ -80,6 +87,7 @@ async fn rewrite_argument_value_for_openai_files(
return Ok(None);
};
let rewritten = build_uploaded_local_argument_value(
sess,
turn_context,
auth,
field_name,
@@ -96,6 +104,7 @@ async fn rewrite_argument_value_for_openai_files(
}
async fn build_uploaded_local_argument_value(
sess: &Session,
turn_context: &TurnContext,
auth: Option<&CodexAuth>,
field_name: &str,
@@ -113,11 +122,38 @@ async fn build_uploaded_local_argument_value(
"ChatGPT auth is required to upload local files for Codex Apps tools".to_string(),
);
}
let sandbox = turn_context.file_system_sandbox_context(/*additional_permissions*/ None);
let file_system = turn_context
.environment
.as_ref()
.map(|environment| environment.get_filesystem())
.unwrap_or_else(|| {
sess.services
.environment_manager
.local_environment()
.get_filesystem()
});
let file_contents = file_system
.read_file(&resolved_path, Some(&sandbox))
.await
.map_err(|error| match index {
Some(index) => {
format!("failed to read `{file_path}` for `{field_name}[{index}]`: {error}")
}
None => format!("failed to read `{file_path}` for `{field_name}`: {error}"),
})?;
let file_name = resolved_path
.as_path()
.file_name()
.and_then(|value| value.to_str())
.unwrap_or("file")
.to_string();
let upload_auth = codex_model_provider::auth_provider_from_auth(auth);
let uploaded = upload_local_file(
let uploaded = upload_file_bytes(
turn_context.config.chatgpt_base_url.trim_end_matches('/'),
upload_auth.as_ref(),
&resolved_path,
file_name,
file_contents,
)
.await
.map_err(|error| match index {
@@ -140,6 +176,9 @@ async fn build_uploaded_local_argument_value(
mod tests {
use super::*;
use crate::session::tests::make_session_and_context;
use codex_protocol::models::PermissionProfile;
use codex_protocol::permissions::FileSystemSandboxPolicy;
use codex_protocol::permissions::NetworkSandboxPolicy;
use codex_utils_absolute_path::AbsolutePathBuf;
use pretty_assertions::assert_eq;
use std::sync::Arc;
@@ -209,7 +248,7 @@ mod tests {
.mount(&server)
.await;
let (_, mut turn_context) = make_session_and_context().await;
let (session, 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");
@@ -221,8 +260,13 @@ mod tests {
let mut config = (*turn_context.config).clone();
config.chatgpt_base_url = format!("{}/backend-api", server.uri());
turn_context.config = Arc::new(config);
turn_context.permission_profile = PermissionProfile::from_runtime_permissions(
&FileSystemSandboxPolicy::unrestricted(),
NetworkSandboxPolicy::Enabled,
);
let rewritten = build_uploaded_local_argument_value(
&session,
&turn_context,
Some(&auth),
"file",
@@ -290,7 +334,7 @@ mod tests {
.mount(&server)
.await;
let (_, mut turn_context) = make_session_and_context().await;
let (session, 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");
@@ -302,7 +346,12 @@ mod tests {
let mut config = (*turn_context.config).clone();
config.chatgpt_base_url = format!("{}/backend-api", server.uri());
turn_context.config = Arc::new(config);
turn_context.permission_profile = PermissionProfile::from_runtime_permissions(
&FileSystemSandboxPolicy::unrestricted(),
NetworkSandboxPolicy::Enabled,
);
let rewritten = rewrite_argument_value_for_openai_files(
&session,
&turn_context,
Some(&auth),
"file",
@@ -402,7 +451,7 @@ mod tests {
.mount(&server)
.await;
let (_, mut turn_context) = make_session_and_context().await;
let (session, 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")
@@ -416,7 +465,12 @@ mod tests {
let mut config = (*turn_context.config).clone();
config.chatgpt_base_url = format!("{}/backend-api", server.uri());
turn_context.config = Arc::new(config);
turn_context.permission_profile = PermissionProfile::from_runtime_permissions(
&FileSystemSandboxPolicy::unrestricted(),
NetworkSandboxPolicy::Enabled,
);
let rewritten = rewrite_argument_value_for_openai_files(
&session,
&turn_context,
Some(&auth),
"files",
@@ -465,7 +519,7 @@ mod tests {
.await
.expect_err("missing file should fail");
assert!(error.contains("failed to upload"));
assert!(error.contains("failed to read"));
assert!(error.contains("file"));
}
}