Compare commits

...

5 Commits

Author SHA1 Message Date
Liang-Ting Jiang
c03932e66a feat(core): add sep2356 file input encoder
Add a broker-side SEP-2356 data URI encoder for local and env://current file refs, with tests for RFC 2397 name encoding and environment escape rejection.

Co-authored-by: Codex <noreply@openai.com>
2026-05-12 08:22:04 +00:00
Liang-Ting Jiang
b204ac18ae feat(core): accept env file refs for app uploads
Allow openai/fileParams arguments to use env://current/<relative-path> references, resolving them through the file broker before uploading to OpenAI file storage.

Co-authored-by: Codex <noreply@openai.com>
2026-05-12 08:18:23 +00:00
Liang-Ting Jiang
f1815041e4 feat(code-mode): add io.write helper
Expose a host-mediated io.write primitive in Code Mode that writes serializable text or bytes to env://current paths via the core harness.

Co-authored-by: Codex <noreply@openai.com>
2026-05-12 08:14:29 +00:00
Liang-Ting Jiang
25c7e752f8 refactor(core): isolate openai file broker
Route openai/fileParams rewriting through an OpenAI file broker wrapper that keeps the current behavior while making file input resolution explicit for follow-up transports.

Co-authored-by: Codex <noreply@openai.com>
2026-05-12 08:10:10 +00:00
Liang-Ting Jiang
5b0a195e48 test(core): cover code mode app file uploads
Add integration coverage that exercises Code Mode's nested MCP tool path through openai/fileParams rewriting for Codex Apps file uploads.

Co-authored-by: Codex <noreply@openai.com>
2026-05-12 08:07:51 +00:00
11 changed files with 825 additions and 92 deletions

View File

@@ -30,6 +30,7 @@ const EXEC_DESCRIPTION_TEMPLATE: &str = r#"Run JavaScript code to orchestrate/co
- `store(key: string, value: any)`: stores a serializable value under a string key for later `exec` calls in the same session.
- `load(key: string)`: returns the stored value for a string key, or `undefined` if it is missing.
- `notify(value: string | number | boolean | undefined | null)`: immediately injects an extra `custom_tool_call_output` for the current `exec` call. Values are stringified like `text(...)`.
- `io.write(value: string | number[] | TextContent | BlobResourceContents, destination: "env://current/<relative-path>")`: writes text or bytes to the current environment filesystem and returns `{ uri, path, bytes_written }`.
- `setTimeout(callback: () => void, delayMs?: number)`: schedules a callback to run later and returns a timeout id. Pending timeouts do not keep `exec` alive by themselves; await an explicit promise if you need to wait for one.
- `clearTimeout(timeoutId?: number)`: cancels a timeout created by `setTimeout`.
- `ALL_TOOLS`: metadata for the enabled nested tools as `{ name, description }` entries.

View File

@@ -18,6 +18,7 @@ pub use description::render_json_schema_to_typescript;
pub use response::DEFAULT_IMAGE_DETAIL;
pub use response::FunctionCallOutputContentItem;
pub use response::ImageDetail;
pub use runtime::CodeModeIoWrite;
pub use runtime::CodeModeNestedToolCall;
pub use runtime::DEFAULT_EXEC_YIELD_TIME_MS;
pub use runtime::DEFAULT_MAX_OUTPUT_TOKENS_PER_EXEC_CALL;

View File

