[codex] Route MCP file uploads through environment filesystem (#27923)

## Why

Codex Apps tools can mark arguments with `openai/fileParams`, but the
execution path resolved and opened those files directly on the host.
That bypassed the selected turn environment and prevented annotated file
arguments from working with remote environments.

## What changed

- resolve annotated file arguments against the primary turn environment
- read file metadata and contents through that environment's sandboxed
`ExecutorFileSystem`
- reject files over the 512 MiB limit from metadata before reading or
transferring them
- retain the buffered upload-size check as defense in depth
- make the OpenAI upload API accept a filename and buffered contents
instead of owning local filesystem access
- describe the model-visible argument as a path in the primary
environment

This builds on #27927, which added `size` to internal filesystem
metadata.

## Testing

- `just test -p codex-api upload_openai_file_returns_canonical_uri`
- `just test -p codex-mcp
tool_with_model_visible_input_schema_masks_file_params`
- `just test -p codex-core mcp_openai_file`
- `just test -p codex-core
codex_apps_file_params_upload_environment_files_before_mcp_tool_call`
This commit is contained in:
pakrym-oai
2026-06-16 11:27:46 -07:00
committed by GitHub
Unverified
parent 009a2bb93d
commit 7baf7e467e
6 changed files with 218 additions and 151 deletions
-1
View File
@@ -1946,7 +1946,6 @@ dependencies = [
"schemars 0.8.22",
"serde",
"serde_json",
"tempfile",
"thiserror 2.0.18",
"tokio",
"tokio-test",
-1
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 }
+28 -64
View File
@@ -1,15 +1,13 @@
use std::path::Path;
use std::path::PathBuf;
use std::time::Duration;
use crate::AuthProvider;
use bytes::Bytes;
use codex_client::build_reqwest_client_with_custom_ca;
use futures::Stream;
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;
@@ -27,26 +25,15 @@ pub struct UploadedOpenAiFile {
pub file_name: String,
pub file_size_bytes: u64,
pub mime_type: Option<String>,
pub path: PathBuf,
}
#[derive(Debug, thiserror::Error)]
pub enum OpenAiFileError {
#[error("path `{path}` does not exist")]
MissingPath { path: PathBuf },
#[error("path `{path}` is not a file")]
NotAFile { path: PathBuf },
#[error("path `{path}` cannot be read: {source}")]
ReadFile {
path: PathBuf,
#[source]
source: std::io::Error,
},
#[error(
"file `{path}` is too large: {size_bytes} bytes exceeds the limit of {limit_bytes} bytes"
"file `{file_name}` is too large: {size_bytes} bytes exceeds the limit of {limit_bytes} bytes"
)]
FileTooLarge {
path: PathBuf,
file_name: String,
size_bytes: u64,
limit_bytes: u64,
},
@@ -94,45 +81,26 @@ 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_openai_file(
base_url: &str,
auth: &dyn AuthProvider,
path: &Path,
file_name: String,
file_size_bytes: u64,
contents: impl Stream<Item = std::io::Result<Bytes>> + Send + 'static,
) -> 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 {
if file_size_bytes > OPENAI_FILE_UPLOAD_LIMIT_BYTES {
return Err(OpenAiFileError::FileTooLarge {
path: path.to_path_buf(),
size_bytes: metadata.len(),
file_name,
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_name": file_name.as_str(),
"file_size": file_size_bytes,
"use_case": OPENAI_FILE_USE_CASE,
}))
.send()
@@ -156,18 +124,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(reqwest::Body::wrap_stream(contents))
.send()
.await
.map_err(|source| OpenAiFileError::Request {
@@ -226,9 +188,8 @@ 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(),
});
}
"retry" => {
@@ -281,7 +242,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 +273,7 @@ mod tests {
}
#[tokio::test]
async fn upload_local_file_returns_canonical_uri() {
async fn upload_openai_file_returns_canonical_uri() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/backend-api/files"))
@@ -359,13 +319,17 @@ 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 contents =
futures::stream::iter([Ok::<_, std::io::Error>(Bytes::from_static(b"hello"))]);
let uploaded = upload_openai_file(
&base_url,
&chatgpt_auth(),
"hello.txt".to_string(),
/*file_size_bytes*/ 5,
contents,
)
.await
.expect("upload succeeds");
assert_eq!(uploaded.file_id, "file_123");
assert_eq!(uploaded.uri, "sediment://file_123");
+2 -1
View File
@@ -65,7 +65,8 @@ pub use crate::endpoint::ResponsesWebsocketProbe;
pub use crate::endpoint::SearchClient;
pub use crate::endpoint::session_update_session_json;
pub use crate::error::ApiError;
pub use crate::files::upload_local_file;
pub use crate::files::OPENAI_FILE_UPLOAD_LIMIT_BYTES;
pub use crate::files::upload_openai_file;
pub use crate::images::ImageBackground;
pub use crate::images::ImageData;
pub use crate::images::ImageEditRequest;
+113 -44
View File
@@ -3,7 +3,8 @@
//! 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
//! - At tool execution time, read those files from the primary environment,
//! upload them to OpenAI file storage,
//! and rewrite only the declared arguments into the provided-file payload
//! shape expected by the downstream Apps tool.
//!
@@ -12,8 +13,10 @@
use crate::session::session::Session;
use crate::session::turn_context::TurnContext;
use codex_api::upload_local_file;
use codex_api::OPENAI_FILE_UPLOAD_LIMIT_BYTES;
use codex_api::upload_openai_file;
use codex_login::CodexAuth;
use codex_utils_path_uri::PathUri;
use serde_json::Value as JsonValue;
pub(crate) async fn rewrite_mcp_tool_arguments_for_openai_files(
@@ -62,13 +65,13 @@ async fn rewrite_argument_value_for_openai_files(
value: &JsonValue,
) -> Result<Option<JsonValue>, String> {
match value {
JsonValue::String(path_or_file_ref) => {
let rewritten = build_uploaded_local_argument_value(
JsonValue::String(file_path) => {
let rewritten = build_uploaded_argument_value(
turn_context,
auth,
field_name,
/*index*/ None,
path_or_file_ref,
file_path,
)
.await?;
Ok(Some(rewritten))
@@ -76,15 +79,15 @@ async fn rewrite_argument_value_for_openai_files(
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 {
let Some(file_path) = item.as_str() else {
return Ok(None);
};
let rewritten = build_uploaded_local_argument_value(
let rewritten = build_uploaded_argument_value(
turn_context,
auth,
field_name,
Some(index),
path_or_file_ref,
file_path,
)
.await?;
rewritten_values.push(rewritten);
@@ -95,38 +98,70 @@ async fn rewrite_argument_value_for_openai_files(
}
}
async fn build_uploaded_local_argument_value(
async fn build_uploaded_argument_value(
turn_context: &TurnContext,
auth: Option<&CodexAuth>,
field_name: &str,
index: Option<usize>,
file_path: &str,
) -> Result<JsonValue, String> {
#[allow(deprecated)]
let resolved_path = turn_context.resolve_path(Some(file_path.to_string()));
let Some(auth) = auth else {
return Err(
"ChatGPT auth is required to upload local files for Codex Apps tools".to_string(),
);
};
if !auth.uses_codex_backend() {
return Err(
"ChatGPT auth is required to upload local files for Codex Apps tools".to_string(),
);
}
let upload_auth = codex_model_provider::auth_provider_from_auth(auth);
let uploaded = upload_local_file(
turn_context.config.chatgpt_base_url.trim_end_matches('/'),
upload_auth.as_ref(),
&resolved_path,
)
.await
.map_err(|error| match index {
let contextualize_error = |error: String| 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}"),
})?;
};
let Some(auth) = auth else {
return Err("ChatGPT auth is required to upload files for Codex Apps tools".to_string());
};
if !auth.uses_codex_backend() {
return Err("ChatGPT auth is required to upload files for Codex Apps tools".to_string());
}
let Some(turn_environment) = turn_context.environments.primary() else {
return Err(contextualize_error(
"no primary turn environment is available".to_string(),
));
};
let resolved_path = turn_environment.cwd().join(file_path);
let path_uri = PathUri::from_abs_path(&resolved_path);
let fs = turn_environment.environment.get_filesystem();
let metadata = fs
.get_metadata(&path_uri, /*sandbox*/ None)
.await
.map_err(|error| contextualize_error(error.to_string()))?;
if !metadata.is_file {
return Err(contextualize_error(format!(
"path `{}` is not a file",
resolved_path.display()
)));
}
if metadata.size > OPENAI_FILE_UPLOAD_LIMIT_BYTES {
return Err(contextualize_error(format!(
"file `{}` is too large: {} bytes exceeds the limit of {} bytes",
resolved_path.display(),
metadata.size,
OPENAI_FILE_UPLOAD_LIMIT_BYTES,
)));
}
let contents = fs
.read_file_stream(&path_uri, /*sandbox*/ None)
.await
.map_err(|error| contextualize_error(error.to_string()))?;
let file_name = resolved_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_openai_file(
turn_context.config.chatgpt_base_url.trim_end_matches('/'),
upload_auth.as_ref(),
file_name,
metadata.size,
contents,
)
.await
.map_err(|error| contextualize_error(error.to_string()))?;
Ok(serde_json::json!({
"download_url": uploaded.download_url,
"file_id": uploaded.file_id,
@@ -141,11 +176,29 @@ async fn build_uploaded_local_argument_value(
mod tests {
use super::*;
use crate::session::tests::make_session_and_context;
use crate::session::turn_context::TurnEnvironment;
use codex_utils_absolute_path::AbsolutePathBuf;
use pretty_assertions::assert_eq;
use std::path::Path;
use std::sync::Arc;
use tempfile::tempdir;
fn set_primary_environment_cwd(turn_context: &mut TurnContext, cwd: &Path) {
let cwd = AbsolutePathBuf::try_from(cwd).expect("absolute path");
turn_context.permission_profile = codex_protocol::models::PermissionProfile::Disabled;
let primary = turn_context
.environments
.turn_environments
.first_mut()
.expect("primary environment");
*primary = TurnEnvironment::new(
primary.environment_id.clone(),
Arc::clone(&primary.environment),
cwd,
primary.shell.clone(),
);
}
#[tokio::test]
async fn openai_file_argument_rewrite_requires_declared_file_params() {
let (session, turn_context) = make_session_and_context().await;
@@ -166,7 +219,7 @@ mod tests {
}
#[tokio::test]
async fn build_uploaded_local_argument_value_uploads_local_file_path() {
async fn build_uploaded_argument_value_uploads_environment_file() {
use wiremock::Mock;
use wiremock::MockServer;
use wiremock::ResponseTemplate;
@@ -217,16 +270,13 @@ mod tests {
tokio::fs::write(&local_path, b"hello")
.await
.expect("write local file");
#[allow(deprecated)]
{
turn_context.cwd = AbsolutePathBuf::try_from(dir.path()).expect("absolute path");
}
set_primary_environment_cwd(&mut turn_context, dir.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(
let rewritten = build_uploaded_argument_value(
&turn_context,
Some(&auth),
"file",
@@ -249,6 +299,31 @@ mod tests {
);
}
#[tokio::test]
async fn build_uploaded_argument_value_rejects_oversized_file_before_reading() {
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 file_path = dir.path().join("oversized.bin");
let file = std::fs::File::create(&file_path).expect("create sparse file");
file.set_len(OPENAI_FILE_UPLOAD_LIMIT_BYTES + 1)
.expect("size sparse file");
set_primary_environment_cwd(&mut turn_context, dir.path());
let error = build_uploaded_argument_value(
&turn_context,
Some(&auth),
"file",
/*index*/ None,
"oversized.bin",
)
.await
.expect_err("oversized file should be rejected");
assert!(error.contains("is too large"));
assert!(error.contains(&(OPENAI_FILE_UPLOAD_LIMIT_BYTES + 1).to_string()));
}
#[tokio::test]
async fn rewrite_argument_value_for_openai_files_rewrites_scalar_path() {
use wiremock::Mock;
@@ -301,10 +376,7 @@ mod tests {
tokio::fs::write(&local_path, b"hello")
.await
.expect("write local file");
#[allow(deprecated)]
{
turn_context.cwd = AbsolutePathBuf::try_from(dir.path()).expect("absolute path");
}
set_primary_environment_cwd(&mut turn_context, dir.path());
let mut config = (*turn_context.config).clone();
config.chatgpt_base_url = format!("{}/backend-api", server.uri());
@@ -418,10 +490,7 @@ mod tests {
tokio::fs::write(dir.path().join("two.csv"), b"two")
.await
.expect("write second local file");
#[allow(deprecated)]
{
turn_context.cwd = AbsolutePathBuf::try_from(dir.path()).expect("absolute path");
}
set_primary_environment_cwd(&mut turn_context, dir.path());
let mut config = (*turn_context.config).clone();
config.chatgpt_base_url = format!("{}/backend-api", server.uri());
+75 -40
View File
@@ -7,6 +7,7 @@ use anyhow::Context;
use anyhow::Result;
use codex_protocol::models::PermissionProfile;
use codex_protocol::protocol::AskForApproval;
use codex_utils_path_uri::PathUri;
use core_test_support::apps_test_server::AppsTestServer;
use core_test_support::apps_test_server::CALENDAR_EXTRACT_TEXT_TOOL_NAME;
use core_test_support::apps_test_server::DIRECT_CALENDAR_EXTRACT_TEXT_TOOL as DOCUMENT_EXTRACT_HOOK_MATCHER;
@@ -16,6 +17,7 @@ use core_test_support::apps_test_server::SEARCH_CALENDAR_NAMESPACE as DOCUMENT_E
use core_test_support::apps_test_server::apps_enabled_builder;
use core_test_support::apps_test_server::recorded_apps_tool_call_by_name;
use core_test_support::hooks::trust_discovered_hooks;
use core_test_support::responses::ResponseMock;
use core_test_support::responses::ev_assistant_message;
use core_test_support::responses::ev_completed;
use core_test_support::responses::ev_function_call_with_namespace;
@@ -23,16 +25,20 @@ use core_test_support::responses::ev_response_created;
use core_test_support::responses::mount_sse_sequence;
use core_test_support::responses::sse;
use core_test_support::responses::start_mock_server;
use core_test_support::test_codex::TestCodex;
use pretty_assertions::assert_eq;
use serde_json::Value;
use serde_json::json;
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;
const STREAMED_FILE_SIZE: usize = 13 * 1024 * 1024;
fn write_post_tool_use_hook(home: &Path) -> Result<()> {
let script_path = home.join("post_tool_use_hook.py");
let log_path = home.join("post_tool_use_hook_log.jsonl");
@@ -82,17 +88,24 @@ fn read_post_tool_use_hook_inputs(home: &Path) -> Result<Vec<Value>> {
.collect()
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn codex_apps_file_params_upload_local_paths_before_mcp_tool_call() -> Result<()> {
let server = start_mock_server().await;
let apps_server = AppsTestServer::mount(&server).await?;
fn uploaded_file(server: &MockServer, file_size_bytes: u64) -> Value {
json!({
"download_url": format!("{}/download/file_123", server.uri()),
"file_id": "file_123",
"mime_type": "text/plain",
"file_name": "report.txt",
"uri": "sediment://file_123",
"file_size_bytes": file_size_bytes,
})
}
async fn mount_file_upload_mocks(server: &MockServer, file_size_bytes: u64) {
Mock::given(method("POST"))
.and(path("/files"))
.and(header("chatgpt-account-id", "account_id"))
.and(body_json(json!({
"file_name": "report.txt",
"file_size": 11,
"file_size": file_size_bytes,
"use_case": "codex",
})))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
@@ -100,14 +113,14 @@ async fn codex_apps_file_params_upload_local_paths_before_mcp_tool_call() -> Res
"upload_url": format!("{}/upload/file_123", server.uri()),
})))
.expect(1)
.mount(&server)
.mount(server)
.await;
Mock::given(method("PUT"))
.and(path("/upload/file_123"))
.and(header("content-length", "11"))
.and(header("content-length", file_size_bytes.to_string()))
.respond_with(ResponseTemplate::new(200))
.expect(1)
.mount(&server)
.mount(server)
.await;
Mock::given(method("POST"))
.and(path("/files/file_123/uploaded"))
@@ -116,20 +129,21 @@ async fn codex_apps_file_params_upload_local_paths_before_mcp_tool_call() -> Res
"download_url": format!("{}/download/file_123", server.uri()),
"file_name": "report.txt",
"mime_type": "text/plain",
"file_size_bytes": 11,
"file_size_bytes": file_size_bytes,
})))
.expect(1)
.mount(&server)
.mount(server)
.await;
}
let call_id = "extract-call-1";
async fn run_extract_turn(test: &TestCodex, server: &MockServer) -> Result<ResponseMock> {
let mock = mount_sse_sequence(
&server,
server,
vec![
sse(vec![
ev_response_created("resp-1"),
ev_function_call_with_namespace(
call_id,
"extract-call-1",
DOCUMENT_EXTRACT_NAMESPACE,
DOCUMENT_EXTRACT_TOOL,
&json!({"file": "report.txt"}).to_string(),
@@ -145,17 +159,6 @@ async fn codex_apps_file_params_upload_local_paths_before_mcp_tool_call() -> Res
)
.await;
let mut builder = apps_enabled_builder(apps_server.chatgpt_base_url.clone())
.with_pre_build_hook(move |home| {
write_post_tool_use_hook(home)
.expect("failed to write apps file post tool use hook fixture");
})
.with_config(move |config| {
trust_discovered_hooks(config);
});
let test = builder.build(&server).await?;
tokio::fs::write(test.cwd.path().join("report.txt"), b"hello world").await?;
test.submit_turn_with_approval_and_permission_profile(
"Extract the report text with the app tool.",
AskForApproval::Never,
@@ -163,6 +166,29 @@ async fn codex_apps_file_params_upload_local_paths_before_mcp_tool_call() -> Res
)
.await?;
Ok(mock)
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn codex_apps_file_params_upload_environment_files_before_mcp_tool_call() -> Result<()> {
let server = start_mock_server().await;
let apps_server = AppsTestServer::mount(&server).await?;
mount_file_upload_mocks(&server, STREAMED_FILE_SIZE as u64).await;
let mut builder = apps_enabled_builder(apps_server.chatgpt_base_url.clone())
.with_workspace_setup(|cwd, fs| async move {
let report_path = PathUri::from_abs_path(&cwd.join("report.txt"));
fs.write_file(
&report_path,
vec![b'x'; STREAMED_FILE_SIZE],
/*sandbox*/ None,
)
.await?;
Ok(())
});
let test = builder.build_with_remote_env(&server).await?;
let mock = run_extract_turn(&test, &server).await?;
let requests = mock.requests();
let body = requests[0].body_json();
let missing_tool_message = format!(
@@ -184,14 +210,7 @@ async fn codex_apps_file_params_upload_local_paths_before_mcp_tool_call() -> Res
assert_eq!(
apps_tool_call.pointer("/params/arguments/file"),
Some(&json!({
"download_url": format!("{}/download/file_123", server.uri()),
"file_id": "file_123",
"mime_type": "text/plain",
"file_name": "report.txt",
"uri": "sediment://file_123",
"file_size_bytes": 11,
}))
Some(&uploaded_file(&server, STREAMED_FILE_SIZE as u64))
);
assert_eq!(
apps_tool_call.pointer("/params/_meta/_codex_apps"),
@@ -203,18 +222,34 @@ async fn codex_apps_file_params_upload_local_paths_before_mcp_tool_call() -> Res
}))
);
server.verify().await;
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn codex_apps_file_params_pass_uploaded_file_to_post_tool_use_hook() -> Result<()> {
let server = start_mock_server().await;
let apps_server = AppsTestServer::mount(&server).await?;
mount_file_upload_mocks(&server, /*file_size_bytes*/ 11).await;
let mut builder = apps_enabled_builder(apps_server.chatgpt_base_url.clone())
.with_pre_build_hook(move |home| {
if let Err(error) = write_post_tool_use_hook(home) {
panic!("failed to write apps file post tool use hook fixture: {error}");
}
})
.with_config(move |config| {
trust_discovered_hooks(config);
});
let test = builder.build(&server).await?;
tokio::fs::write(test.cwd.path().join("report.txt"), b"hello world").await?;
let _responses = run_extract_turn(&test, &server).await?;
let hook_inputs = read_post_tool_use_hook_inputs(test.codex_home_path())?;
assert_eq!(hook_inputs.len(), 1);
assert_eq!(
hook_inputs[0]["tool_input"]["file"],
json!({
"download_url": format!("{}/download/file_123", server.uri()),
"file_id": "file_123",
"mime_type": "text/plain",
"file_name": "report.txt",
"uri": "sediment://file_123",
"file_size_bytes": 11,
})
uploaded_file(&server, /*file_size_bytes*/ 11)
);
server.verify().await;