mirror of
https://github.com/openai/codex.git
synced 2026-05-10 22:32:36 +00:00
Compare commits
5 Commits
pr20360
...
codex/libr
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0380483d73 | ||
|
|
5a04eab75f | ||
|
|
f0c1c2951b | ||
|
|
27db0443d2 | ||
|
|
6717d23c57 |
1
codex-rs/Cargo.lock
generated
1
codex-rs/Cargo.lock
generated
@@ -1987,6 +1987,7 @@ dependencies = [
|
||||
"serde_json",
|
||||
"serial_test",
|
||||
"sha1",
|
||||
"sha2",
|
||||
"shlex",
|
||||
"similar",
|
||||
"tempfile",
|
||||
|
||||
@@ -753,7 +753,7 @@ impl MessageProcessor {
|
||||
| ClientRequest::TurnSteer { request_id, .. } = &codex_request
|
||||
{
|
||||
self.analytics_events_client.track_request(
|
||||
connection_id.0,
|
||||
connection_request_id.connection_id.0,
|
||||
request_id.clone(),
|
||||
codex_request.clone(),
|
||||
);
|
||||
|
||||
@@ -10,6 +10,7 @@ use serde::Deserialize;
|
||||
use tokio::fs::File;
|
||||
use tokio::time::Instant;
|
||||
use tokio_util::io::ReaderStream;
|
||||
use url::Url;
|
||||
|
||||
pub const OPENAI_FILE_URI_PREFIX: &str = "sediment://";
|
||||
pub const OPENAI_FILE_UPLOAD_LIMIT_BYTES: u64 = 512 * 1024 * 1024;
|
||||
@@ -19,6 +20,19 @@ 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)]
|
||||
pub struct OpenAiFileUploadOptions {
|
||||
pub store_in_library: bool,
|
||||
}
|
||||
|
||||
impl Default for OpenAiFileUploadOptions {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
store_in_library: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct UploadedOpenAiFile {
|
||||
pub file_id: String,
|
||||
@@ -68,6 +82,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}")]
|
||||
@@ -94,10 +114,50 @@ 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 = resolve_openai_file_download_url(base_url, download_url)?;
|
||||
let request_builder = if should_attach_auth_to_openai_file_url(&resolved_url, base_url) {
|
||||
authorized_request(auth, reqwest::Method::GET, resolved_url.as_str())
|
||||
} 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,
|
||||
})?;
|
||||
let status = response.status();
|
||||
if !status.is_success() {
|
||||
let body = response.text().await.unwrap_or_default();
|
||||
return Err(OpenAiFileError::UnexpectedStatus {
|
||||
url: resolved_url.to_string(),
|
||||
status,
|
||||
body,
|
||||
});
|
||||
}
|
||||
let bytes = response
|
||||
.bytes()
|
||||
.await
|
||||
.map_err(|source| OpenAiFileError::Request {
|
||||
url: resolved_url.to_string(),
|
||||
source,
|
||||
})?;
|
||||
Ok(bytes.to_vec())
|
||||
}
|
||||
|
||||
pub async fn upload_local_file(
|
||||
base_url: &str,
|
||||
auth: &impl AuthProvider,
|
||||
path: &Path,
|
||||
options: &OpenAiFileUploadOptions,
|
||||
) -> Result<UploadedOpenAiFile, OpenAiFileError> {
|
||||
let metadata = tokio::fs::metadata(path)
|
||||
.await
|
||||
@@ -129,12 +189,16 @@ pub async fn upload_local_file(
|
||||
.unwrap_or("file")
|
||||
.to_string();
|
||||
let create_url = format!("{}/files", 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_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 {
|
||||
@@ -266,6 +330,46 @@ fn authorized_request(
|
||||
.headers(headers)
|
||||
}
|
||||
|
||||
fn resolve_openai_file_download_url(
|
||||
base_url: &str,
|
||||
download_url: &str,
|
||||
) -> Result<Url, OpenAiFileError> {
|
||||
match Url::parse(download_url) {
|
||||
Ok(url) => Ok(url),
|
||||
Err(url::ParseError::RelativeUrlWithoutBase) => {
|
||||
let normalized_base_url = if base_url.ends_with('/') {
|
||||
base_url.to_string()
|
||||
} else {
|
||||
format!("{base_url}/")
|
||||
};
|
||||
let base =
|
||||
Url::parse(&normalized_base_url).map_err(|source| OpenAiFileError::InvalidUrl {
|
||||
url: normalized_base_url.clone(),
|
||||
source,
|
||||
})?;
|
||||
base.join(download_url)
|
||||
.map_err(|source| OpenAiFileError::InvalidUrl {
|
||||
url: download_url.to_string(),
|
||||
source,
|
||||
})
|
||||
}
|
||||
Err(source) => Err(OpenAiFileError::InvalidUrl {
|
||||
url: download_url.to_string(),
|
||||
source,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
};
|
||||
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");
|
||||
@@ -299,6 +403,32 @@ mod tests {
|
||||
format!("{}/backend-api", server.uri())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn download_openai_file_resolves_relative_url_and_attaches_auth() {
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/files/download/file_123"))
|
||||
.and(header("authorization", "Bearer token"))
|
||||
.and(header("chatgpt-account-id", "account_id"))
|
||||
.respond_with(
|
||||
ResponseTemplate::new(200)
|
||||
.insert_header("content-type", "text/plain")
|
||||
.set_body_bytes(b"hello".to_vec()),
|
||||
)
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let downloaded = download_openai_file(
|
||||
&format!("{}/backend-api/codex", server.uri()),
|
||||
&chatgpt_auth(),
|
||||
"/files/download/file_123",
|
||||
)
|
||||
.await
|
||||
.expect("download succeeds");
|
||||
|
||||
assert_eq!(downloaded, b"hello".to_vec());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn upload_local_file_returns_canonical_uri() {
|
||||
let server = MockServer::start().await;
|
||||
@@ -350,9 +480,14 @@ 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");
|
||||
|
||||
@@ -54,6 +54,8 @@ 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::OpenAiFileUploadOptions;
|
||||
pub use crate::files::download_openai_file;
|
||||
pub use crate::files::upload_local_file;
|
||||
pub use crate::provider::Provider;
|
||||
pub use crate::provider::RetryConfig;
|
||||
|
||||
16
codex-rs/codex-mcp/src/codex_apps_library_tools.rs
Normal file
16
codex-rs/codex-mcp/src/codex_apps_library_tools.rs
Normal file
@@ -0,0 +1,16 @@
|
||||
pub const SEARCH_LIBRARY_FILES_TOOL_NAME: &str = "search_library_files";
|
||||
pub const LIST_LIBRARY_DIRECTORY_NODES_TOOL_NAME: &str = "list_library_directory_nodes";
|
||||
pub const DOWNLOAD_LIBRARY_FILE_TOOL_NAME: &str = "download_library_file";
|
||||
pub const CREATE_LIBRARY_FILE_TOOL_NAME: &str = "create_library_file";
|
||||
pub const WRITEBACK_LIBRARY_FILE_TOOL_NAME: &str = "writeback_library_file";
|
||||
|
||||
pub fn is_codex_apps_library_tool(tool_name: &str) -> bool {
|
||||
matches!(
|
||||
tool_name,
|
||||
SEARCH_LIBRARY_FILES_TOOL_NAME
|
||||
| LIST_LIBRARY_DIRECTORY_NODES_TOOL_NAME
|
||||
| DOWNLOAD_LIBRARY_FILE_TOOL_NAME
|
||||
| CREATE_LIBRARY_FILE_TOOL_NAME
|
||||
| WRITEBACK_LIBRARY_FILE_TOOL_NAME
|
||||
)
|
||||
}
|
||||
@@ -1,7 +1,14 @@
|
||||
pub(crate) mod codex_apps_library_tools;
|
||||
pub(crate) mod mcp;
|
||||
pub(crate) mod mcp_connection_manager;
|
||||
pub(crate) mod mcp_tool_names;
|
||||
|
||||
pub use codex_apps_library_tools::CREATE_LIBRARY_FILE_TOOL_NAME;
|
||||
pub use codex_apps_library_tools::DOWNLOAD_LIBRARY_FILE_TOOL_NAME;
|
||||
pub use codex_apps_library_tools::LIST_LIBRARY_DIRECTORY_NODES_TOOL_NAME;
|
||||
pub use codex_apps_library_tools::SEARCH_LIBRARY_FILES_TOOL_NAME;
|
||||
pub use codex_apps_library_tools::WRITEBACK_LIBRARY_FILE_TOOL_NAME;
|
||||
pub use codex_apps_library_tools::is_codex_apps_library_tool;
|
||||
pub use mcp::CODEX_APPS_MCP_SERVER_NAME;
|
||||
pub use mcp::McpAuthStatusEntry;
|
||||
pub use mcp::McpConfig;
|
||||
|
||||
@@ -727,7 +727,10 @@ async fn list_all_tools_does_not_block_when_startup_snapshot_cache_hit_is_empty(
|
||||
let timeout_result =
|
||||
tokio::time::timeout(Duration::from_millis(10), manager.list_all_tools()).await;
|
||||
let tools = timeout_result.expect("cache-hit startup snapshot should not block");
|
||||
assert!(tools.is_empty());
|
||||
assert!(
|
||||
tools.is_empty(),
|
||||
"empty startup snapshots should not synthesize codex apps tools"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
@@ -97,6 +97,7 @@ rmcp = { workspace = true, default-features = false, features = [
|
||||
] }
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
serde_json = { workspace = true }
|
||||
sha2 = { workspace = true }
|
||||
sha1 = { workspace = true }
|
||||
shlex = { workspace = true }
|
||||
similar = { workspace = true }
|
||||
|
||||
@@ -7052,12 +7052,7 @@ async fn run_sampling_request(
|
||||
let _code_mode_worker = sess
|
||||
.services
|
||||
.code_mode_service
|
||||
.start_turn_worker(
|
||||
&sess,
|
||||
&turn_context,
|
||||
Arc::clone(&router),
|
||||
Arc::clone(&turn_diff_tracker),
|
||||
)
|
||||
.start_turn_worker(&sess, &turn_context, Arc::clone(&turn_diff_tracker))
|
||||
.await;
|
||||
let mut retries = 0;
|
||||
let mut initial_input = Some(input);
|
||||
|
||||
413
codex-rs/core/src/codex_apps_file_download.rs
Normal file
413
codex-rs/core/src/codex_apps_file_download.rs
Normal file
@@ -0,0 +1,413 @@
|
||||
use crate::codex::Session;
|
||||
use crate::codex::TurnContext;
|
||||
use crate::codex_apps_mcp_tools::should_materialize_codex_apps_file_download;
|
||||
use codex_api::CoreAuthProvider;
|
||||
use codex_api::download_openai_file;
|
||||
use codex_login::CodexAuth;
|
||||
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";
|
||||
|
||||
#[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>,
|
||||
}
|
||||
|
||||
pub(crate) async fn maybe_materialize_codex_apps_file_download_result(
|
||||
sess: &Session,
|
||||
turn_context: &TurnContext,
|
||||
server: &str,
|
||||
codex_apps_meta: Option<&JsonMap<String, JsonValue>>,
|
||||
result: CallToolResult,
|
||||
) -> CallToolResult {
|
||||
let auth = sess.services.auth_manager.auth().await;
|
||||
maybe_materialize_codex_apps_file_download_result_with_auth(
|
||||
turn_context,
|
||||
&sess.conversation_id.to_string(),
|
||||
auth.as_ref(),
|
||||
server,
|
||||
codex_apps_meta,
|
||||
result,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn maybe_materialize_codex_apps_file_download_result_with_auth(
|
||||
turn_context: &TurnContext,
|
||||
session_id: &str,
|
||||
auth: Option<&CodexAuth>,
|
||||
server: &str,
|
||||
codex_apps_meta: Option<&JsonMap<String, JsonValue>>,
|
||||
mut result: CallToolResult,
|
||||
) -> CallToolResult {
|
||||
if !should_materialize_codex_apps_file_download(server, codex_apps_meta)
|
||||
|| result.is_error == Some(true)
|
||||
{
|
||||
return result;
|
||||
}
|
||||
|
||||
let Some(payload) = extract_codex_apps_file_download_payload(&result) else {
|
||||
return result;
|
||||
};
|
||||
if result.structured_content.is_none()
|
||||
&& let Ok(structured_content) = serde_json::to_value(&payload)
|
||||
{
|
||||
result.structured_content = Some(structured_content);
|
||||
}
|
||||
|
||||
let Some(auth) = auth else {
|
||||
warn!(
|
||||
"skipping codex_apps file download materialization because ChatGPT auth is unavailable"
|
||||
);
|
||||
return result;
|
||||
};
|
||||
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");
|
||||
return result;
|
||||
}
|
||||
};
|
||||
let download_auth = CoreAuthProvider {
|
||||
token: Some(token_data.access_token),
|
||||
account_id: token_data
|
||||
.id_token
|
||||
.chatgpt_account_id
|
||||
.clone()
|
||||
.or(token_data.account_id),
|
||||
};
|
||||
let downloaded = match download_openai_file(
|
||||
turn_context.config.chatgpt_base_url.trim_end_matches('/'),
|
||||
&download_auth,
|
||||
&payload.file_uri.download_url,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(downloaded) => downloaded,
|
||||
Err(error) => {
|
||||
warn!(
|
||||
error = %error,
|
||||
file_id = payload.file_id,
|
||||
"failed to materialize codex_apps file download via app-server",
|
||||
);
|
||||
return result;
|
||||
}
|
||||
};
|
||||
|
||||
let artifact_path = codex_apps_file_download_artifact_path(
|
||||
&turn_context.config.codex_home,
|
||||
session_id,
|
||||
&payload.file_id,
|
||||
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",
|
||||
);
|
||||
return result;
|
||||
}
|
||||
if let Err(error) = tokio::fs::write(artifact_path.as_path(), &downloaded).await {
|
||||
warn!(
|
||||
error = %error,
|
||||
path = %artifact_path.display(),
|
||||
"failed to write codex_apps file download artifact",
|
||||
);
|
||||
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()),
|
||||
);
|
||||
}
|
||||
result.content.push(serde_json::json!({
|
||||
"type": "text",
|
||||
"text": format!("Downloaded file to local path: {local_path}"),
|
||||
}));
|
||||
result
|
||||
}
|
||||
|
||||
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::codex::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::ResponseTemplate;
|
||||
use wiremock::matchers::header;
|
||||
use wiremock::matchers::method;
|
||||
use wiremock::matchers::path;
|
||||
|
||||
fn download_materialization_meta() -> JsonMap<String, JsonValue> {
|
||||
serde_json::json!({
|
||||
"provider": "builtin",
|
||||
"materialize_file_download": true,
|
||||
})
|
||||
.as_object()
|
||||
.cloned()
|
||||
.expect("_codex_apps metadata object")
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn codex_apps_file_download_materialization_ignores_results_without_metadata_flag() {
|
||||
let (_, turn_context) = make_session_and_context().await;
|
||||
let original = CallToolResult {
|
||||
content: vec![serde_json::json!({"type": "text", "text": "hello"})],
|
||||
structured_content: Some(serde_json::json!({"x": 1})),
|
||||
is_error: Some(false),
|
||||
meta: None,
|
||||
};
|
||||
|
||||
let result = maybe_materialize_codex_apps_file_download_result_with_auth(
|
||||
&turn_context,
|
||||
"session-1",
|
||||
Some(&CodexAuth::create_dummy_chatgpt_auth_for_testing()),
|
||||
"custom_server",
|
||||
/*codex_apps_meta*/ None,
|
||||
original.clone(),
|
||||
)
|
||||
.await;
|
||||
|
||||
assert_eq!(result, original);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn codex_apps_file_download_materialization_adds_local_path_for_marked_tools() {
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/api/codex/files/file_123/content"))
|
||||
.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;
|
||||
|
||||
let (_, mut turn_context) = make_session_and_context().await;
|
||||
let mut config = (*turn_context.config).clone();
|
||||
config.chatgpt_base_url = format!("{}/backend-api/codex", server.uri());
|
||||
turn_context.config = Arc::new(config);
|
||||
let original = CallToolResult {
|
||||
content: vec![serde_json::json!({
|
||||
"type": "text",
|
||||
"text": "{\"file_id\":\"file_123\"}",
|
||||
})],
|
||||
structured_content: Some(serde_json::json!({
|
||||
"file_id": "file_123",
|
||||
"file_name": "testing-file.txt",
|
||||
"file_uri": {
|
||||
"download_url": "/api/codex/files/file_123/content",
|
||||
"file_id": "file_123",
|
||||
"file_name": "testing-file.txt",
|
||||
"mime_type": "text/plain",
|
||||
}
|
||||
})),
|
||||
is_error: Some(false),
|
||||
meta: None,
|
||||
};
|
||||
|
||||
let result = maybe_materialize_codex_apps_file_download_result_with_auth(
|
||||
&turn_context,
|
||||
"session-1",
|
||||
Some(&CodexAuth::create_dummy_chatgpt_auth_for_testing()),
|
||||
codex_mcp::CODEX_APPS_MCP_SERVER_NAME,
|
||||
Some(&download_materialization_meta()),
|
||||
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"));
|
||||
let saved = tokio::fs::read(local_path)
|
||||
.await
|
||||
.expect("saved local file should exist");
|
||||
assert_eq!(saved, b"downloaded contents".to_vec());
|
||||
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_uses_json_text_when_structured_content_is_missing()
|
||||
{
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/api/codex/files/file_123/content"))
|
||||
.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;
|
||||
|
||||
let (_, mut turn_context) = make_session_and_context().await;
|
||||
let mut config = (*turn_context.config).clone();
|
||||
config.chatgpt_base_url = format!("{}/backend-api/codex", server.uri());
|
||||
turn_context.config = Arc::new(config);
|
||||
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": "/api/codex/files/file_123/content",
|
||||
"file_name": "testing-file.txt",
|
||||
}
|
||||
})
|
||||
.to_string(),
|
||||
})],
|
||||
structured_content: None,
|
||||
is_error: Some(false),
|
||||
meta: None,
|
||||
};
|
||||
|
||||
let result = maybe_materialize_codex_apps_file_download_result_with_auth(
|
||||
&turn_context,
|
||||
"session-1",
|
||||
Some(&CodexAuth::create_dummy_chatgpt_auth_for_testing()),
|
||||
codex_mcp::CODEX_APPS_MCP_SERVER_NAME,
|
||||
Some(&download_materialization_meta()),
|
||||
original,
|
||||
)
|
||||
.await;
|
||||
|
||||
let local_path = result
|
||||
.content
|
||||
.iter()
|
||||
.find_map(|item| {
|
||||
item.get("text")
|
||||
.and_then(|text| text.as_str())
|
||||
.and_then(|text| text.strip_prefix("Downloaded file to local path: "))
|
||||
})
|
||||
.expect("expected local path announcement");
|
||||
assert_eq!(
|
||||
result.structured_content,
|
||||
Some(serde_json::json!({
|
||||
"file_id": "file_123",
|
||||
"file_name": "testing-file.txt",
|
||||
"file_uri": {
|
||||
"download_url": "/api/codex/files/file_123/content",
|
||||
"file_name": "testing-file.txt",
|
||||
},
|
||||
"local_path": local_path,
|
||||
}))
|
||||
);
|
||||
assert_eq!(
|
||||
tokio::fs::read(local_path).await.expect("downloaded file"),
|
||||
b"downloaded contents"
|
||||
);
|
||||
}
|
||||
}
|
||||
72
codex-rs/core/src/codex_apps_mcp_tools.rs
Normal file
72
codex-rs/core/src/codex_apps_mcp_tools.rs
Normal file
@@ -0,0 +1,72 @@
|
||||
use codex_mcp::CODEX_APPS_MCP_SERVER_NAME;
|
||||
use codex_mcp::ToolInfo as McpToolInfo;
|
||||
use serde_json::Map;
|
||||
use serde_json::Value;
|
||||
|
||||
pub(crate) const CODEX_APPS_META_KEY: &str = "_codex_apps";
|
||||
|
||||
const CODEX_APPS_PROVIDER_BUILTIN: &str = "builtin";
|
||||
const CODEX_APPS_META_PROVIDER_KEY: &str = "provider";
|
||||
const CODEX_APPS_META_DIRECT_EXPOSE_KEY: &str = "direct_expose";
|
||||
const CODEX_APPS_META_MATERIALIZE_FILE_DOWNLOAD_KEY: &str = "materialize_file_download";
|
||||
|
||||
pub(crate) fn is_direct_exposed_codex_apps_builtin_tool_info(tool: &McpToolInfo) -> bool {
|
||||
is_direct_exposed_codex_apps_builtin(
|
||||
&tool.server_name,
|
||||
tool.connector_id.as_deref(),
|
||||
codex_apps_meta_from_tool_info(tool),
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn is_direct_exposed_codex_apps_builtin(
|
||||
server_name: &str,
|
||||
connector_id: Option<&str>,
|
||||
codex_apps_meta: Option<&Map<String, Value>>,
|
||||
) -> bool {
|
||||
if server_name != CODEX_APPS_MCP_SERVER_NAME || connector_id.is_some() {
|
||||
return false;
|
||||
}
|
||||
|
||||
let Some(codex_apps_meta) = codex_apps_meta else {
|
||||
return false;
|
||||
};
|
||||
|
||||
codex_apps_meta
|
||||
.get(CODEX_APPS_META_PROVIDER_KEY)
|
||||
.and_then(Value::as_str)
|
||||
== Some(CODEX_APPS_PROVIDER_BUILTIN)
|
||||
&& codex_apps_meta
|
||||
.get(CODEX_APPS_META_DIRECT_EXPOSE_KEY)
|
||||
.and_then(Value::as_bool)
|
||||
== Some(true)
|
||||
}
|
||||
|
||||
pub(crate) fn should_materialize_codex_apps_file_download(
|
||||
server_name: &str,
|
||||
codex_apps_meta: Option<&Map<String, Value>>,
|
||||
) -> bool {
|
||||
if server_name != CODEX_APPS_MCP_SERVER_NAME {
|
||||
return false;
|
||||
}
|
||||
|
||||
let Some(codex_apps_meta) = codex_apps_meta else {
|
||||
return false;
|
||||
};
|
||||
|
||||
codex_apps_meta
|
||||
.get(CODEX_APPS_META_PROVIDER_KEY)
|
||||
.and_then(Value::as_str)
|
||||
== Some(CODEX_APPS_PROVIDER_BUILTIN)
|
||||
&& codex_apps_meta
|
||||
.get(CODEX_APPS_META_MATERIALIZE_FILE_DOWNLOAD_KEY)
|
||||
.and_then(Value::as_bool)
|
||||
== Some(true)
|
||||
}
|
||||
|
||||
fn codex_apps_meta_from_tool_info(tool: &McpToolInfo) -> Option<&Map<String, Value>> {
|
||||
tool.tool
|
||||
.meta
|
||||
.as_ref()
|
||||
.and_then(|meta| meta.get(CODEX_APPS_META_KEY))
|
||||
.and_then(Value::as_object)
|
||||
}
|
||||
@@ -410,6 +410,16 @@ fn make_mcp_tool(
|
||||
tool_name: &str,
|
||||
connector_id: Option<&str>,
|
||||
connector_name: Option<&str>,
|
||||
) -> ToolInfo {
|
||||
make_mcp_tool_with_meta(server_name, tool_name, connector_id, connector_name, None)
|
||||
}
|
||||
|
||||
fn make_mcp_tool_with_meta(
|
||||
server_name: &str,
|
||||
tool_name: &str,
|
||||
connector_id: Option<&str>,
|
||||
connector_name: Option<&str>,
|
||||
meta: Option<JsonObject>,
|
||||
) -> ToolInfo {
|
||||
let tool_namespace = if server_name == CODEX_APPS_MCP_SERVER_NAME {
|
||||
connector_name
|
||||
@@ -434,7 +444,7 @@ fn make_mcp_tool(
|
||||
annotations: None,
|
||||
execution: None,
|
||||
icons: None,
|
||||
meta: None,
|
||||
meta: meta.map(rmcp::model::Meta),
|
||||
},
|
||||
connector_id: connector_id.map(str::to_string),
|
||||
connector_name: connector_name.map(str::to_string),
|
||||
@@ -443,6 +453,18 @@ fn make_mcp_tool(
|
||||
}
|
||||
}
|
||||
|
||||
fn direct_exposed_builtin_codex_apps_meta() -> JsonObject {
|
||||
serde_json::json!({
|
||||
"_codex_apps": {
|
||||
"provider": "builtin",
|
||||
"direct_expose": true,
|
||||
},
|
||||
})
|
||||
.as_object()
|
||||
.cloned()
|
||||
.expect("tool metadata object")
|
||||
}
|
||||
|
||||
fn numbered_mcp_tools(count: usize) -> HashMap<String, ToolInfo> {
|
||||
(0..count)
|
||||
.map(|index| {
|
||||
@@ -1131,6 +1153,72 @@ async fn mcp_tool_exposure_directly_exposes_explicit_apps_without_deferred_overl
|
||||
assert!(deferred_tools.contains_key("mcp__rmcp__tool_0"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn mcp_tool_exposure_directly_exposes_builtin_codex_apps_tools_marked_for_direct_exposure() {
|
||||
let config = test_config().await;
|
||||
let tools_config = tools_config_for_mcp_tool_exposure(/*search_tool*/ true).await;
|
||||
let mcp_tools = HashMap::from([(
|
||||
"mcp__codex_apps__builtin_search_file".to_string(),
|
||||
make_mcp_tool_with_meta(
|
||||
CODEX_APPS_MCP_SERVER_NAME,
|
||||
"builtin_search_file",
|
||||
/*connector_id*/ None,
|
||||
/*connector_name*/ None,
|
||||
Some(direct_exposed_builtin_codex_apps_meta()),
|
||||
),
|
||||
)]);
|
||||
|
||||
let exposure = build_mcp_tool_exposure(
|
||||
&mcp_tools,
|
||||
/*connectors*/ None,
|
||||
&[],
|
||||
&config,
|
||||
&tools_config,
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
exposure.direct_tools.into_keys().collect::<Vec<_>>(),
|
||||
vec!["mcp__codex_apps__builtin_search_file".to_string()]
|
||||
);
|
||||
assert!(exposure.deferred_tools.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn mcp_tool_exposure_keeps_direct_exposed_builtin_codex_apps_tools_out_of_deferred_sets() {
|
||||
let config = test_config().await;
|
||||
let tools_config = tools_config_for_mcp_tool_exposure(/*search_tool*/ true).await;
|
||||
let mut mcp_tools = numbered_mcp_tools(DIRECT_MCP_TOOL_EXPOSURE_THRESHOLD);
|
||||
mcp_tools.insert(
|
||||
"mcp__codex_apps__builtin_search_file".to_string(),
|
||||
make_mcp_tool_with_meta(
|
||||
CODEX_APPS_MCP_SERVER_NAME,
|
||||
"builtin_search_file",
|
||||
/*connector_id*/ None,
|
||||
/*connector_name*/ None,
|
||||
Some(direct_exposed_builtin_codex_apps_meta()),
|
||||
),
|
||||
);
|
||||
|
||||
let exposure = build_mcp_tool_exposure(
|
||||
&mcp_tools,
|
||||
/*connectors*/ None,
|
||||
&[],
|
||||
&config,
|
||||
&tools_config,
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
exposure.direct_tools.into_keys().collect::<Vec<_>>(),
|
||||
vec!["mcp__codex_apps__builtin_search_file".to_string()]
|
||||
);
|
||||
let deferred_tools = exposure
|
||||
.deferred_tools
|
||||
.as_ref()
|
||||
.expect("large tool sets should be discoverable through tool_search");
|
||||
assert!(!deferred_tools.contains_key("mcp__codex_apps__builtin_search_file"));
|
||||
assert!(deferred_tools.contains_key("mcp__rmcp__tool_0"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn reconstruct_history_matches_live_compactions() {
|
||||
let (session, turn_context) = make_session_and_context().await;
|
||||
|
||||
@@ -21,6 +21,8 @@ mod compact_remote;
|
||||
pub use codex_thread::CodexThread;
|
||||
pub use codex_thread::ThreadConfigSnapshot;
|
||||
mod agent;
|
||||
mod codex_apps_file_download;
|
||||
mod codex_apps_mcp_tools;
|
||||
mod codex_delegate;
|
||||
mod command_canonicalization;
|
||||
mod commit_attribution;
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
use crate::codex::Session;
|
||||
use crate::codex::TurnContext;
|
||||
use codex_api::CoreAuthProvider;
|
||||
use codex_api::OpenAiFileUploadOptions;
|
||||
use codex_api::upload_local_file;
|
||||
use codex_login::CodexAuth;
|
||||
use serde_json::Value as JsonValue;
|
||||
@@ -22,6 +23,7 @@ pub(crate) async fn rewrite_mcp_tool_arguments_for_openai_files(
|
||||
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);
|
||||
@@ -40,9 +42,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;
|
||||
};
|
||||
@@ -61,6 +68,7 @@ async fn rewrite_argument_value_for_openai_files(
|
||||
auth: Option<&CodexAuth>,
|
||||
field_name: &str,
|
||||
value: &JsonValue,
|
||||
upload_options: Option<&OpenAiFileUploadOptions>,
|
||||
) -> Result<Option<JsonValue>, String> {
|
||||
match value {
|
||||
JsonValue::String(path_or_file_ref) => {
|
||||
@@ -70,6 +78,7 @@ async fn rewrite_argument_value_for_openai_files(
|
||||
field_name,
|
||||
/*index*/ None,
|
||||
path_or_file_ref,
|
||||
upload_options,
|
||||
)
|
||||
.await?;
|
||||
Ok(Some(rewritten))
|
||||
@@ -86,6 +95,7 @@ async fn rewrite_argument_value_for_openai_files(
|
||||
field_name,
|
||||
Some(index),
|
||||
path_or_file_ref,
|
||||
upload_options,
|
||||
)
|
||||
.await?;
|
||||
rewritten_values.push(rewritten);
|
||||
@@ -102,6 +112,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 {
|
||||
@@ -116,10 +127,12 @@ async fn build_uploaded_local_argument_value(
|
||||
token: Some(token_data.access_token),
|
||||
account_id: token_data.account_id,
|
||||
};
|
||||
let default_upload_options = OpenAiFileUploadOptions::default();
|
||||
let uploaded = upload_local_file(
|
||||
turn_context.config.chatgpt_base_url.trim_end_matches('/'),
|
||||
&upload_auth,
|
||||
&resolved_path,
|
||||
upload_options.unwrap_or(&default_upload_options),
|
||||
)
|
||||
.await
|
||||
.map_err(|error| match index {
|
||||
@@ -159,6 +172,7 @@ mod tests {
|
||||
&Arc::new(turn_context),
|
||||
arguments.clone(),
|
||||
/*openai_file_input_params*/ None,
|
||||
/*upload_options*/ None,
|
||||
)
|
||||
.await
|
||||
.expect("rewrite should succeed");
|
||||
@@ -230,6 +244,7 @@ mod tests {
|
||||
"file",
|
||||
/*index*/ None,
|
||||
"file_report.csv",
|
||||
/*upload_options*/ None,
|
||||
)
|
||||
.await
|
||||
.expect("rewrite should upload the local file");
|
||||
@@ -247,6 +262,92 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn build_uploaded_local_argument_value_honors_upload_options() {
|
||||
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": "library.txt",
|
||||
"file_size": 7,
|
||||
"use_case": "codex",
|
||||
"store_in_library": true,
|
||||
})))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
|
||||
"file_id": "file_library",
|
||||
"upload_url": format!("{}/upload/file_library", server.uri()),
|
||||
})))
|
||||
.expect(1)
|
||||
.mount(&server)
|
||||
.await;
|
||||
Mock::given(method("PUT"))
|
||||
.and(path("/upload/file_library"))
|
||||
.respond_with(ResponseTemplate::new(200))
|
||||
.expect(1)
|
||||
.mount(&server)
|
||||
.await;
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/backend-api/files/file_library/uploaded"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
|
||||
"status": "success",
|
||||
"download_url": format!("{}/download/file_library", server.uri()),
|
||||
"file_name": "library.txt",
|
||||
"mime_type": "text/plain",
|
||||
"file_size_bytes": 7,
|
||||
})))
|
||||
.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");
|
||||
let local_path = dir.path().join("library.txt");
|
||||
tokio::fs::write(&local_path, b"library")
|
||||
.await
|
||||
.expect("write local file");
|
||||
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 upload_options = OpenAiFileUploadOptions {
|
||||
store_in_library: true,
|
||||
};
|
||||
let rewritten = build_uploaded_local_argument_value(
|
||||
&turn_context,
|
||||
Some(&auth),
|
||||
"file",
|
||||
/*index*/ None,
|
||||
"library.txt",
|
||||
Some(&upload_options),
|
||||
)
|
||||
.await
|
||||
.expect("rewrite should upload the local file");
|
||||
|
||||
assert_eq!(
|
||||
rewritten,
|
||||
serde_json::json!({
|
||||
"download_url": format!("{}/download/file_library", server.uri()),
|
||||
"file_id": "file_library",
|
||||
"mime_type": "text/plain",
|
||||
"file_name": "library.txt",
|
||||
"uri": "sediment://file_library",
|
||||
"file_size_bytes": 7,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn rewrite_argument_value_for_openai_files_rewrites_scalar_path() {
|
||||
use wiremock::Mock;
|
||||
@@ -309,6 +410,7 @@ mod tests {
|
||||
Some(&auth),
|
||||
"file",
|
||||
&serde_json::json!("file_report.csv"),
|
||||
/*upload_options*/ None,
|
||||
)
|
||||
.await
|
||||
.expect("rewrite should succeed");
|
||||
@@ -423,6 +525,7 @@ mod tests {
|
||||
Some(&auth),
|
||||
"files",
|
||||
&serde_json::json!(["one.csv", "two.csv"]),
|
||||
/*upload_options*/ None,
|
||||
)
|
||||
.await
|
||||
.expect("rewrite should succeed");
|
||||
@@ -463,6 +566,7 @@ mod tests {
|
||||
"file": "/definitely/missing/file.csv",
|
||||
})),
|
||||
Some(&["file".to_string()]),
|
||||
/*upload_options*/ None,
|
||||
)
|
||||
.await
|
||||
.expect_err("missing file should fail");
|
||||
|
||||
@@ -14,6 +14,8 @@ use crate::arc_monitor::ArcMonitorOutcome;
|
||||
use crate::arc_monitor::monitor_action;
|
||||
use crate::codex::Session;
|
||||
use crate::codex::TurnContext;
|
||||
use crate::codex_apps_file_download::maybe_materialize_codex_apps_file_download_result;
|
||||
use crate::codex_apps_mcp_tools::CODEX_APPS_META_KEY;
|
||||
use crate::config::Config;
|
||||
use crate::config::edit::ConfigEdit;
|
||||
use crate::config::edit::ConfigEditsBuilder;
|
||||
@@ -33,6 +35,7 @@ use crate::mcp_tool_approval_templates::render_mcp_tool_approval_template;
|
||||
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_mcp::CODEX_APPS_MCP_SERVER_NAME;
|
||||
@@ -465,6 +468,7 @@ async fn execute_mcp_tool_call(
|
||||
turn_context,
|
||||
arguments_value,
|
||||
metadata.and_then(|metadata| metadata.openai_file_input_params.as_deref()),
|
||||
metadata.and_then(|metadata| metadata.openai_file_upload_options.as_ref()),
|
||||
)
|
||||
.await?;
|
||||
let request_meta =
|
||||
@@ -475,6 +479,14 @@ async fn execute_mcp_tool_call(
|
||||
.call_tool(server, tool_name, rewritten_arguments, request_meta)
|
||||
.await
|
||||
.map_err(|e| format!("tool call error: {e:?}"))?;
|
||||
let result = maybe_materialize_codex_apps_file_download_result(
|
||||
sess,
|
||||
turn_context,
|
||||
server,
|
||||
metadata.and_then(|metadata| metadata.codex_apps_meta.as_ref()),
|
||||
result,
|
||||
)
|
||||
.await;
|
||||
sanitize_mcp_tool_result_for_model(
|
||||
turn_context
|
||||
.model_info
|
||||
@@ -644,9 +656,33 @@ pub(crate) struct McpToolApprovalMetadata {
|
||||
tool_description: 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 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 custom_mcp_tool_approval_mode(
|
||||
turn_context: &TurnContext,
|
||||
@@ -688,7 +724,7 @@ fn build_mcp_tool_call_request_meta(
|
||||
metadata.and_then(|metadata| metadata.codex_apps_meta.clone())
|
||||
{
|
||||
request_meta.insert(
|
||||
MCP_TOOL_CODEX_APPS_META_KEY.to_string(),
|
||||
CODEX_APPS_META_KEY.to_string(),
|
||||
serde_json::Value::Object(codex_apps_meta),
|
||||
);
|
||||
}
|
||||
@@ -1104,13 +1140,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(),
|
||||
openai_file_input_params: Some(declared_openai_file_input_param_names(
|
||||
tool_info.tool.meta.as_deref(),
|
||||
))
|
||||
.filter(|params| !params.is_empty()),
|
||||
openai_file_upload_options: parse_openai_file_upload_options(
|
||||
tool_info.tool.meta.as_deref(),
|
||||
),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -61,6 +61,7 @@ fn approval_metadata(
|
||||
tool_description: tool_description.map(str::to_string),
|
||||
codex_apps_meta: None,
|
||||
openai_file_input_params: None,
|
||||
openai_file_upload_options: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -74,6 +75,24 @@ fn prompt_options(
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_openai_file_upload_options_reads_store_in_library_config() {
|
||||
let meta = serde_json::json!({
|
||||
"openai/fileUploadConfig": {
|
||||
"store_in_library": true,
|
||||
}
|
||||
});
|
||||
|
||||
let parsed = parse_openai_file_upload_options(meta.as_object());
|
||||
|
||||
assert_eq!(
|
||||
parsed,
|
||||
Some(codex_api::OpenAiFileUploadOptions {
|
||||
store_in_library: true,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn approval_required_when_read_only_false_and_destructive() {
|
||||
let annotations = annotations(Some(false), Some(true), /*open_world*/ None);
|
||||
@@ -600,6 +619,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!(
|
||||
@@ -610,7 +630,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: {
|
||||
crate::codex_apps_mcp_tools::CODEX_APPS_META_KEY: {
|
||||
"resource_uri": "connector://calendar/tools/calendar_create_event",
|
||||
"contains_mcp_source": true,
|
||||
"connector_id": "calendar",
|
||||
@@ -748,6 +768,7 @@ fn guardian_mcp_review_request_includes_annotations_when_present() {
|
||||
tool_description: 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));
|
||||
@@ -1274,6 +1295,7 @@ async fn approve_mode_skips_when_annotations_do_not_require_approval() {
|
||||
tool_description: None,
|
||||
codex_apps_meta: None,
|
||||
openai_file_input_params: None,
|
||||
openai_file_upload_options: None,
|
||||
};
|
||||
|
||||
let decision = maybe_request_mcp_tool_approval(
|
||||
@@ -1342,6 +1364,7 @@ async fn guardian_mode_skips_auto_when_annotations_do_not_require_approval() {
|
||||
tool_description: None,
|
||||
codex_apps_meta: None,
|
||||
openai_file_input_params: None,
|
||||
openai_file_upload_options: None,
|
||||
};
|
||||
|
||||
let decision = maybe_request_mcp_tool_approval(
|
||||
@@ -1413,6 +1436,7 @@ async fn guardian_mode_mcp_denial_returns_rationale_message() {
|
||||
tool_description: Some("Reads calendar data.".to_string()),
|
||||
codex_apps_meta: None,
|
||||
openai_file_input_params: None,
|
||||
openai_file_upload_options: None,
|
||||
};
|
||||
|
||||
let decision = maybe_request_mcp_tool_approval(
|
||||
@@ -1464,6 +1488,7 @@ async fn prompt_mode_waits_for_approval_when_annotations_do_not_require_approval
|
||||
tool_description: None,
|
||||
codex_apps_meta: None,
|
||||
openai_file_input_params: None,
|
||||
openai_file_upload_options: None,
|
||||
};
|
||||
|
||||
let mut approval_task = {
|
||||
@@ -1541,6 +1566,7 @@ async fn approve_mode_blocks_when_arc_returns_interrupt_for_model() {
|
||||
tool_description: Some("Performs a risky action.".to_string()),
|
||||
codex_apps_meta: None,
|
||||
openai_file_input_params: None,
|
||||
openai_file_upload_options: None,
|
||||
};
|
||||
|
||||
let decision = maybe_request_mcp_tool_approval(
|
||||
@@ -1611,6 +1637,7 @@ async fn custom_approve_mode_blocks_when_arc_returns_interrupt_for_model() {
|
||||
tool_description: Some("Performs a risky action.".to_string()),
|
||||
codex_apps_meta: None,
|
||||
openai_file_input_params: None,
|
||||
openai_file_upload_options: None,
|
||||
};
|
||||
|
||||
let decision = maybe_request_mcp_tool_approval(
|
||||
@@ -1681,6 +1708,7 @@ async fn approve_mode_blocks_when_arc_returns_interrupt_without_annotations() {
|
||||
tool_description: Some("Performs a risky action.".to_string()),
|
||||
codex_apps_meta: None,
|
||||
openai_file_input_params: None,
|
||||
openai_file_upload_options: None,
|
||||
};
|
||||
|
||||
let decision = maybe_request_mcp_tool_approval(
|
||||
@@ -1759,6 +1787,7 @@ async fn full_access_mode_skips_arc_monitor_for_all_approval_modes() {
|
||||
tool_description: Some("Performs a risky action.".to_string()),
|
||||
codex_apps_meta: None,
|
||||
openai_file_input_params: None,
|
||||
openai_file_upload_options: None,
|
||||
};
|
||||
|
||||
for approval_mode in [
|
||||
@@ -1861,6 +1890,7 @@ async fn approve_mode_routes_arc_ask_user_to_guardian_when_guardian_reviewer_is_
|
||||
tool_description: Some("Performs a risky action.".to_string()),
|
||||
codex_apps_meta: None,
|
||||
openai_file_input_params: None,
|
||||
openai_file_upload_options: None,
|
||||
};
|
||||
|
||||
let decision = maybe_request_mcp_tool_approval(
|
||||
|
||||
@@ -6,6 +6,7 @@ use codex_mcp::ToolInfo as McpToolInfo;
|
||||
use codex_mcp::filter_non_codex_apps_mcp_tools_only;
|
||||
use codex_tools::ToolsConfig;
|
||||
|
||||
use crate::codex_apps_mcp_tools::is_direct_exposed_codex_apps_builtin_tool_info;
|
||||
use crate::config::Config;
|
||||
use crate::connectors;
|
||||
|
||||
@@ -24,6 +25,9 @@ pub(crate) fn build_mcp_tool_exposure(
|
||||
tools_config: &ToolsConfig,
|
||||
) -> McpToolExposure {
|
||||
let mut deferred_tools = filter_non_codex_apps_mcp_tools_only(all_mcp_tools);
|
||||
deferred_tools.extend(filter_direct_exposed_builtin_codex_apps_tools(
|
||||
all_mcp_tools,
|
||||
));
|
||||
if let Some(connectors) = connectors {
|
||||
deferred_tools.extend(filter_codex_apps_mcp_tools(
|
||||
all_mcp_tools,
|
||||
@@ -45,12 +49,31 @@ pub(crate) fn build_mcp_tool_exposure(
|
||||
deferred_tools.remove(direct_tool_name);
|
||||
}
|
||||
|
||||
let mut direct_tools = filter_direct_exposed_builtin_codex_apps_tools(all_mcp_tools);
|
||||
direct_tools.extend(filter_codex_apps_mcp_tools(
|
||||
all_mcp_tools,
|
||||
explicitly_enabled_connectors,
|
||||
config,
|
||||
));
|
||||
for direct_tool_name in direct_tools.keys() {
|
||||
deferred_tools.remove(direct_tool_name);
|
||||
}
|
||||
McpToolExposure {
|
||||
direct_tools,
|
||||
deferred_tools: (!deferred_tools.is_empty()).then_some(deferred_tools),
|
||||
}
|
||||
}
|
||||
|
||||
fn filter_direct_exposed_builtin_codex_apps_tools(
|
||||
mcp_tools: &HashMap<String, McpToolInfo>,
|
||||
) -> HashMap<String, McpToolInfo> {
|
||||
mcp_tools
|
||||
.iter()
|
||||
.filter(|(_, tool)| is_direct_exposed_codex_apps_builtin_tool_info(tool))
|
||||
.map(|(name, tool)| (name.clone(), tool.clone()))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn filter_codex_apps_mcp_tools(
|
||||
mcp_tools: &HashMap<String, McpToolInfo>,
|
||||
connectors: &[connectors::AppInfo],
|
||||
|
||||
@@ -91,7 +91,6 @@ impl CodeModeService {
|
||||
&self,
|
||||
session: &Arc<Session>,
|
||||
turn: &Arc<TurnContext>,
|
||||
router: Arc<ToolRouter>,
|
||||
tracker: SharedTurnDiffTracker,
|
||||
) -> Option<codex_code_mode::CodeModeTurnWorker> {
|
||||
if !turn.features.enabled(Feature::CodeMode) {
|
||||
@@ -102,8 +101,13 @@ impl CodeModeService {
|
||||
session: Arc::clone(session),
|
||||
turn: Arc::clone(turn),
|
||||
};
|
||||
let tool_runtime =
|
||||
ToolCallRuntime::new(router, Arc::clone(session), Arc::clone(turn), tracker);
|
||||
let nested_router = Arc::new(build_nested_router(&exec).await);
|
||||
let tool_runtime = ToolCallRuntime::new(
|
||||
nested_router,
|
||||
Arc::clone(session),
|
||||
Arc::clone(turn),
|
||||
tracker,
|
||||
);
|
||||
let host = Arc::new(CoreTurnHost { exec, tool_runtime });
|
||||
Some(self.inner.start_turn_worker(host))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user