@@ -9,6 +9,7 @@ use super::value::normalize_output_image;
use super::value::serialize_output_text;
use super::value::throw_type_error;
use super::value::v8_value_to_json;
use serde_json::Value as JsonValue;
pub(super) fn tool_callback(
scope: &mut v8::PinScope<'_, '_>,
@@ -74,6 +75,59 @@ pub(super) fn tool_callback(
retval.set(promise.into());
}
pub(super) fn io_write_callback(
scope: &mut v8::PinScope<'_, '_>,
args: v8::FunctionCallbackArguments,
mut retval: v8::ReturnValue<v8::Value>,
) {
if args.length() < 2 {
throw_type_error(scope, "io.write expects a value and destination");
return;
}
let value = match v8_value_to_json(scope, args.get(0)) {
Ok(Some(value)) => value,
Ok(None) => JsonValue::Null,
Err(error_text) => {
throw_type_error(scope, &error_text);
return;
}
};
let destination = match args.get(1).to_string(scope) {
Some(destination) => destination.to_rust_string_lossy(scope),
None => {
throw_type_error(scope, "io.write destination must be a string");
return;
}
};
if destination.trim().is_empty() {
throw_type_error(scope, "io.write destination must be non-empty");
return;
}
let Some(resolver) = v8::PromiseResolver::new(scope) else {
throw_type_error(scope, "failed to create io.write promise");
return;
};
let promise = resolver.get_promise(scope);
let resolver = v8::Global::new(scope, resolver);
let Some(state) = scope.get_slot_mut::<RuntimeState>() else {
throw_type_error(scope, "runtime state unavailable");
return;
};
let id = format!("io-write-{}", state.next_tool_call_id);
state.next_tool_call_id = state.next_tool_call_id.saturating_add(1);
let event_tx = state.event_tx.clone();
state.pending_tool_calls.insert(id.clone(), resolver);
let _ = event_tx.send(RuntimeEvent::IoWrite {
id,
value,
destination,
});
retval.set(promise.into());
}
pub(super) fn text_callback(
scope: &mut v8::PinScope<'_, '_>,
args: v8::FunctionCallbackArguments,

View File

@@ -2,6 +2,7 @@ use super::RuntimeState;
use super::callbacks::clear_timeout_callback;
use super::callbacks::exit_callback;
use super::callbacks::image_callback;
use super::callbacks::io_write_callback;
use super::callbacks::load_callback;
use super::callbacks::notify_callback;
use super::callbacks::set_timeout_callback;
@@ -19,6 +20,7 @@ pub(super) fn install_globals(scope: &mut v8::PinScope<'_, '_>) -> Result<(), St
let tools = build_tools_object(scope)?;
let all_tools = build_all_tools_value(scope)?;
let io = build_io_object(scope)?;
let clear_timeout = helper_function(scope, "clearTimeout", clear_timeout_callback)?;
let set_timeout = helper_function(scope, "setTimeout", set_timeout_callback)?;
let text = helper_function(scope, "text", text_callback)?;
@@ -30,6 +32,7 @@ pub(super) fn install_globals(scope: &mut v8::PinScope<'_, '_>) -> Result<(), St
let exit = helper_function(scope, "exit", exit_callback)?;
set_global(scope, global, "tools", tools.into())?;
set_global(scope, global, "io", io.into())?;
set_global(scope, global, "ALL_TOOLS", all_tools)?;
set_global(scope, global, "clearTimeout", clear_timeout.into())?;
set_global(scope, global, "setTimeout", set_timeout.into())?;
@@ -43,6 +46,20 @@ pub(super) fn install_globals(scope: &mut v8::PinScope<'_, '_>) -> Result<(), St
Ok(())
}
fn build_io_object<'s>(
scope: &mut v8::PinScope<'s, '_>,
) -> Result<v8::Local<'s, v8::Object>, String> {
let io = v8::Object::new(scope);
let write_key = v8::String::new(scope, "write")
.ok_or_else(|| "failed to allocate io.write key".to_string())?;
let write = helper_function(scope, "io.write", io_write_callback)?;
if io.set(scope, write_key.into(), write.into()) == Some(true) {
Ok(io)
} else {
Err("failed to set io.write".to_string())
}
}
fn build_tools_object<'s>(
scope: &mut v8::PinScope<'s, '_>,
) -> Result<v8::Local<'s, v8::Object>, String> {

View File

