Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion codex-rs/codex-mcp/src/tools.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
147 changes: 142 additions & 5 deletions codex-rs/core/src/mcp_openai_file.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,9 @@ 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/";

pub(crate) async fn rewrite_mcp_tool_arguments_for_openai_files(
sess: &Session,
Expand Down Expand Up @@ -112,7 +115,7 @@ impl<'a> OpenAiFileBroker<'a> {
index: Option<usize>,
file_path: &str,
) -> Result<JsonValue, String> {
let file_input = FileBrokerInput::local_path(self.turn_context, file_path);
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(),
Expand Down Expand Up @@ -162,11 +165,43 @@ struct FileBrokerInput<'a> {
}

impl<'a> FileBrokerInput<'a> {
fn local_path(turn_context: &TurnContext, path: &'a str) -> Self {
Self {
original: path,
resolved_path: turn_context.resolve_path(Some(path.to_string())),
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,
})
}
}

Expand Down Expand Up @@ -304,6 +339,108 @@ 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 rewrite_argument_value_for_openai_files_rewrites_scalar_path() {
use wiremock::Mock;
Expand Down
2 changes: 1 addition & 1 deletion codex-rs/core/tests/suite/openai_file_mcp.rs
Original file line number Diff line number Diff line change
Expand Up @@ -190,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."
}))
);

Expand Down
Loading