@@ -100,9 +100,18 @@ pub struct CodeModeNestedToolCall {
pub input: Option<JsonValue>,
}
#[derive(Debug)]
pub struct CodeModeIoWrite {
pub cell_id: String,
pub runtime_write_id: String,
pub value: JsonValue,
pub destination: String,
}
#[derive(Debug)]
pub(crate) enum TurnMessage {
ToolCall(CodeModeNestedToolCall),
IoWrite(CodeModeIoWrite),
Notify {
cell_id: String,
call_id: String,
@@ -128,6 +137,11 @@ pub(crate) enum RuntimeEvent {
name: ToolName,
input: Option<JsonValue>,
},
IoWrite {
id: String,
value: JsonValue,
destination: String,
},
Notify {
call_id: String,
text: String,

View File

@@ -13,6 +13,7 @@ use tokio_util::sync::CancellationToken;
use tracing::warn;
use crate::FunctionCallOutputContentItem;
use crate::runtime::CodeModeIoWrite;
use crate::runtime::CodeModeNestedToolCall;
use crate::runtime::DEFAULT_EXEC_YIELD_TIME_MS;
use crate::runtime::ExecuteRequest;
@@ -33,6 +34,12 @@ pub trait CodeModeTurnHost: Send + Sync {
) -> Result<JsonValue, String>;
async fn notify(&self, call_id: String, cell_id: String, text: String) -> Result<(), String>;
async fn write_file(
&self,
request: CodeModeIoWrite,
cancellation_token: CancellationToken,
) -> Result<JsonValue, String>;
}
#[derive(Clone)]
@@ -222,6 +229,35 @@ impl CodeModeService {
let _ = runtime_tx.send(command);
});
}
TurnMessage::IoWrite(request) => {
let host = Arc::clone(&host);
let inner = Arc::clone(&inner);
tokio::spawn(async move {
let cell_id = request.cell_id.clone();
let runtime_write_id = request.runtime_write_id.clone();
let response = host.write_file(request, CancellationToken::new()).await;
let runtime_tx = inner
.sessions
.lock()
.await
.get(&cell_id)
.map(|handle| handle.runtime_tx.clone());
let Some(runtime_tx) = runtime_tx else {
return;
};
let command = match response {
Ok(result) => RuntimeCommand::ToolResponse {
id: runtime_write_id,
result,
},
Err(error_text) => RuntimeCommand::ToolError {
id: runtime_write_id,
error_text,
},
};
let _ = runtime_tx.send(command);
});
}
}
}
});
@@ -397,6 +433,19 @@ async fn run_session_control(
.send(TurnMessage::ToolCall(tool_call))
.await;
}
RuntimeEvent::IoWrite {
id,
value,
destination,
} => {
let request = CodeModeIoWrite {
cell_id: cell_id.clone(),
runtime_write_id: id,
value,
destination,
};
let _ = inner.turn_message_tx.send(TurnMessage::IoWrite(request)).await;
}
RuntimeEvent::Result {
stored_values,
error_text,

View File

@@ -275,7 +275,7 @@ fn mask_input_property_schema(schema: &mut JsonValue) {
.and_then(JsonValue::as_str)
.map(str::to_string)
.unwrap_or_default();
let guidance = "This parameter expects an absolute local file path. If you want to upload a file, provide the absolute path to that file here.";
let guidance = "This parameter expects an absolute local file path or an env://current/<relative-path> file reference. If you want to upload a file, provide the path or file reference here.";
if description.is_empty() {
description = guidance.to_string();
} else if !description.contains(guidance) {

View File

@@ -12,9 +12,35 @@
use crate::session::session::Session;
use crate::session::turn_context::TurnContext;
use base64::Engine as _;
use base64::engine::general_purpose::STANDARD as BASE64_STANDARD;
use codex_api::upload_local_file;
use codex_login::CodexAuth;
use codex_utils_absolute_path::AbsolutePathBuf;
use serde_json::Value as JsonValue;
use std::path::Path;
const CURRENT_ENV_URI_PREFIX: &str = "env://current/";
// rmcp 0.15 does not expose SEP-2356 `Tool.inputFiles` yet. Keep the encoder
// close to the broker so wiring the field later does not introduce a new path.
#[allow(dead_code)]
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct Sep2356FileInput {
pub(crate) data_uri: String,
pub(crate) file_name: String,
pub(crate) mime_type: String,
pub(crate) file_size_bytes: u64,
}
#[allow(dead_code)]
pub(crate) async fn build_sep2356_file_input_for_mcp(
turn_context: &TurnContext,
file_ref: &str,
) -> Result<Sep2356FileInput, String> {
let file_input = FileBrokerInput::from_model_argument(turn_context, file_ref)?;
file_input.to_sep2356_file_input().await
}
pub(crate) async fn rewrite_mcp_tool_arguments_for_openai_files(
sess: &Session,
@@ -26,75 +52,260 @@ pub(crate) async fn rewrite_mcp_tool_arguments_for_openai_files(
return Ok(arguments_value);
};
let Some(arguments_value) = arguments_value else {
return Ok(None);
};
let Some(arguments) = arguments_value.as_object() else {
return Ok(Some(arguments_value));
};
let auth = sess.services.auth_manager.auth().await;
let mut rewritten_arguments = arguments.clone();
for field_name in openai_file_input_params {
let Some(value) = arguments.get(field_name) else {
continue;
};
let Some(uploaded_value) =
rewrite_argument_value_for_openai_files(turn_context, auth.as_ref(), field_name, value)
.await?
else {
continue;
};
rewritten_arguments.insert(field_name.clone(), uploaded_value);
}
if rewritten_arguments == *arguments {
return Ok(Some(arguments_value));
}
Ok(Some(JsonValue::Object(rewritten_arguments)))
OpenAiFileBroker::new(turn_context, auth.as_ref())
.rewrite_tool_arguments(arguments_value, openai_file_input_params)
.await
}
struct OpenAiFileBroker<'a> {
turn_context: &'a TurnContext,
auth: Option<&'a CodexAuth>,
}
impl<'a> OpenAiFileBroker<'a> {
fn new(turn_context: &'a TurnContext, auth: Option<&'a CodexAuth>) -> Self {
Self { turn_context, auth }
}
async fn rewrite_tool_arguments(
&self,
arguments_value: Option<JsonValue>,
openai_file_input_params: &[String],
) -> Result<Option<JsonValue>, String> {
let Some(arguments_value) = arguments_value else {
return Ok(None);
};
let Some(arguments) = arguments_value.as_object() else {
return Ok(Some(arguments_value));
};
let mut rewritten_arguments = arguments.clone();
for field_name in openai_file_input_params {
let Some(value) = arguments.get(field_name) else {
continue;
};
let Some(uploaded_value) = self.rewrite_argument_value(field_name, value).await? else {
continue;
};
rewritten_arguments.insert(field_name.clone(), uploaded_value);
}
if rewritten_arguments == *arguments {
return Ok(Some(arguments_value));
}
Ok(Some(JsonValue::Object(rewritten_arguments)))
}
async fn rewrite_argument_value(
&self,
field_name: &str,
value: &JsonValue,
) -> Result<Option<JsonValue>, String> {
match value {
JsonValue::String(path_or_file_ref) => {
let rewritten = self
.build_uploaded_argument_value(
field_name,
/*index*/ None,
path_or_file_ref,
)
.await?;
Ok(Some(rewritten))
}
JsonValue::Array(values) => {
let mut rewritten_values = Vec::with_capacity(values.len());
for (index, item) in values.iter().enumerate() {
let Some(path_or_file_ref) = item.as_str() else {
return Ok(None);
};
let rewritten = self
.build_uploaded_argument_value(field_name, Some(index), path_or_file_ref)
.await?;
rewritten_values.push(rewritten);
}
Ok(Some(JsonValue::Array(rewritten_values)))
}
_ => Ok(None),
}
}
async fn build_uploaded_argument_value(
&self,
field_name: &str,
index: Option<usize>,
file_path: &str,
) -> Result<JsonValue, String> {
let file_input = FileBrokerInput::from_model_argument(self.turn_context, file_path)?;
let Some(auth) = self.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(
self.turn_context
.config
.chatgpt_base_url
.trim_end_matches('/'),
upload_auth.as_ref(),
&file_input.resolved_path,
)
.await
.map_err(|error| match index {
Some(index) => {
format!(
"failed to upload `{}` for `{field_name}[{index}]`: {error}",
file_input.original
)
}
None => format!(
"failed to upload `{}` for `{field_name}`: {error}",
file_input.original
),
})?;
Ok(serde_json::json!({
"download_url": uploaded.download_url,
"file_id": uploaded.file_id,
"mime_type": uploaded.mime_type,
"file_name": uploaded.file_name,
"uri": uploaded.uri,
"file_size_bytes": uploaded.file_size_bytes,
}))
}
}
struct FileBrokerInput<'a> {
original: &'a str,
resolved_path: AbsolutePathBuf,
}
impl<'a> FileBrokerInput<'a> {
fn from_model_argument(turn_context: &TurnContext, value: &'a str) -> Result<Self, String> {
if let Some(relative_path) = value.strip_prefix(CURRENT_ENV_URI_PREFIX) {
return Self::current_env_path(turn_context, value, relative_path);
}
if value.starts_with("env://") {
return Err(format!("unsupported file environment reference `{value}`"));
}
Ok(Self {
original: value,
resolved_path: turn_context.resolve_path(Some(value.to_string())),
})
}
fn current_env_path(
turn_context: &TurnContext,
original: &'a str,
relative_path: &str,
) -> Result<Self, String> {
if relative_path.trim().is_empty() {
return Err("file environment reference path must be non-empty".to_string());
}
if Path::new(relative_path).is_absolute() {
return Err("file environment reference path must be relative".to_string());
}
let resolved_path = turn_context.cwd.join(relative_path);
if !resolved_path
.as_path()
.starts_with(turn_context.cwd.as_path())
{
return Err(format!(
"file environment reference `{original}` escapes the current environment"
));
}
Ok(Self {
original,
resolved_path,
})
}
#[allow(dead_code)]
async fn to_sep2356_file_input(&self) -> Result<Sep2356FileInput, String> {
let bytes = tokio::fs::read(self.resolved_path.as_path())
.await
.map_err(|error| {
format!(
"failed to read `{}` for SEP-2356 file input: {error}",
self.original
)
})?;
let file_name = self
.resolved_path
.as_path()
.file_name()
.and_then(|name| name.to_str())
.filter(|name| !name.is_empty())
.ok_or_else(|| format!("failed to determine file name for `{}`", self.original))?
.to_string();
let mime_type = mime_type_for_sep2356_path(self.resolved_path.as_path()).to_string();
let encoded_name = percent_encode_rfc2397_parameter(&file_name);
let data_uri = format!(
"data:{mime_type};name={encoded_name};base64,{}",
BASE64_STANDARD.encode(&bytes)
);
Ok(Sep2356FileInput {
data_uri,
file_name,
mime_type,
file_size_bytes: bytes.len() as u64,
})
}
}
#[allow(dead_code)]
fn mime_type_for_sep2356_path(path: &Path) -> &'static str {
match path
.extension()
.and_then(|extension| extension.to_str())
.map(str::to_ascii_lowercase)
.as_deref()
{
Some("csv") => "text/csv",
Some("gif") => "image/gif",
Some("htm" | "html") => "text/html",
Some("jpg" | "jpeg") => "image/jpeg",
Some("json") => "application/json",
Some("pdf") => "application/pdf",
Some("png") => "image/png",
Some("txt") => "text/plain",
Some("webp") => "image/webp",
_ => "application/octet-stream",
}
}
#[allow(dead_code)]
fn percent_encode_rfc2397_parameter(value: &str) -> String {
let mut encoded = String::with_capacity(value.len());
for byte in value.as_bytes() {
if byte.is_ascii_alphanumeric() || matches!(*byte, b'-' | b'.' | b'_' | b'~') {
encoded.push(*byte as char);
} else {
encoded.push_str(&format!("%{byte:02X}"));
}
}
encoded
}
#[cfg(test)]
async fn rewrite_argument_value_for_openai_files(
turn_context: &TurnContext,
auth: Option<&CodexAuth>,
field_name: &str,
value: &JsonValue,
) -> Result<Option<JsonValue>, String> {
match value {
JsonValue::String(path_or_file_ref) => {
let rewritten = build_uploaded_local_argument_value(
turn_context,
auth,
field_name,
/*index*/ None,
path_or_file_ref,
)
.await?;
Ok(Some(rewritten))
}
JsonValue::Array(values) => {
let mut rewritten_values = Vec::with_capacity(values.len());
for (index, item) in values.iter().enumerate() {
let Some(path_or_file_ref) = item.as_str() else {
return Ok(None);
};
let rewritten = build_uploaded_local_argument_value(
turn_context,
auth,
field_name,
Some(index),
path_or_file_ref,
)
.await?;
rewritten_values.push(rewritten);
}
Ok(Some(JsonValue::Array(rewritten_values)))
}
_ => Ok(None),
}
OpenAiFileBroker::new(turn_context, auth)
.rewrite_argument_value(field_name, value)
.await
}
#[cfg(test)]
async fn build_uploaded_local_argument_value(
turn_context: &TurnContext,
auth: Option<&CodexAuth>,
@@ -102,38 +313,9 @@ async fn build_uploaded_local_argument_value(
index: Option<usize>,
file_path: &str,
) -> Result<JsonValue, String> {
let resolved_path = turn_context.resolve_path(Some(file_path.to_string()));
let Some(auth) = auth else {
return Err(
"ChatGPT auth is required to upload local files for Codex Apps tools".to_string(),
);
};
if !auth.uses_codex_backend() {
return Err(
"ChatGPT auth is required to upload local files for Codex Apps tools".to_string(),
);
}
let upload_auth = codex_model_provider::auth_provider_from_auth(auth);
let uploaded = upload_local_file(
turn_context.config.chatgpt_base_url.trim_end_matches('/'),
upload_auth.as_ref(),
&resolved_path,
)
.await
.map_err(|error| match index {
Some(index) => {
format!("failed to upload `{file_path}` for `{field_name}[{index}]`: {error}")
}
None => format!("failed to upload `{file_path}` for `{field_name}`: {error}"),
})?;
Ok(serde_json::json!({
"download_url": uploaded.download_url,
"file_id": uploaded.file_id,
"mime_type": uploaded.mime_type,
"file_name": uploaded.file_name,
"uri": uploaded.uri,
"file_size_bytes": uploaded.file_size_bytes,
}))
OpenAiFileBroker::new(turn_context, auth)
.build_uploaded_argument_value(field_name, index, file_path)
.await
}
#[cfg(test)]
@@ -245,6 +427,144 @@ mod tests {
);
}
#[tokio::test]
async fn build_uploaded_local_argument_value_uploads_current_env_file_ref() {
use wiremock::Mock;
use wiremock::MockServer;
use wiremock::ResponseTemplate;
use wiremock::matchers::body_json;
use wiremock::matchers::header;
use wiremock::matchers::method;
use wiremock::matchers::path;
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/backend-api/files"))
.and(header("chatgpt-account-id", "account_id"))
.and(body_json(serde_json::json!({
"file_name": "file_report.csv",
"file_size": 5,
"use_case": "codex",
})))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"file_id": "file_123",
"upload_url": format!("{}/upload/file_123", server.uri()),
})))
.expect(1)
.mount(&server)
.await;
Mock::given(method("PUT"))
.and(path("/upload/file_123"))
.respond_with(ResponseTemplate::new(200))
.expect(1)
.mount(&server)
.await;
Mock::given(method("POST"))
.and(path("/backend-api/files/file_123/uploaded"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"status": "success",
"download_url": format!("{}/download/file_123", server.uri()),
"file_name": "file_report.csv",
"mime_type": "text/csv",
"file_size_bytes": 5,
})))
.expect(1)
.mount(&server)
.await;
let (_, mut turn_context) = make_session_and_context().await;
let auth = CodexAuth::create_dummy_chatgpt_auth_for_testing();
let dir = tempdir().expect("temp dir");
tokio::fs::create_dir_all(dir.path().join("nested"))
.await
.expect("create nested dir");
tokio::fs::write(dir.path().join("nested/file_report.csv"), b"hello")
.await
.expect("write env file ref target");
turn_context.cwd = AbsolutePathBuf::try_from(dir.path()).expect("absolute path");
let mut config = (*turn_context.config).clone();
config.chatgpt_base_url = format!("{}/backend-api", server.uri());
turn_context.config = Arc::new(config);
let rewritten = build_uploaded_local_argument_value(
&turn_context,
Some(&auth),
"file",
/*index*/ None,
"env://current/nested/file_report.csv",
)
.await
.expect("rewrite should upload the env file ref");
assert_eq!(
rewritten,
serde_json::json!({
"download_url": format!("{}/download/file_123", server.uri()),
"file_id": "file_123",
"mime_type": "text/csv",
"file_name": "file_report.csv",
"uri": "sediment://file_123",
"file_size_bytes": 5,
})
);
}
#[tokio::test]
async fn build_uploaded_local_argument_value_rejects_current_env_escape() {
let (_, mut turn_context) = make_session_and_context().await;
let dir = tempdir().expect("temp dir");
turn_context.cwd = AbsolutePathBuf::try_from(dir.path()).expect("absolute path");
let error = build_uploaded_local_argument_value(
&turn_context,
Some(&CodexAuth::create_dummy_chatgpt_auth_for_testing()),
"file",
/*index*/ None,
"env://current/../outside.csv",
)
.await
.expect_err("env file refs should not escape cwd");
assert!(error.contains("escapes the current environment"));
}
#[tokio::test]
async fn build_sep2356_file_input_encodes_current_env_file_ref_as_data_uri() {
let (_, mut turn_context) = make_session_and_context().await;
let dir = tempdir().expect("temp dir");
tokio::fs::write(dir.path().join("report 1.csv"), b"hello")
.await
.expect("write sep file");
turn_context.cwd = AbsolutePathBuf::try_from(dir.path()).expect("absolute path");
let file_input =
build_sep2356_file_input_for_mcp(&turn_context, "env://current/report 1.csv")
.await
.expect("sep file input should be encoded");
assert_eq!(file_input.file_name, "report 1.csv");
assert_eq!(file_input.mime_type, "text/csv");
assert_eq!(file_input.file_size_bytes, 5);
assert_eq!(
file_input.data_uri,
"data:text/csv;name=report%201.csv;base64,aGVsbG8="
);
}
#[tokio::test]
async fn build_sep2356_file_input_rejects_current_env_escape() {
let (_, mut turn_context) = make_session_and_context().await;
let dir = tempdir().expect("temp dir");
turn_context.cwd = AbsolutePathBuf::try_from(dir.path()).expect("absolute path");
let error = build_sep2356_file_input_for_mcp(&turn_context, "env://current/../outside.csv")
.await
.expect_err("sep file refs should not escape cwd");
assert!(error.contains("escapes the current environment"));
}
#[tokio::test]
async fn rewrite_argument_value_for_openai_files_rewrites_scalar_path() {
use wiremock::Mock;

View File

@@ -4,9 +4,13 @@ mod response_adapter;
mod wait_handler;
pub(crate) mod wait_spec;
use std::path::Path;
use std::sync::Arc;
use std::time::Duration;
use base64::Engine as _;
use base64::engine::general_purpose::STANDARD as BASE64_STANDARD;
use codex_code_mode::CodeModeIoWrite;
use codex_code_mode::CodeModeNestedToolCall;
use codex_code_mode::CodeModeTurnHost;
use codex_code_mode::RuntimeResponse;
@@ -158,6 +162,104 @@ impl CodeModeTurnHost for CoreTurnHost {
format!("failed to inject exec notify message for cell {cell_id}: no active turn")
})
}
async fn write_file(
&self,
request: CodeModeIoWrite,
_cancellation_token: CancellationToken,
) -> Result<JsonValue, String> {
write_code_mode_io_value(self.exec.turn.as_ref(), request).await
}
}
const CURRENT_ENV_URI_PREFIX: &str = "env://current/";
async fn write_code_mode_io_value(
turn: &TurnContext,
request: CodeModeIoWrite,
) -> Result<JsonValue, String> {
let destination = resolve_code_mode_io_destination(turn, &request.destination)?;
let bytes = code_mode_io_value_bytes(&request.value)?;
if let Some(parent) = destination.parent() {
tokio::fs::create_dir_all(parent.as_path())
.await
.map_err(|error| format!("failed to create parent directory for io.write: {error}"))?;
}
tokio::fs::write(destination.as_path(), &bytes)
.await
.map_err(|error| format!("failed to write `{}`: {error}", request.destination))?;
Ok(serde_json::json!({
"uri": request.destination,
"path": destination.to_string_lossy(),
"bytes_written": bytes.len(),
}))
}
fn resolve_code_mode_io_destination(
turn: &TurnContext,
destination: &str,
) -> Result<codex_utils_absolute_path::AbsolutePathBuf, String> {
let relative_path = destination
.strip_prefix(CURRENT_ENV_URI_PREFIX)
.ok_or_else(|| {
format!("io.write destination must start with `{CURRENT_ENV_URI_PREFIX}`")
})?;
if relative_path.trim().is_empty() {
return Err("io.write destination path must be non-empty".to_string());
}
if Path::new(relative_path).is_absolute() {
return Err("io.write destination path must be relative".to_string());
}
let resolved = turn.cwd.join(relative_path);
if !resolved.as_path().starts_with(turn.cwd.as_path()) {
return Err(format!(
"io.write destination `{destination}` escapes the current environment"
));
}
Ok(resolved)
}
fn code_mode_io_value_bytes(value: &JsonValue) -> Result<Vec<u8>, String> {
match value {
JsonValue::String(text) => Ok(text.as_bytes().to_vec()),
JsonValue::Array(items) if items.iter().all(JsonValue::is_number) => items
.iter()
.map(|item| {
let Some(byte) = item.as_u64().filter(|byte| *byte <= u8::MAX as u64) else {
return Err(
"io.write byte arrays must contain integers from 0 to 255".to_string()
);
};
Ok(byte as u8)
})
.collect(),
JsonValue::Object(object) => {
if let Some(text) = object.get("text").and_then(JsonValue::as_str) {
return Ok(text.as_bytes().to_vec());
}
if let Some(resource) = object.get("resource") {
return code_mode_io_value_bytes(resource);
}
if let Some(encoded) = object
.get("blob")
.or_else(|| object.get("data_base64"))
.or_else(|| object.get("base64"))
.and_then(JsonValue::as_str)
{
return BASE64_STANDARD
.decode(encoded)
.map_err(|error| format!("io.write received invalid base64 data: {error}"));
}
Err(
"io.write value must be a string, byte array, MCP text block, or base64 blob block"
.to_string(),
)
}
_ => Err(
"io.write value must be a string, byte array, MCP text block, or base64 blob block"
.to_string(),
),
}
}
pub(super) async fn handle_runtime_response(

View File

@@ -2170,6 +2170,43 @@ async fn code_mode_can_apply_patch_via_nested_tool() -> Result<()> {
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn code_mode_io_write_writes_text_to_current_env() -> Result<()> {
skip_if_no_network!(Ok(()));
let server = responses::start_mock_server().await;
let code = r#"
const result = await io.write("hello from io", "env://current/nested/io-output.txt");
text(JSON.stringify(result));
"#;
let (test, second_mock) =
run_code_mode_turn(&server, "use io.write to write a file", code, false).await?;
let req = second_mock.single_request();
let (output, success) = custom_tool_output_body_and_success(&req, "call-1");
assert_ne!(
success,
Some(false),
"exec io.write failed unexpectedly: {output}"
);
let parsed: Value = serde_json::from_str(&output)?;
assert_eq!(
parsed.get("uri").and_then(Value::as_str),
Some("env://current/nested/io-output.txt")
);
assert_eq!(
parsed.get("bytes_written").and_then(Value::as_u64),
Some(13)
);
assert_eq!(
fs::read_to_string(test.cwd_path().join("nested/io-output.txt"))?,
"hello from io"
);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn code_mode_can_print_structured_mcp_tool_result_fields() -> Result<()> {
skip_if_no_network!(Ok(()));

View File

@@ -15,11 +15,13 @@ use core_test_support::apps_test_server::DOCUMENT_EXTRACT_TEXT_RESOURCE_URI;
use core_test_support::hooks::trust_discovered_hooks;
use core_test_support::responses::ev_assistant_message;
use core_test_support::responses::ev_completed;
use core_test_support::responses::ev_custom_tool_call;
use core_test_support::responses::ev_function_call_with_namespace;
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::skip_if_no_network;
use core_test_support::test_codex::test_codex;
use pretty_assertions::assert_eq;
use serde_json::Value;
@@ -188,7 +190,7 @@ async fn codex_apps_file_params_upload_local_paths_before_mcp_tool_call() -> Res
extract_tool.pointer("/parameters/properties/file"),
Some(&json!({
"type": "string",
"description": "Document file payload. This parameter expects an absolute local file path. If you want to upload a file, provide the absolute path to that file here."
"description": "Document file payload. This parameter expects an absolute local file path or an env://current/<relative-path> file reference. If you want to upload a file, provide the path or file reference here."
}))
);
@@ -245,3 +247,139 @@ 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 code_mode_nested_mcp_file_params_upload_local_paths_before_tool_call() -> Result<()> {
skip_if_no_network!(Ok(()));
let server = start_mock_server().await;
let apps_server = AppsTestServer::mount(&server).await?;
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,
"use_case": "codex",
})))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"file_id": "file_123",
"upload_url": format!("{}/upload/file_123", server.uri()),
})))
.expect(1)
.mount(&server)
.await;
Mock::given(method("PUT"))
.and(path("/upload/file_123"))
.and(header("content-length", "11"))
.respond_with(ResponseTemplate::new(200))
.expect(1)
.mount(&server)
.await;
Mock::given(method("POST"))
.and(path("/files/file_123/uploaded"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"status": "success",
"download_url": format!("{}/download/file_123", server.uri()),
"file_name": "report.txt",
"mime_type": "text/plain",
"file_size_bytes": 11,
})))
.expect(1)
.mount(&server)
.await;
let code = r#"
const result = await tools.mcp__codex_apps__calendar_extract_text({
file: "report.txt",
});
text(result.content?.[0]?.text ?? "missing result");
"#;
mount_sse_sequence(
&server,
vec![
sse(vec![
ev_response_created("resp-1"),
ev_custom_tool_call("call-1", "exec", code),
ev_completed("resp-1"),
]),
sse(vec![
ev_response_created("resp-2"),
ev_assistant_message("msg-1", "done"),
ev_completed("resp-2"),
]),
],
)
.await;
let mut builder = test_codex()
.with_auth(CodexAuth::create_dummy_chatgpt_auth_for_testing())
.with_config(move |config| {
configure_apps(config, apps_server.chatgpt_base_url.as_str());
config
.features
.enable(Feature::CodeMode)
.expect("test config should allow feature update");
});
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(
"Use code mode to extract the report text with the app tool.",
AskForApproval::Never,
PermissionProfile::Disabled,
)
.await?;
let apps_tool_call = server
.received_requests()
.await
.unwrap_or_default()
.into_iter()
.find_map(|request| {
let body: Value = serde_json::from_slice(&request.body).ok()?;
(request.url.path() == "/api/codex/apps"
&& body.get("method").and_then(Value::as_str) == Some("tools/call")
&& body.pointer("/params/name").and_then(Value::as_str)
== Some("calendar_extract_text"))
.then_some(body)
})
.expect("apps calendar_extract_text tools/call request should be recorded");
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,
}))
);
let codex_apps_meta = apps_tool_call
.pointer("/params/_meta/_codex_apps")
.expect("codex apps metadata should be forwarded");
assert!(
codex_apps_meta
.pointer("/call_id")
.and_then(Value::as_str)
.is_some_and(|call_id| call_id.starts_with("exec-"))
);
assert_eq!(
codex_apps_meta.pointer("/resource_uri"),
Some(&json!(DOCUMENT_EXTRACT_TEXT_RESOURCE_URI))
);
assert_eq!(
codex_apps_meta.pointer("/contains_mcp_source"),
Some(&json!(true))
);
assert_eq!(
codex_apps_meta.pointer("/connector_id"),
Some(&json!("calendar"))
);
server.verify().await;
Ok(())
}