Merge branch 'main' into alexs/tui-migrate

This commit is contained in:
alexsong-oai
2026-04-15 15:07:12 -07:00
54 changed files with 3042 additions and 712 deletions
+10
View File
@@ -2255,6 +2255,15 @@ dependencies = [
"tokio",
]
[[package]]
name = "codex-install-context"
version = "0.0.0"
dependencies = [
"codex-utils-home-dir",
"pretty_assertions",
"tempfile",
]
[[package]]
name = "codex-instructions"
version = "0.0.0"
@@ -2896,6 +2905,7 @@ dependencies = [
"codex-feedback",
"codex-file-search",
"codex-git-utils",
"codex-install-context",
"codex-login",
"codex-mcp",
"codex-model-provider-info",
+2
View File
@@ -13,6 +13,7 @@ members = [
"arg0",
"feedback",
"features",
"install-context",
"codex-backend-openapi-models",
"code-mode",
"cloud-requirements",
@@ -134,6 +135,7 @@ codex-execpolicy = { path = "execpolicy" }
codex-experimental-api-macros = { path = "codex-experimental-api-macros" }
codex-features = { path = "features" }
codex-feedback = { path = "feedback" }
codex-install-context = { path = "install-context" }
codex-file-search = { path = "file-search" }
codex-git-utils = { path = "git-utils" }
codex-hooks = { path = "hooks" }
+1 -1
View File
@@ -1,6 +1,6 @@
# Codex CLI (Rust Implementation)
We provide Codex CLI as a standalone, native executable to ensure a zero-dependency install.
We provide Codex CLI as a standalone executable to ensure a zero-dependency install.
## Installing Codex
-3
View File
@@ -3,9 +3,6 @@ load("//:defs.bzl", "codex_rust_crate")
codex_rust_crate(
name = "app-server",
crate_name = "codex_app_server",
integration_test_tags_extra_by_stem = {
"all": ["flaky"],
},
integration_test_timeout = "long",
test_tags = ["no-sandbox"],
)
+13 -4
View File
@@ -516,10 +516,19 @@ fn run_update_action(action: UpdateAction) -> anyhow::Result<()> {
let status = {
#[cfg(windows)]
{
// On Windows, run via cmd.exe so .CMD/.BAT are correctly resolved (PATHEXT semantics).
std::process::Command::new("cmd")
.args(["/C", &cmd_str])
.status()?
if action == UpdateAction::StandaloneWindows {
let (cmd, args) = action.command_args();
// Run the standalone PowerShell installer with PowerShell
// itself. Routing this through `cmd.exe /C` would parse
// PowerShell metacharacters like `|` before PowerShell sees
// the installer command.
std::process::Command::new(cmd).args(args).status()?
} else {
// On Windows, run via cmd.exe so .CMD/.BAT are correctly resolved (PATHEXT semantics).
std::process::Command::new("cmd")
.args(["/C", &cmd_str])
.status()?
}
}
#[cfg(not(windows))]
{
+1 -1
View File
@@ -24,7 +24,7 @@ const EXEC_DESCRIPTION_TEMPLATE: &str = r#"Run JavaScript code to orchestrate/co
- Global helpers:
- `exit()`: Immediately ends the current script successfully (like an early return from the top level).
- `text(value: string | number | boolean | undefined | null)`: Appends a text item. Non-string values are stringified with `JSON.stringify(...)` when possible.
- `image(imageUrlOrItem: string | { image_url: string; detail?: "auto" | "low" | "high" | "original" | null })`: Appends an image item. `image_url` can be an HTTPS URL or a base64-encoded `data:` URL.
- `image(imageUrlOrItem: string | { image_url: string; detail?: "auto" | "low" | "high" | "original" | null } | ImageContent, detail?: "auto" | "low" | "high" | "original" | null)`: Appends an image item. `image_url` can be an HTTPS URL or a base64-encoded `data:` URL. To forward an MCP tool image, pass an individual `ImageContent` block from `result.content`, for example `image(result.content[0])`. MCP image blocks may request original detail with `_meta: { "codex/imageDetail": "original" }`. When provided, the second `detail` argument overrides any detail embedded in the first argument.
- `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(...)`.
+14 -1
View File
@@ -109,7 +109,20 @@ pub(super) fn image_callback(
} else {
args.get(0)
};
let image_item = match normalize_output_image(scope, value) {
let detail_override = if args.length() < 2 {
None
} else {
let detail = args.get(1);
if detail.is_string() {
Some(detail.to_rust_string_lossy(scope))
} else if detail.is_null() || detail.is_undefined() {
None
} else {
throw_type_error(scope, "image detail must be a string when provided");
return;
}
};
let image_item = match normalize_output_image(scope, value, detail_override) {
Ok(image_item) => image_item,
Err(()) => return,
};
+94 -30
View File
@@ -3,6 +3,9 @@ use serde_json::Value as JsonValue;
use crate::response::FunctionCallOutputContentItem;
use crate::response::ImageDetail;
const IMAGE_HELPER_EXPECTS_MESSAGE: &str = "image expects a non-empty image URL string, an object with image_url and optional detail, or a raw MCP image block";
const CODEX_IMAGE_DETAIL_META_KEY: &str = "codex/imageDetail";
pub(super) fn serialize_output_text(
scope: &mut v8::PinScope<'_, '_>,
value: v8::Local<'_, v8::Value>,
@@ -34,45 +37,25 @@ pub(super) fn serialize_output_text(
pub(super) fn normalize_output_image(
scope: &mut v8::PinScope<'_, '_>,
value: v8::Local<'_, v8::Value>,
detail_override: Option<String>,
) -> Result<FunctionCallOutputContentItem, ()> {
let result = (|| -> Result<FunctionCallOutputContentItem, String> {
let (image_url, detail) = if value.is_string() {
(value.to_rust_string_lossy(scope), None)
} else if value.is_object() && !value.is_array() {
let object = v8::Local::<v8::Object>::try_from(value).map_err(|_| {
"image expects a non-empty image URL string or an object with image_url and optional detail".to_string()
})?;
let image_url_key = v8::String::new(scope, "image_url")
.ok_or_else(|| "failed to allocate image helper keys".to_string())?;
let detail_key = v8::String::new(scope, "detail")
.ok_or_else(|| "failed to allocate image helper keys".to_string())?;
let image_url = object
.get(scope, image_url_key.into())
.filter(|value| value.is_string())
.map(|value| value.to_rust_string_lossy(scope))
.ok_or_else(|| {
"image expects a non-empty image URL string or an object with image_url and optional detail"
.to_string()
})?;
let detail = match object.get(scope, detail_key.into()) {
Some(value) if value.is_string() => Some(value.to_rust_string_lossy(scope)),
Some(value) if value.is_null() || value.is_undefined() => None,
Some(_) => return Err("image detail must be a string when provided".to_string()),
None => None,
};
(image_url, detail)
let object = v8::Local::<v8::Object>::try_from(value)
.map_err(|_| IMAGE_HELPER_EXPECTS_MESSAGE.to_string())?;
if let Some(image) = parse_non_mcp_output_image(scope, object)? {
image
} else {
parse_mcp_output_image(scope, value)?
}
} else {
return Err(
"image expects a non-empty image URL string or an object with image_url and optional detail"
.to_string(),
);
return Err(IMAGE_HELPER_EXPECTS_MESSAGE.to_string());
};
if image_url.is_empty() {
return Err(
"image expects a non-empty image URL string or an object with image_url and optional detail"
.to_string(),
);
return Err(IMAGE_HELPER_EXPECTS_MESSAGE.to_string());
}
let lower = image_url.to_ascii_lowercase();
if !(lower.starts_with("http://")
@@ -82,6 +65,7 @@ pub(super) fn normalize_output_image(
return Err("image expects an http(s) or data URL".to_string());
}
let detail = detail_override.or(detail);
let detail = match detail {
Some(detail) => {
let normalized = detail.to_ascii_lowercase();
@@ -112,6 +96,86 @@ pub(super) fn normalize_output_image(
}
}
fn parse_non_mcp_output_image(
scope: &mut v8::PinScope<'_, '_>,
object: v8::Local<'_, v8::Object>,
) -> Result<Option<(String, Option<String>)>, String> {
let image_url_key = v8::String::new(scope, "image_url")
.ok_or_else(|| "failed to allocate image helper keys".to_string())?;
let Some(image_url) = object.get(scope, image_url_key.into()) else {
return Ok(None);
};
if image_url.is_undefined() {
return Ok(None);
}
if !image_url.is_string() {
return Err(IMAGE_HELPER_EXPECTS_MESSAGE.to_string());
}
let detail_key = v8::String::new(scope, "detail")
.ok_or_else(|| "failed to allocate image helper keys".to_string())?;
let detail = parse_image_detail_value(scope, object.get(scope, detail_key.into()))?;
Ok(Some((image_url.to_rust_string_lossy(scope), detail)))
}
fn parse_mcp_output_image(
scope: &mut v8::PinScope<'_, '_>,
value: v8::Local<'_, v8::Value>,
) -> Result<(String, Option<String>), String> {
let Some(result) = v8_value_to_json(scope, value)? else {
return Err(IMAGE_HELPER_EXPECTS_MESSAGE.to_string());
};
let JsonValue::Object(result) = result else {
return Err(IMAGE_HELPER_EXPECTS_MESSAGE.to_string());
};
let Some(item_type) = result.get("type").and_then(JsonValue::as_str) else {
return Err(IMAGE_HELPER_EXPECTS_MESSAGE.to_string());
};
if item_type != "image" {
return Err(format!(
"image only accepts MCP image blocks, got \"{item_type}\""
));
}
let data = result
.get("data")
.and_then(JsonValue::as_str)
.ok_or_else(|| "image expected MCP image data".to_string())?;
if data.is_empty() {
return Err("image expected MCP image data".to_string());
}
let image_url = if data.to_ascii_lowercase().starts_with("data:") {
data.to_string()
} else {
let mime_type = result
.get("mimeType")
.or_else(|| result.get("mime_type"))
.and_then(JsonValue::as_str)
.filter(|mime_type| !mime_type.is_empty())
.unwrap_or("application/octet-stream");
format!("data:{mime_type};base64,{data}")
};
let detail = result
.get("_meta")
.and_then(JsonValue::as_object)
.and_then(|meta| meta.get(CODEX_IMAGE_DETAIL_META_KEY))
.and_then(JsonValue::as_str)
.filter(|detail| *detail == "original")
.map(str::to_string);
Ok((image_url, detail))
}
fn parse_image_detail_value<'s>(
scope: &mut v8::PinScope<'s, '_>,
value: Option<v8::Local<'s, v8::Value>>,
) -> Result<Option<String>, String> {
match value {
Some(value) if value.is_string() => Ok(Some(value.to_rust_string_lossy(scope))),
Some(value) if value.is_null() || value.is_undefined() => Ok(None),
Some(_) => Err("image detail must be a string when provided".to_string()),
None => Ok(None),
}
}
pub(super) fn v8_value_to_json(
scope: &mut v8::PinScope<'_, '_>,
value: v8::Local<'_, v8::Value>,
+148
View File
@@ -684,6 +684,154 @@ text(JSON.stringify(returnsUndefined));
);
}
#[tokio::test]
async fn image_helper_accepts_raw_mcp_image_block_with_original_detail() {
let service = CodeModeService::new();
let response = service
.execute(ExecuteRequest {
source: r#"
image({
type: "image",
data: "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4z8DwHwAFAAH/iZk9HQAAAABJRU5ErkJggg==",
mimeType: "image/png",
_meta: { "codex/imageDetail": "original" },
});
"#
.to_string(),
yield_time_ms: None,
..execute_request("")
})
.await
.unwrap();
assert_eq!(
response,
RuntimeResponse::Result {
cell_id: "1".to_string(),
content_items: vec![FunctionCallOutputContentItem::InputImage {
image_url: "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4z8DwHwAFAAH/iZk9HQAAAABJRU5ErkJggg==".to_string(),
detail: Some(crate::ImageDetail::Original),
}],
stored_values: HashMap::new(),
error_text: None,
}
);
}
#[tokio::test]
async fn image_helper_second_arg_overrides_explicit_object_detail() {
let service = CodeModeService::new();
let response = service
.execute(ExecuteRequest {
source: r#"
image(
{
image_url: "https://example.com/image.jpg",
detail: "low",
},
"original",
);
"#
.to_string(),
yield_time_ms: None,
..execute_request("")
})
.await
.unwrap();
assert_eq!(
response,
RuntimeResponse::Result {
cell_id: "1".to_string(),
content_items: vec![FunctionCallOutputContentItem::InputImage {
image_url: "https://example.com/image.jpg".to_string(),
detail: Some(crate::ImageDetail::Original),
}],
stored_values: HashMap::new(),
error_text: None,
}
);
}
#[tokio::test]
async fn image_helper_second_arg_overrides_raw_mcp_image_detail() {
let service = CodeModeService::new();
let response = service
.execute(ExecuteRequest {
source: r#"
image(
{
type: "image",
data: "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4z8DwHwAFAAH/iZk9HQAAAABJRU5ErkJggg==",
mimeType: "image/png",
_meta: { "codex/imageDetail": "original" },
},
"low",
);
"#
.to_string(),
yield_time_ms: None,
..execute_request("")
})
.await
.unwrap();
assert_eq!(
response,
RuntimeResponse::Result {
cell_id: "1".to_string(),
content_items: vec![FunctionCallOutputContentItem::InputImage {
image_url: "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4z8DwHwAFAAH/iZk9HQAAAABJRU5ErkJggg==".to_string(),
detail: Some(crate::ImageDetail::Low),
}],
stored_values: HashMap::new(),
error_text: None,
}
);
}
#[tokio::test]
async fn image_helper_rejects_raw_mcp_result_container() {
let service = CodeModeService::new();
let response = service
.execute(ExecuteRequest {
source: r#"
image({
content: [
{
type: "image",
data: "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4z8DwHwAFAAH/iZk9HQAAAABJRU5ErkJggg==",
mimeType: "image/png",
_meta: { "codex/imageDetail": "original" },
},
],
isError: false,
});
"#
.to_string(),
yield_time_ms: None,
..execute_request("")
})
.await
.unwrap();
assert_eq!(
response,
RuntimeResponse::Result {
cell_id: "1".to_string(),
content_items: Vec::new(),
stored_values: HashMap::new(),
error_text: Some(
"image expects a non-empty image URL string, an object with image_url and optional detail, or a raw MCP image block".to_string(),
),
}
);
}
#[tokio::test]
async fn terminate_waits_for_runtime_shutdown_before_responding() {
let inner = test_inner();
+1 -54
View File
@@ -1,4 +1,4 @@
load("//:defs.bzl", "codex_rust_crate", "workspace_root_test")
load("//:defs.bzl", "codex_rust_crate")
filegroup(
name = "model_availability_nux_fixtures",
@@ -31,15 +31,6 @@ codex_rust_crate(
"//codex-rs/apply-patch:apply_patch_tool_instructions.md",
"templates/realtime/backend_prompt.md",
],
integration_test_flaky_by_stem = {
"all": [
"suite::abort_tasks::interrupt_tool_records_history_entries",
"suite::apply_patch_cli::apply_patch_cli_rejects_invalid_hunk_header::applypatchmodeloutput_function_expects",
],
"responses_headers": [
"responses_stream_includes_turn_metadata_header_for_git_workspace_e2e",
],
},
integration_test_timeout = "long",
test_data_extra = [
"config.schema.json",
@@ -57,12 +48,6 @@ codex_rust_crate(
"//:AGENTS.md",
],
test_tags = ["no-sandbox"],
unit_test_args = [
"--skip",
"guardian::tests::guardian_parallel_reviews_fork_from_last_committed_trunk_history",
"--skip",
"agent::control::tests::completion_watcher_notifies_parent_when_child_is_missing",
],
unit_test_timeout = "long",
extra_binaries = [
"//codex-rs/linux-sandbox:codex-linux-sandbox",
@@ -72,41 +57,3 @@ codex_rust_crate(
"//codex-rs/cli:codex",
],
)
workspace_root_test(
name = "core-guardian-parallel-reviews-flaky-test",
env = {
"INSTA_WORKSPACE_ROOT": ".",
"INSTA_SNAPSHOT_PATH": "src",
},
test_bin = ":core-unit-tests-bin",
workspace_root_marker = "//codex-rs/utils/cargo-bin:repo_root.marker",
args = [
"--exact",
"guardian::tests::guardian_parallel_reviews_fork_from_last_committed_trunk_history",
],
tags = [
"flaky",
"no-sandbox",
],
timeout = "long",
)
workspace_root_test(
name = "core-completion-watcher-missing-child-flaky-test",
env = {
"INSTA_WORKSPACE_ROOT": ".",
"INSTA_SNAPSHOT_PATH": "src",
},
test_bin = ":core-unit-tests-bin",
workspace_root_marker = "//codex-rs/utils/cargo-bin:repo_root.marker",
args = [
"--exact",
"agent::control::tests::completion_watcher_notifies_parent_when_child_is_missing",
],
tags = [
"flaky",
"no-sandbox",
],
timeout = "long",
)
+6
View File
@@ -503,6 +503,9 @@
"tui_app_server": {
"type": "boolean"
},
"unavailable_dummy_tools": {
"type": "boolean"
},
"undo": {
"type": "boolean"
},
@@ -2397,6 +2400,9 @@
"tui_app_server": {
"type": "boolean"
},
"unavailable_dummy_tools": {
"type": "boolean"
},
"undo": {
"type": "boolean"
},
+20 -3
View File
@@ -44,6 +44,7 @@ use crate::stream_events_utils::last_assistant_message_from_item;
use crate::stream_events_utils::raw_assistant_output_text_from_item;
use crate::stream_events_utils::record_completed_response_item;
use crate::turn_metadata::TurnMetadataState;
use crate::unavailable_tool::collect_unavailable_called_tools;
use crate::util::error_or_panic;
use async_channel::Receiver;
use async_channel::Sender;
@@ -7229,7 +7230,22 @@ pub(crate) async fn built_tools(
&turn_context.config,
&turn_context.tools_config,
);
let direct_mcp_tools = has_mcp_servers.then_some(mcp_tool_exposure.direct_tools);
let mcp_tools = has_mcp_servers.then_some(mcp_tool_exposure.direct_tools);
let deferred_mcp_tools = mcp_tool_exposure.deferred_tools;
let unavailable_called_tools = if turn_context
.config
.features
.enabled(Feature::UnavailableDummyTools)
{
let exposed_tool_names = mcp_tools
.iter()
.chain(deferred_mcp_tools.iter())
.flat_map(|tools| tools.keys().map(String::as_str))
.collect::<HashSet<_>>();
collect_unavailable_called_tools(input, &exposed_tool_names)
} else {
Vec::new()
};
let parallel_mcp_server_names = turn_context
.config
@@ -7246,8 +7262,9 @@ pub(crate) async fn built_tools(
Ok(Arc::new(ToolRouter::from_config(
&turn_context.tools_config,
ToolRouterParams {
mcp_tools: direct_mcp_tools,
deferred_mcp_tools: mcp_tool_exposure.deferred_tools,
mcp_tools,
deferred_mcp_tools,
unavailable_called_tools,
parallel_mcp_server_names,
discoverable_tools,
dynamic_tools: turn_context.dynamic_tools.as_slice(),
+2
View File
@@ -313,6 +313,7 @@ fn test_tool_runtime(session: Arc<Session>, turn_context: Arc<TurnContext>) -> T
crate::tools::router::ToolRouterParams {
mcp_tools: None,
deferred_mcp_tools: None,
unavailable_called_tools: Vec::new(),
parallel_mcp_server_names: HashSet::new(),
discoverable_tools: None,
dynamic_tools: turn_context.dynamic_tools.as_slice(),
@@ -5451,6 +5452,7 @@ async fn fatal_tool_error_stops_turn_and_reports_error() {
crate::tools::router::ToolRouterParams {
deferred_mcp_tools,
mcp_tools: Some(tools),
unavailable_called_tools: Vec::new(),
parallel_mcp_server_names: HashSet::new(),
discoverable_tools: None,
dynamic_tools: turn_context.dynamic_tools.as_slice(),
+1
View File
@@ -150,6 +150,7 @@ mod tools;
pub(crate) mod turn_diff_tracker;
mod turn_metadata;
mod turn_timing;
mod unavailable_tool;
pub use rollout::ARCHIVED_SESSIONS_SUBDIR;
pub use rollout::Cursor;
pub use rollout::EventPersistenceMode;
@@ -1,2 +1,3 @@
pub(crate) use codex_tools::can_request_original_image_detail;
pub(crate) use codex_tools::normalize_output_image_detail;
pub(crate) use codex_tools::sanitize_original_image_detail;
+1
View File
@@ -59,6 +59,7 @@ fn render_js_repl_instructions(config: &Config) -> Option<String> {
section.push_str("- `codex.emitImage(...)` adds one image to the outer `js_repl` function output each time you call it, so you can call it multiple times to emit multiple images. It accepts a data URL, a single `input_image` item, an object like `{ bytes, mimeType }`, or a raw tool response object with exactly one image and no text. It rejects mixed text-and-image content.\n");
section.push_str("- `codex.tool(...)` and `codex.emitImage(...)` keep stable helper identities across cells. Saved references and persisted objects can reuse them in later cells, but async callbacks that fire after a cell finishes still fail because no exec is active.\n");
section.push_str("- Request full-resolution image processing with `detail: \"original\"` only when the `view_image` tool schema includes a `detail` argument. The same availability applies to `codex.emitImage(...)`: if `view_image.detail` is present, you may also pass `detail: \"original\"` there. Use this when high-fidelity image perception or precise localization is needed, especially for CUA agents.\n");
section.push_str("- Raw MCP image blocks can request the same behavior by returning `_meta: { \"codex/imageDetail\": \"original\" }` on the image content item.\n");
section.push_str("- Example of sharing an in-memory Playwright screenshot: `await codex.emitImage({ bytes: await page.screenshot({ type: \"jpeg\", quality: 85 }), mimeType: \"image/jpeg\", detail: \"original\" })`.\n");
section.push_str("- Example of sharing a local image tool result: `await codex.emitImage(codex.tool(\"view_image\", { path: \"/absolute/path\", detail: \"original\" }))`.\n");
section.push_str("- When encoding an image to send with `codex.emitImage(...)` or `view_image`, prefer JPEG at about 85 quality when lossy compression is acceptable; use PNG when transparency or lossless detail matters. Smaller uploads are faster and less likely to hit size limits.\n");
+2 -2
View File
@@ -205,7 +205,7 @@ async fn js_repl_instructions_are_appended_when_enabled() {
let res = get_user_instructions(&cfg)
.await
.expect("js_repl instructions expected");
let expected = "## JavaScript REPL (Node)\n- Use `js_repl` for Node-backed JavaScript with top-level await in a persistent kernel.\n- `js_repl` is a freeform/custom tool. Direct `js_repl` calls must send raw JavaScript tool input (optionally with first-line `// codex-js-repl: timeout_ms=15000`). Do not wrap code in JSON (for example `{\"code\":\"...\"}`), quotes, or markdown code fences.\n- Helpers: `codex.cwd`, `codex.homeDir`, `codex.tmpDir`, `codex.tool(name, args?)`, and `codex.emitImage(imageLike)`.\n- `codex.tool` executes a normal tool call and resolves to the raw tool output object. Use it for shell and non-shell tools alike. Nested tool outputs stay inside JavaScript unless you emit them explicitly.\n- `codex.emitImage(...)` adds one image to the outer `js_repl` function output each time you call it, so you can call it multiple times to emit multiple images. It accepts a data URL, a single `input_image` item, an object like `{ bytes, mimeType }`, or a raw tool response object with exactly one image and no text. It rejects mixed text-and-image content.\n- `codex.tool(...)` and `codex.emitImage(...)` keep stable helper identities across cells. Saved references and persisted objects can reuse them in later cells, but async callbacks that fire after a cell finishes still fail because no exec is active.\n- Request full-resolution image processing with `detail: \"original\"` only when the `view_image` tool schema includes a `detail` argument. The same availability applies to `codex.emitImage(...)`: if `view_image.detail` is present, you may also pass `detail: \"original\"` there. Use this when high-fidelity image perception or precise localization is needed, especially for CUA agents.\n- Example of sharing an in-memory Playwright screenshot: `await codex.emitImage({ bytes: await page.screenshot({ type: \"jpeg\", quality: 85 }), mimeType: \"image/jpeg\", detail: \"original\" })`.\n- Example of sharing a local image tool result: `await codex.emitImage(codex.tool(\"view_image\", { path: \"/absolute/path\", detail: \"original\" }))`.\n- When encoding an image to send with `codex.emitImage(...)` or `view_image`, prefer JPEG at about 85 quality when lossy compression is acceptable; use PNG when transparency or lossless detail matters. Smaller uploads are faster and less likely to hit size limits.\n- Top-level bindings persist across cells. If a cell throws, prior bindings remain available and bindings that finished initializing before the throw often remain usable in later cells. For code you plan to reuse across cells, prefer declaring or assigning it in direct top-level statements before operations that might throw. If you hit `SyntaxError: Identifier 'x' has already been declared`, first reuse the existing binding, reassign a previously declared `let`, or pick a new descriptive name. Use `{ ... }` only for a short temporary block when you specifically need local scratch names; do not wrap an entire cell in block scope if you want those names reusable later. Reset the kernel with `js_repl_reset` only when you need a clean state.\n- Top-level static import declarations (for example `import x from \"./file.js\"`) are currently unsupported in `js_repl`; use dynamic imports with `await import(\"pkg\")`, `await import(\"./file.js\")`, or `await import(\"/abs/path/file.mjs\")` instead. Imported local files must be ESM `.js`/`.mjs` files and run in the same REPL VM context. Bare package imports always resolve from REPL-global search roots (`CODEX_JS_REPL_NODE_MODULE_DIRS`, then cwd), not relative to the imported file location. Local files may statically import only other local relative/absolute/`file://` `.js`/`.mjs` files; package and builtin imports from local files must stay dynamic. `import.meta.resolve()` returns importable strings such as `file://...`, bare package names, and `node:...` specifiers. Local file modules reload between execs, while top-level bindings persist until `js_repl_reset`.\n- Avoid direct access to `process.stdout` / `process.stderr` / `process.stdin`; it can corrupt the JSON line protocol. Use `console.log`, `codex.tool(...)`, and `codex.emitImage(...)`.";
let expected = "## JavaScript REPL (Node)\n- Use `js_repl` for Node-backed JavaScript with top-level await in a persistent kernel.\n- `js_repl` is a freeform/custom tool. Direct `js_repl` calls must send raw JavaScript tool input (optionally with first-line `// codex-js-repl: timeout_ms=15000`). Do not wrap code in JSON (for example `{\"code\":\"...\"}`), quotes, or markdown code fences.\n- Helpers: `codex.cwd`, `codex.homeDir`, `codex.tmpDir`, `codex.tool(name, args?)`, and `codex.emitImage(imageLike)`.\n- `codex.tool` executes a normal tool call and resolves to the raw tool output object. Use it for shell and non-shell tools alike. Nested tool outputs stay inside JavaScript unless you emit them explicitly.\n- `codex.emitImage(...)` adds one image to the outer `js_repl` function output each time you call it, so you can call it multiple times to emit multiple images. It accepts a data URL, a single `input_image` item, an object like `{ bytes, mimeType }`, or a raw tool response object with exactly one image and no text. It rejects mixed text-and-image content.\n- `codex.tool(...)` and `codex.emitImage(...)` keep stable helper identities across cells. Saved references and persisted objects can reuse them in later cells, but async callbacks that fire after a cell finishes still fail because no exec is active.\n- Request full-resolution image processing with `detail: \"original\"` only when the `view_image` tool schema includes a `detail` argument. The same availability applies to `codex.emitImage(...)`: if `view_image.detail` is present, you may also pass `detail: \"original\"` there. Use this when high-fidelity image perception or precise localization is needed, especially for CUA agents.\n- Raw MCP image blocks can request the same behavior by returning `_meta: { \"codex/imageDetail\": \"original\" }` on the image content item.\n- Example of sharing an in-memory Playwright screenshot: `await codex.emitImage({ bytes: await page.screenshot({ type: \"jpeg\", quality: 85 }), mimeType: \"image/jpeg\", detail: \"original\" })`.\n- Example of sharing a local image tool result: `await codex.emitImage(codex.tool(\"view_image\", { path: \"/absolute/path\", detail: \"original\" }))`.\n- When encoding an image to send with `codex.emitImage(...)` or `view_image`, prefer JPEG at about 85 quality when lossy compression is acceptable; use PNG when transparency or lossless detail matters. Smaller uploads are faster and less likely to hit size limits.\n- Top-level bindings persist across cells. If a cell throws, prior bindings remain available and bindings that finished initializing before the throw often remain usable in later cells. For code you plan to reuse across cells, prefer declaring or assigning it in direct top-level statements before operations that might throw. If you hit `SyntaxError: Identifier 'x' has already been declared`, first reuse the existing binding, reassign a previously declared `let`, or pick a new descriptive name. Use `{ ... }` only for a short temporary block when you specifically need local scratch names; do not wrap an entire cell in block scope if you want those names reusable later. Reset the kernel with `js_repl_reset` only when you need a clean state.\n- Top-level static import declarations (for example `import x from \"./file.js\"`) are currently unsupported in `js_repl`; use dynamic imports with `await import(\"pkg\")`, `await import(\"./file.js\")`, or `await import(\"/abs/path/file.mjs\")` instead. Imported local files must be ESM `.js`/`.mjs` files and run in the same REPL VM context. Bare package imports always resolve from REPL-global search roots (`CODEX_JS_REPL_NODE_MODULE_DIRS`, then cwd), not relative to the imported file location. Local files may statically import only other local relative/absolute/`file://` `.js`/`.mjs` files; package and builtin imports from local files must stay dynamic. `import.meta.resolve()` returns importable strings such as `file://...`, bare package names, and `node:...` specifiers. Local file modules reload between execs, while top-level bindings persist until `js_repl_reset`.\n- Avoid direct access to `process.stdout` / `process.stderr` / `process.stdin`; it can corrupt the JSON line protocol. Use `console.log`, `codex.tool(...)`, and `codex.emitImage(...)`.";
assert_eq!(res, expected);
}
@@ -224,7 +224,7 @@ async fn js_repl_tools_only_instructions_are_feature_gated() {
let res = get_user_instructions(&cfg)
.await
.expect("js_repl instructions expected");
let expected = "## JavaScript REPL (Node)\n- Use `js_repl` for Node-backed JavaScript with top-level await in a persistent kernel.\n- `js_repl` is a freeform/custom tool. Direct `js_repl` calls must send raw JavaScript tool input (optionally with first-line `// codex-js-repl: timeout_ms=15000`). Do not wrap code in JSON (for example `{\"code\":\"...\"}`), quotes, or markdown code fences.\n- Helpers: `codex.cwd`, `codex.homeDir`, `codex.tmpDir`, `codex.tool(name, args?)`, and `codex.emitImage(imageLike)`.\n- `codex.tool` executes a normal tool call and resolves to the raw tool output object. Use it for shell and non-shell tools alike. Nested tool outputs stay inside JavaScript unless you emit them explicitly.\n- `codex.emitImage(...)` adds one image to the outer `js_repl` function output each time you call it, so you can call it multiple times to emit multiple images. It accepts a data URL, a single `input_image` item, an object like `{ bytes, mimeType }`, or a raw tool response object with exactly one image and no text. It rejects mixed text-and-image content.\n- `codex.tool(...)` and `codex.emitImage(...)` keep stable helper identities across cells. Saved references and persisted objects can reuse them in later cells, but async callbacks that fire after a cell finishes still fail because no exec is active.\n- Request full-resolution image processing with `detail: \"original\"` only when the `view_image` tool schema includes a `detail` argument. The same availability applies to `codex.emitImage(...)`: if `view_image.detail` is present, you may also pass `detail: \"original\"` there. Use this when high-fidelity image perception or precise localization is needed, especially for CUA agents.\n- Example of sharing an in-memory Playwright screenshot: `await codex.emitImage({ bytes: await page.screenshot({ type: \"jpeg\", quality: 85 }), mimeType: \"image/jpeg\", detail: \"original\" })`.\n- Example of sharing a local image tool result: `await codex.emitImage(codex.tool(\"view_image\", { path: \"/absolute/path\", detail: \"original\" }))`.\n- When encoding an image to send with `codex.emitImage(...)` or `view_image`, prefer JPEG at about 85 quality when lossy compression is acceptable; use PNG when transparency or lossless detail matters. Smaller uploads are faster and less likely to hit size limits.\n- Top-level bindings persist across cells. If a cell throws, prior bindings remain available and bindings that finished initializing before the throw often remain usable in later cells. For code you plan to reuse across cells, prefer declaring or assigning it in direct top-level statements before operations that might throw. If you hit `SyntaxError: Identifier 'x' has already been declared`, first reuse the existing binding, reassign a previously declared `let`, or pick a new descriptive name. Use `{ ... }` only for a short temporary block when you specifically need local scratch names; do not wrap an entire cell in block scope if you want those names reusable later. Reset the kernel with `js_repl_reset` only when you need a clean state.\n- Top-level static import declarations (for example `import x from \"./file.js\"`) are currently unsupported in `js_repl`; use dynamic imports with `await import(\"pkg\")`, `await import(\"./file.js\")`, or `await import(\"/abs/path/file.mjs\")` instead. Imported local files must be ESM `.js`/`.mjs` files and run in the same REPL VM context. Bare package imports always resolve from REPL-global search roots (`CODEX_JS_REPL_NODE_MODULE_DIRS`, then cwd), not relative to the imported file location. Local files may statically import only other local relative/absolute/`file://` `.js`/`.mjs` files; package and builtin imports from local files must stay dynamic. `import.meta.resolve()` returns importable strings such as `file://...`, bare package names, and `node:...` specifiers. Local file modules reload between execs, while top-level bindings persist until `js_repl_reset`.\n- Do not call tools directly; use `js_repl` + `codex.tool(...)` for all tool calls, including shell commands.\n- MCP tools (if any) can also be called by name via `codex.tool(...)`.\n- Avoid direct access to `process.stdout` / `process.stderr` / `process.stdin`; it can corrupt the JSON line protocol. Use `console.log`, `codex.tool(...)`, and `codex.emitImage(...)`.";
let expected = "## JavaScript REPL (Node)\n- Use `js_repl` for Node-backed JavaScript with top-level await in a persistent kernel.\n- `js_repl` is a freeform/custom tool. Direct `js_repl` calls must send raw JavaScript tool input (optionally with first-line `// codex-js-repl: timeout_ms=15000`). Do not wrap code in JSON (for example `{\"code\":\"...\"}`), quotes, or markdown code fences.\n- Helpers: `codex.cwd`, `codex.homeDir`, `codex.tmpDir`, `codex.tool(name, args?)`, and `codex.emitImage(imageLike)`.\n- `codex.tool` executes a normal tool call and resolves to the raw tool output object. Use it for shell and non-shell tools alike. Nested tool outputs stay inside JavaScript unless you emit them explicitly.\n- `codex.emitImage(...)` adds one image to the outer `js_repl` function output each time you call it, so you can call it multiple times to emit multiple images. It accepts a data URL, a single `input_image` item, an object like `{ bytes, mimeType }`, or a raw tool response object with exactly one image and no text. It rejects mixed text-and-image content.\n- `codex.tool(...)` and `codex.emitImage(...)` keep stable helper identities across cells. Saved references and persisted objects can reuse them in later cells, but async callbacks that fire after a cell finishes still fail because no exec is active.\n- Request full-resolution image processing with `detail: \"original\"` only when the `view_image` tool schema includes a `detail` argument. The same availability applies to `codex.emitImage(...)`: if `view_image.detail` is present, you may also pass `detail: \"original\"` there. Use this when high-fidelity image perception or precise localization is needed, especially for CUA agents.\n- Raw MCP image blocks can request the same behavior by returning `_meta: { \"codex/imageDetail\": \"original\" }` on the image content item.\n- Example of sharing an in-memory Playwright screenshot: `await codex.emitImage({ bytes: await page.screenshot({ type: \"jpeg\", quality: 85 }), mimeType: \"image/jpeg\", detail: \"original\" })`.\n- Example of sharing a local image tool result: `await codex.emitImage(codex.tool(\"view_image\", { path: \"/absolute/path\", detail: \"original\" }))`.\n- When encoding an image to send with `codex.emitImage(...)` or `view_image`, prefer JPEG at about 85 quality when lossy compression is acceptable; use PNG when transparency or lossless detail matters. Smaller uploads are faster and less likely to hit size limits.\n- Top-level bindings persist across cells. If a cell throws, prior bindings remain available and bindings that finished initializing before the throw often remain usable in later cells. For code you plan to reuse across cells, prefer declaring or assigning it in direct top-level statements before operations that might throw. If you hit `SyntaxError: Identifier 'x' has already been declared`, first reuse the existing binding, reassign a previously declared `let`, or pick a new descriptive name. Use `{ ... }` only for a short temporary block when you specifically need local scratch names; do not wrap an entire cell in block scope if you want those names reusable later. Reset the kernel with `js_repl_reset` only when you need a clean state.\n- Top-level static import declarations (for example `import x from \"./file.js\"`) are currently unsupported in `js_repl`; use dynamic imports with `await import(\"pkg\")`, `await import(\"./file.js\")`, or `await import(\"/abs/path/file.mjs\")` instead. Imported local files must be ESM `.js`/`.mjs` files and run in the same REPL VM context. Bare package imports always resolve from REPL-global search roots (`CODEX_JS_REPL_NODE_MODULE_DIRS`, then cwd), not relative to the imported file location. Local files may statically import only other local relative/absolute/`file://` `.js`/`.mjs` files; package and builtin imports from local files must stay dynamic. `import.meta.resolve()` returns importable strings such as `file://...`, bare package names, and `node:...` specifiers. Local file modules reload between execs, while top-level bindings persist until `js_repl_reset`.\n- Do not call tools directly; use `js_repl` + `codex.tool(...)` for all tool calls, including shell commands.\n- MCP tools (if any) can also be called by name via `codex.tool(...)`.\n- Avoid direct access to `process.stdout` / `process.stderr` / `process.stdin`; it can corrupt the JSON line protocol. Use `console.log`, `codex.tool(...)`, and `codex.emitImage(...)`.";
assert_eq!(res, expected);
}
+10
View File
@@ -18,6 +18,8 @@ use tokio_util::sync::CancellationToken;
use crate::codex::Session;
use crate::codex::TurnContext;
use crate::function_tool::FunctionCallError;
use crate::original_image_detail::can_request_original_image_detail;
use crate::original_image_detail::sanitize_original_image_detail as sanitize_image_detail_items;
use crate::tools::ToolRouter;
use crate::tools::context::FunctionToolOutput;
use crate::tools::context::SharedTurnDiffTracker;
@@ -160,12 +162,14 @@ pub(super) async fn handle_runtime_response(
match response {
RuntimeResponse::Yielded { content_items, .. } => {
let mut content_items = into_function_call_output_content_items(content_items);
sanitize_runtime_image_detail(exec.turn.as_ref(), &mut content_items);
content_items = truncate_code_mode_result(content_items, max_output_tokens);
prepend_script_status(&mut content_items, &script_status, started_at.elapsed());
Ok(FunctionToolOutput::from_content(content_items, Some(true)))
}
RuntimeResponse::Terminated { content_items, .. } => {
let mut content_items = into_function_call_output_content_items(content_items);
sanitize_runtime_image_detail(exec.turn.as_ref(), &mut content_items);
content_items = truncate_code_mode_result(content_items, max_output_tokens);
prepend_script_status(&mut content_items, &script_status, started_at.elapsed());
Ok(FunctionToolOutput::from_content(content_items, Some(true)))
@@ -177,6 +181,7 @@ pub(super) async fn handle_runtime_response(
..
} => {
let mut content_items = into_function_call_output_content_items(content_items);
sanitize_runtime_image_detail(exec.turn.as_ref(), &mut content_items);
exec.session
.services
.code_mode_service
@@ -198,6 +203,10 @@ pub(super) async fn handle_runtime_response(
}
}
fn sanitize_runtime_image_detail(turn: &TurnContext, items: &mut [FunctionCallOutputContentItem]) {
sanitize_image_detail_items(can_request_original_image_detail(&turn.model_info), items);
}
fn format_script_status(response: &RuntimeResponse) -> String {
match response {
RuntimeResponse::Yielded { cell_id, .. } => {
@@ -278,6 +287,7 @@ async fn build_nested_router(exec: &ExecContext) -> ToolRouter {
ToolRouterParams {
deferred_mcp_tools: None,
mcp_tools: Some(listed_mcp_tools),
unavailable_called_tools: Vec::new(),
parallel_mcp_server_names,
discoverable_tools: None,
dynamic_tools: exec.turn.dynamic_tools.as_slice(),
+6
View File
@@ -1,5 +1,6 @@
use crate::codex::Session;
use crate::codex::TurnContext;
use crate::original_image_detail::sanitize_original_image_detail;
use crate::tools::TELEMETRY_PREVIEW_MAX_BYTES;
use crate::tools::TELEMETRY_PREVIEW_MAX_LINES;
use crate::tools::TELEMETRY_PREVIEW_TRUNCATION_NOTICE;
@@ -122,6 +123,7 @@ impl ToolOutput for CallToolResult {
pub struct McpToolOutput {
pub result: CallToolResult,
pub wall_time: Duration,
pub original_image_detail_supported: bool,
}
impl ToolOutput for McpToolOutput {
@@ -155,6 +157,10 @@ impl ToolOutput for McpToolOutput {
impl McpToolOutput {
fn response_payload(&self) -> FunctionCallOutputPayload {
let mut payload = self.result.as_function_call_output_payload();
if let Some(items) = payload.content_items_mut() {
sanitize_original_image_detail(self.original_image_detail_supported, items);
}
let wall_time_seconds = self.wall_time.as_secs_f64();
let header = format!("Wall time: {wall_time_seconds:.4} seconds\nOutput:");
+3
View File
@@ -98,6 +98,7 @@ fn mcp_tool_output_response_item_includes_wall_time() {
meta: None,
},
wall_time: std::time::Duration::from_millis(1250),
original_image_detail_supported: false,
};
let response = output.to_response_item(
@@ -149,6 +150,7 @@ fn mcp_tool_output_response_item_preserves_content_items() {
meta: None,
},
wall_time: std::time::Duration::from_millis(500),
original_image_detail_supported: false,
};
let response = output.to_response_item(
@@ -201,6 +203,7 @@ fn mcp_tool_output_code_mode_result_stays_raw_call_tool_result() {
meta: None,
},
wall_time: std::time::Duration::from_millis(1250),
original_image_detail_supported: false,
};
let result = output.code_mode_result(&ToolPayload::Mcp {
+2
View File
@@ -3,6 +3,7 @@ use std::time::Instant;
use crate::function_tool::FunctionCallError;
use crate::mcp_tool_call::handle_mcp_tool_call;
use crate::original_image_detail::can_request_original_image_detail;
use crate::tools::context::McpToolOutput;
use crate::tools::context::ToolInvocation;
use crate::tools::context::ToolPayload;
@@ -56,6 +57,7 @@ impl ToolHandler for McpHandler {
Ok(McpToolOutput {
result,
wall_time: started.elapsed(),
original_image_detail_supported: can_request_original_image_detail(&turn.model_info),
})
}
}
+3
View File
@@ -15,6 +15,7 @@ mod shell;
mod test_sync;
mod tool_search;
mod tool_suggest;
mod unavailable_tool;
pub(crate) mod unified_exec;
mod view_image;
@@ -49,6 +50,8 @@ pub use shell::ShellHandler;
pub use test_sync::TestSyncHandler;
pub use tool_search::ToolSearchHandler;
pub use tool_suggest::ToolSuggestHandler;
pub use unavailable_tool::UnavailableToolHandler;
pub(crate) use unavailable_tool::unavailable_tool_message;
pub use unified_exec::UnifiedExecHandler;
pub use view_image::ViewImageHandler;
@@ -0,0 +1,44 @@
use crate::function_tool::FunctionCallError;
use crate::tools::context::FunctionToolOutput;
use crate::tools::context::ToolInvocation;
use crate::tools::context::ToolPayload;
use crate::tools::registry::ToolHandler;
use crate::tools::registry::ToolKind;
pub struct UnavailableToolHandler;
pub(crate) fn unavailable_tool_message(
tool_name: impl std::fmt::Display,
next_step: &str,
) -> String {
format!(
"Tool `{tool_name}` is not currently available. It appeared in earlier tool calls in this conversation, but its implementation is not available in the current request. {next_step}"
)
}
impl ToolHandler for UnavailableToolHandler {
type Output = FunctionToolOutput;
fn kind(&self) -> ToolKind {
ToolKind::Function
}
async fn handle(&self, invocation: ToolInvocation) -> Result<Self::Output, FunctionCallError> {
let ToolInvocation {
tool_name, payload, ..
} = invocation;
match payload {
ToolPayload::Function { .. } => Ok(FunctionToolOutput::from_text(
unavailable_tool_message(
tool_name.display(),
"Retry after the tool becomes available or ask the user to re-enable it.",
),
Some(false),
)),
_ => Err(FunctionCallError::RespondToModel(
"unavailable tool handler received unsupported payload".to_string(),
)),
}
}
}
@@ -1330,6 +1330,13 @@ function normalizeMcpImageData(data, mimeType) {
return `data:${normalizedMimeType};base64,${data}`;
}
function parseMcpImageDetail(meta) {
if (!isPlainObject(meta) || meta["codex/imageDetail"] !== "original") {
return undefined;
}
return "original";
}
function parseMcpToolResult(result) {
if (typeof result === "string") {
return { images: [], textCount: result.length > 0 ? 1 : 0 };
@@ -1362,6 +1369,7 @@ function parseMcpToolResult(result) {
if (item.type === "image") {
images.push({
image_url: normalizeMcpImageData(item.data, item.mimeType ?? item.mime_type),
detail: parseMcpImageDetail(item._meta),
});
continue;
}
+1
View File
@@ -1568,6 +1568,7 @@ impl JsReplManager {
crate::tools::router::ToolRouterParams {
deferred_mcp_tools: None,
mcp_tools: Some(mcp_tools),
unavailable_called_tools: Vec::new(),
// JS REPL dispatches nested tool calls directly, not through
// `ToolCallRuntime`'s parallel scheduling lock.
parallel_mcp_server_names: std::collections::HashSet::new(),
+3
View File
@@ -44,6 +44,7 @@ pub struct ToolRouter {
pub(crate) struct ToolRouterParams<'a> {
pub(crate) mcp_tools: Option<HashMap<String, ToolInfo>>,
pub(crate) deferred_mcp_tools: Option<HashMap<String, ToolInfo>>,
pub(crate) unavailable_called_tools: Vec<ToolName>,
pub(crate) parallel_mcp_server_names: HashSet<String>,
pub(crate) discoverable_tools: Option<Vec<DiscoverableTool>>,
pub(crate) dynamic_tools: &'a [DynamicToolSpec],
@@ -54,6 +55,7 @@ impl ToolRouter {
let ToolRouterParams {
mcp_tools,
deferred_mcp_tools,
unavailable_called_tools,
parallel_mcp_server_names,
discoverable_tools,
dynamic_tools,
@@ -62,6 +64,7 @@ impl ToolRouter {
config,
mcp_tools,
deferred_mcp_tools,
unavailable_called_tools,
discoverable_tools,
dynamic_tools,
);
+5
View File
@@ -33,6 +33,7 @@ async fn js_repl_tools_only_blocks_direct_tool_calls() -> anyhow::Result<()> {
ToolRouterParams {
deferred_mcp_tools,
mcp_tools: Some(mcp_tools),
unavailable_called_tools: Vec::new(),
parallel_mcp_server_names: HashSet::new(),
discoverable_tools: None,
dynamic_tools: turn.dynamic_tools.as_slice(),
@@ -86,6 +87,7 @@ async fn js_repl_tools_only_allows_js_repl_source_calls() -> anyhow::Result<()>
ToolRouterParams {
deferred_mcp_tools,
mcp_tools: Some(mcp_tools),
unavailable_called_tools: Vec::new(),
parallel_mcp_server_names: HashSet::new(),
discoverable_tools: None,
dynamic_tools: turn.dynamic_tools.as_slice(),
@@ -132,6 +134,7 @@ async fn js_repl_tools_only_blocks_namespaced_js_repl_tool() -> anyhow::Result<(
ToolRouterParams {
deferred_mcp_tools: None,
mcp_tools: None,
unavailable_called_tools: Vec::new(),
parallel_mcp_server_names: HashSet::new(),
discoverable_tools: None,
dynamic_tools: turn.dynamic_tools.as_slice(),
@@ -182,6 +185,7 @@ async fn parallel_support_does_not_match_namespaced_local_tool_names() -> anyhow
ToolRouterParams {
deferred_mcp_tools: None,
mcp_tools: Some(mcp_tools),
unavailable_called_tools: Vec::new(),
parallel_mcp_server_names: HashSet::new(),
discoverable_tools: None,
dynamic_tools: turn.dynamic_tools.as_slice(),
@@ -254,6 +258,7 @@ async fn mcp_parallel_support_uses_exact_payload_server() -> anyhow::Result<()>
ToolRouterParams {
deferred_mcp_tools: None,
mcp_tools: None,
unavailable_called_tools: Vec::new(),
parallel_mcp_server_names: HashSet::from(["echo".to_string()]),
discoverable_tools: None,
dynamic_tools: turn.dynamic_tools.as_slice(),
+43
View File
@@ -7,8 +7,12 @@ use crate::tools::handlers::multi_agents_common::MIN_WAIT_TIMEOUT_MS;
use crate::tools::registry::ToolRegistryBuilder;
use codex_mcp::ToolInfo;
use codex_protocol::dynamic_tools::DynamicToolSpec;
use codex_tools::AdditionalProperties;
use codex_tools::DiscoverableTool;
use codex_tools::JsonSchema;
use codex_tools::ResponsesApiTool;
use codex_tools::ToolHandlerKind;
use codex_tools::ToolName;
use codex_tools::ToolNamespace;
use codex_tools::ToolRegistryPlanDeferredTool;
use codex_tools::ToolRegistryPlanMcpTool;
@@ -16,8 +20,10 @@ use codex_tools::ToolRegistryPlanParams;
use codex_tools::ToolUserShellType;
use codex_tools::ToolsConfig;
use codex_tools::WaitAgentTimeoutOptions;
use codex_tools::augment_tool_spec_for_code_mode;
use codex_tools::build_tool_registry_plan;
use std::collections::HashMap;
use std::collections::HashSet;
use std::sync::Arc;
pub(crate) fn tool_user_shell_type(user_shell: &Shell) -> ToolUserShellType {
@@ -66,6 +72,7 @@ pub(crate) fn build_specs_with_discoverable_tools(
config: &ToolsConfig,
mcp_tools: Option<HashMap<String, ToolInfo>>,
deferred_mcp_tools: Option<HashMap<String, ToolInfo>>,
unavailable_called_tools: Vec<ToolName>,
discoverable_tools: Option<Vec<DiscoverableTool>>,
dynamic_tools: &[DynamicToolSpec],
) -> ToolRegistryBuilder {
@@ -86,6 +93,7 @@ pub(crate) fn build_specs_with_discoverable_tools(
use crate::tools::handlers::TestSyncHandler;
use crate::tools::handlers::ToolSearchHandler;
use crate::tools::handlers::ToolSuggestHandler;
use crate::tools::handlers::UnavailableToolHandler;
use crate::tools::handlers::UnifiedExecHandler;
use crate::tools::handlers::ViewImageHandler;
use crate::tools::handlers::multi_agents::CloseAgentHandler;
@@ -99,6 +107,7 @@ pub(crate) fn build_specs_with_discoverable_tools(
use crate::tools::handlers::multi_agents_v2::SendMessageHandler as SendMessageHandlerV2;
use crate::tools::handlers::multi_agents_v2::SpawnAgentHandler as SpawnAgentHandlerV2;
use crate::tools::handlers::multi_agents_v2::WaitAgentHandler as WaitAgentHandlerV2;
use crate::tools::handlers::unavailable_tool_message;
let mut builder = ToolRegistryBuilder::new();
let mcp_tool_plan_inputs = mcp_tools.as_ref().map(map_mcp_tools_for_plan);
@@ -154,6 +163,12 @@ pub(crate) fn build_specs_with_discoverable_tools(
let code_mode_wait_handler = Arc::new(CodeModeWaitHandler);
let js_repl_handler = Arc::new(JsReplHandler);
let js_repl_reset_handler = Arc::new(JsReplResetHandler);
let unavailable_tool_handler = Arc::new(UnavailableToolHandler);
let mut existing_spec_names = plan
.specs
.iter()
.map(|configured_tool| configured_tool.name().to_string())
.collect::<HashSet<_>>();
for spec in plan.specs {
if spec.supports_parallel_tool_calls {
@@ -278,6 +293,34 @@ pub(crate) fn build_specs_with_discoverable_tools(
builder.register_handler(name.clone(), mcp_handler.clone());
}
}
for unavailable_tool in unavailable_called_tools {
let tool_name = unavailable_tool.display();
if existing_spec_names.insert(tool_name.clone()) {
let spec = codex_tools::ToolSpec::Function(ResponsesApiTool {
name: tool_name.clone(),
description: unavailable_tool_message(
&tool_name,
"Calling this placeholder returns an error explaining that the tool is unavailable.",
),
strict: false,
parameters: JsonSchema::object(
Default::default(),
/*required*/ None,
Some(AdditionalProperties::Boolean(false)),
),
output_schema: None,
defer_loading: None,
});
let spec = if config.code_mode_enabled {
augment_tool_spec_for_code_mode(spec)
} else {
spec
};
builder.push_spec(spec);
}
builder.register_handler(unavailable_tool, unavailable_tool_handler.clone());
}
builder
}
+69
View File
@@ -16,6 +16,7 @@ use codex_protocol::openai_models::ConfigShellToolType;
use codex_protocol::openai_models::ModelInfo;
use codex_protocol::protocol::SandboxPolicy;
use codex_protocol::protocol::SessionSource;
use codex_tools::AdditionalProperties;
use codex_tools::ConfiguredToolSpec;
use codex_tools::DiscoverableTool;
use codex_tools::JsonSchema;
@@ -267,11 +268,28 @@ fn build_specs(
mcp_tools: Option<HashMap<String, ToolInfo>>,
deferred_mcp_tools: Option<HashMap<String, ToolInfo>>,
dynamic_tools: &[DynamicToolSpec],
) -> ToolRegistryBuilder {
build_specs_with_unavailable_tools(
config,
mcp_tools,
deferred_mcp_tools,
Vec::new(),
dynamic_tools,
)
}
fn build_specs_with_unavailable_tools(
config: &ToolsConfig,
mcp_tools: Option<HashMap<String, ToolInfo>>,
deferred_mcp_tools: Option<HashMap<String, ToolInfo>>,
unavailable_called_tools: Vec<ToolName>,
dynamic_tools: &[DynamicToolSpec],
) -> ToolRegistryBuilder {
build_specs_with_discoverable_tools(
config,
mcp_tools,
deferred_mcp_tools,
unavailable_called_tools,
/*discoverable_tools*/ None,
dynamic_tools,
)
@@ -356,6 +374,7 @@ fn assert_model_tools(
ToolRouterParams {
mcp_tools: None,
deferred_mcp_tools: None,
unavailable_called_tools: Vec::new(),
parallel_mcp_server_names: std::collections::HashSet::new(),
discoverable_tools: None,
dynamic_tools: &[],
@@ -770,6 +789,7 @@ fn tool_suggest_requires_apps_and_plugins_features() {
&tools_config,
/*mcp_tools*/ None,
/*deferred_mcp_tools*/ None,
Vec::new(),
discoverable_tools.clone(),
&[],
)
@@ -991,6 +1011,55 @@ fn direct_mcp_tools_register_namespaced_handlers() {
assert!(!registry.has_handler(&ToolName::plain("mcp__test_server__echo")));
}
#[test]
fn unavailable_mcp_tools_are_exposed_as_dummy_function_tools() {
let config = test_config();
let model_info = construct_model_info_offline("gpt-5-codex", &config);
let mut features = Features::with_defaults();
features.enable(Feature::UnifiedExec);
let available_models = Vec::new();
let tools_config = ToolsConfig::new(&ToolsConfigParams {
model_info: &model_info,
available_models: &available_models,
features: &features,
image_generation_tool_auth_allowed: true,
web_search_mode: Some(WebSearchMode::Cached),
session_source: SessionSource::Cli,
sandbox_policy: &SandboxPolicy::DangerFullAccess,
windows_sandbox_level: WindowsSandboxLevel::Disabled,
});
let unavailable_tool = ToolName::namespaced("mcp__codex_apps__calendar", "_create_event");
let (tools, registry) = build_specs_with_unavailable_tools(
&tools_config,
/*mcp_tools*/ None,
/*deferred_mcp_tools*/ None,
vec![unavailable_tool],
&[],
)
.build();
let tool = find_tool(&tools, "mcp__codex_apps__calendar_create_event");
let ToolSpec::Function(ResponsesApiTool {
description,
parameters,
..
}) = &tool.spec
else {
panic!("unavailable MCP tool should be exposed as a function tool");
};
assert!(description.contains("not currently available"));
assert_eq!(
parameters.additional_properties,
Some(AdditionalProperties::Boolean(false))
);
assert!(registry.has_handler(&ToolName::namespaced(
"mcp__codex_apps__calendar",
"_create_event"
)));
assert!(!registry.has_handler(&ToolName::plain("mcp__codex_apps__calendar_create_event")));
}
#[test]
fn test_mcp_tool_property_missing_type_defaults_to_string() {
let config = test_config();
+92
View File
@@ -0,0 +1,92 @@
use std::collections::BTreeMap;
use std::collections::HashSet;
use codex_protocol::models::ResponseItem;
use codex_tools::ToolName;
pub(crate) fn collect_unavailable_called_tools(
input: &[ResponseItem],
exposed_tool_names: &HashSet<&str>,
) -> Vec<ToolName> {
let mut unavailable_tools = BTreeMap::new();
for item in input {
let ResponseItem::FunctionCall {
name, namespace, ..
} = item
else {
continue;
};
if !should_collect_unavailable_tool(name, namespace.as_deref()) {
continue;
}
let tool_name = match namespace {
Some(namespace) => ToolName::namespaced(namespace.clone(), name.clone()),
None => ToolName::plain(name.clone()),
};
let display_name = tool_name.display();
if exposed_tool_names.contains(display_name.as_str()) {
continue;
}
unavailable_tools
.entry(display_name)
.or_insert_with(|| tool_name);
}
unavailable_tools.into_values().collect()
}
fn should_collect_unavailable_tool(name: &str, namespace: Option<&str>) -> bool {
namespace.is_some_and(|namespace| namespace.starts_with("mcp__")) || name.starts_with("mcp__")
}
#[cfg(test)]
mod tests {
use super::*;
use pretty_assertions::assert_eq;
fn function_call(name: &str, namespace: Option<&str>) -> ResponseItem {
ResponseItem::FunctionCall {
id: None,
name: name.to_string(),
namespace: namespace.map(str::to_string),
arguments: "{}".to_string(),
call_id: format!("call-{name}"),
}
}
#[test]
fn collect_unavailable_called_tools_detects_mcp_function_calls() {
let input = vec![
function_call("shell", /*namespace*/ None),
function_call("mcp__server__lookup", /*namespace*/ None),
function_call("_create_event", Some("mcp__codex_apps__calendar")),
];
let tools = collect_unavailable_called_tools(&input, &HashSet::new());
assert_eq!(
tools,
vec![
ToolName::namespaced("mcp__codex_apps__calendar", "_create_event"),
ToolName::plain("mcp__server__lookup"),
]
);
}
#[test]
fn collect_unavailable_called_tools_skips_currently_available_tools() {
let exposed_tool_names = HashSet::from(["mcp__server__lookup", "mcp__server__search"]);
let input = vec![
function_call("mcp__server__lookup", /*namespace*/ None),
function_call("mcp__server__search", /*namespace*/ None),
function_call("mcp__server__missing", /*namespace*/ None),
];
let tools = collect_unavailable_called_tools(&input, &exposed_tool_names);
assert_eq!(tools, vec![ToolName::plain("mcp__server__missing")]);
}
}
+115 -41
View File
@@ -178,7 +178,16 @@ async fn run_code_mode_turn_with_rmcp(
prompt: &str,
code: &str,
) -> Result<(TestCodex, ResponseMock)> {
run_code_mode_turn_with_rmcp_mode(server, prompt, code, /*code_mode_only*/ false).await
run_code_mode_turn_with_rmcp_model(server, prompt, code, "test-gpt-5.1-codex").await
}
async fn run_code_mode_turn_with_rmcp_model(
server: &MockServer,
prompt: &str,
code: &str,
model: &'static str,
) -> Result<(TestCodex, ResponseMock)> {
run_code_mode_turn_with_rmcp_config(server, prompt, code, model, /*code_mode_only*/ false).await
}
async fn run_code_mode_turn_with_rmcp_mode(
@@ -187,48 +196,57 @@ async fn run_code_mode_turn_with_rmcp_mode(
code: &str,
code_mode_only: bool,
) -> Result<(TestCodex, ResponseMock)> {
let rmcp_test_server_bin = stdio_server_bin()?;
let mut builder = test_codex()
.with_model("test-gpt-5.1-codex")
.with_config(move |config| {
let _ = if code_mode_only {
config.features.enable(Feature::CodeModeOnly)
} else {
config.features.enable(Feature::CodeMode)
};
run_code_mode_turn_with_rmcp_config(server, prompt, code, "test-gpt-5.1-codex", code_mode_only)
.await
}
let mut servers = config.mcp_servers.get().clone();
servers.insert(
"rmcp".to_string(),
McpServerConfig {
transport: McpServerTransportConfig::Stdio {
command: rmcp_test_server_bin,
args: Vec::new(),
env: Some(HashMap::from([(
"MCP_TEST_VALUE".to_string(),
"propagated-env".to_string(),
)])),
env_vars: Vec::new(),
cwd: None,
},
enabled: true,
required: false,
supports_parallel_tool_calls: false,
disabled_reason: None,
startup_timeout_sec: Some(Duration::from_secs(10)),
tool_timeout_sec: None,
enabled_tools: None,
disabled_tools: None,
scopes: None,
oauth_resource: None,
tools: HashMap::new(),
async fn run_code_mode_turn_with_rmcp_config(
server: &MockServer,
prompt: &str,
code: &str,
model: &'static str,
code_mode_only: bool,
) -> Result<(TestCodex, ResponseMock)> {
let rmcp_test_server_bin = stdio_server_bin()?;
let mut builder = test_codex().with_model(model).with_config(move |config| {
let _ = if code_mode_only {
config.features.enable(Feature::CodeModeOnly)
} else {
config.features.enable(Feature::CodeMode)
};
let mut servers = config.mcp_servers.get().clone();
servers.insert(
"rmcp".to_string(),
McpServerConfig {
transport: McpServerTransportConfig::Stdio {
command: rmcp_test_server_bin,
args: Vec::new(),
env: Some(HashMap::from([(
"MCP_TEST_VALUE".to_string(),
"propagated-env".to_string(),
)])),
env_vars: Vec::new(),
cwd: None,
},
);
config
.mcp_servers
.set(servers)
.expect("test mcp servers should accept any configuration");
});
enabled: true,
required: false,
supports_parallel_tool_calls: false,
disabled_reason: None,
startup_timeout_sec: Some(Duration::from_secs(10)),
tool_timeout_sec: None,
enabled_tools: None,
disabled_tools: None,
scopes: None,
oauth_resource: None,
tools: HashMap::new(),
},
);
config
.mcp_servers
.set(servers)
.expect("test mcp servers should accept any configuration");
});
let test = builder.build(server).await?;
responses::mount_sse_once(
@@ -1919,6 +1937,62 @@ image(out);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn code_mode_can_use_mcp_image_result_with_image_helper() -> Result<()> {
skip_if_no_network!(Ok(()));
let server = responses::start_mock_server().await;
let code = r#"
const out = await tools.mcp__rmcp__image_scenario({
scenario: "image_only_original_detail",
});
const imageItem = out.content.find((item) => item.type === "image");
image(imageItem);
"#;
let (_test, second_mock) = run_code_mode_turn_with_rmcp_model(
&server,
"use exec to call the rmcp image scenario tool and emit its image output",
code,
"gpt-5.3-codex",
)
.await?;
let req = second_mock.single_request();
let items = custom_tool_output_items(&req, "call-1");
let (_, success) = custom_tool_output_body_and_success(&req, "call-1");
assert_ne!(
success,
Some(false),
"code_mode mcp image scenario call failed unexpectedly"
);
assert_eq!(items.len(), 2);
assert_regex_match(
concat!(
r"(?s)\A",
r"Script completed\nWall time \d+\.\d seconds\nOutput:\n\z"
),
text_item(&items, /*index*/ 0),
);
assert_eq!(
items[1].get("type").and_then(Value::as_str),
Some("input_image")
);
let emitted_image_url = items[1]
.get("image_url")
.and_then(Value::as_str)
.expect("image helper should emit an input_image item with image_url");
assert!(emitted_image_url.starts_with("data:image/png;base64,"));
assert_eq!(
items[1].get("detail").and_then(Value::as_str),
Some("original")
);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn code_mode_can_apply_patch_via_nested_tool() -> Result<()> {
skip_if_no_network!(Ok(()));
+344 -291
View File
@@ -11,6 +11,8 @@ use std::time::UNIX_EPOCH;
use codex_config::types::McpServerConfig;
use codex_config::types::McpServerTransportConfig;
use codex_core::config::Config;
use codex_features::Feature;
use codex_login::CodexAuth;
use codex_mcp::MCP_SANDBOX_STATE_META_CAPABILITY;
use codex_models_manager::manager::RefreshStrategy;
@@ -33,10 +35,12 @@ use codex_protocol::user_input::UserInput;
use codex_utils_cargo_bin::cargo_bin;
use core_test_support::assert_regex_match;
use core_test_support::responses;
use core_test_support::responses::ev_custom_tool_call;
use core_test_support::responses::mount_models_once;
use core_test_support::responses::mount_sse_once;
use core_test_support::skip_if_no_network;
use core_test_support::stdio_server_bin;
use core_test_support::test_codex::TestCodex;
use core_test_support::test_codex::test_codex;
use core_test_support::wait_for_event;
use core_test_support::wait_for_event_with_timeout;
@@ -82,6 +86,82 @@ enum McpCallEvent {
End(String),
}
async fn wait_for_mcp_tool(fixture: &TestCodex, tool_name: &str) -> anyhow::Result<()> {
let tools_ready_deadline = Instant::now() + Duration::from_secs(30);
loop {
fixture.codex.submit(Op::ListMcpTools).await?;
let list_event = wait_for_event_with_timeout(
&fixture.codex,
|ev| matches!(ev, EventMsg::McpListToolsResponse(_)),
Duration::from_secs(10),
)
.await;
let EventMsg::McpListToolsResponse(tool_list) = list_event else {
unreachable!("event guard guarantees McpListToolsResponse");
};
if tool_list.tools.contains_key(tool_name) {
return Ok(());
}
let available_tools: Vec<&str> = tool_list.tools.keys().map(String::as_str).collect();
if Instant::now() >= tools_ready_deadline {
panic!(
"timed out waiting for MCP tool {tool_name} to become available; discovered tools: {available_tools:?}"
);
}
sleep(Duration::from_millis(200)).await;
}
}
#[derive(Default)]
struct TestMcpServerOptions {
supports_parallel_tool_calls: bool,
tool_timeout_sec: Option<Duration>,
}
fn stdio_transport(
command: String,
env: Option<HashMap<String, String>>,
env_vars: Vec<String>,
) -> McpServerTransportConfig {
McpServerTransportConfig::Stdio {
command,
args: Vec::new(),
env,
env_vars,
cwd: None,
}
}
fn insert_mcp_server(
config: &mut Config,
server_name: &str,
transport: McpServerTransportConfig,
options: TestMcpServerOptions,
) {
let mut servers = config.mcp_servers.get().clone();
servers.insert(
server_name.to_string(),
McpServerConfig {
transport,
enabled: true,
required: false,
supports_parallel_tool_calls: options.supports_parallel_tool_calls,
disabled_reason: None,
startup_timeout_sec: Some(Duration::from_secs(10)),
tool_timeout_sec: options.tool_timeout_sec,
enabled_tools: None,
disabled_tools: None,
scopes: None,
oauth_resource: None,
tools: HashMap::new(),
},
);
if let Err(err) = config.mcp_servers.set(servers) {
panic!("test mcp servers should accept any configuration: {err}");
}
}
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
#[serial(mcp_test_value)]
async fn stdio_server_round_trip() -> anyhow::Result<()> {
@@ -121,37 +201,19 @@ async fn stdio_server_round_trip() -> anyhow::Result<()> {
let fixture = test_codex()
.with_config(move |config| {
let mut servers = config.mcp_servers.get().clone();
servers.insert(
server_name.to_string(),
McpServerConfig {
transport: McpServerTransportConfig::Stdio {
command: rmcp_test_server_bin,
args: Vec::new(),
env: Some(HashMap::from([(
"MCP_TEST_VALUE".to_string(),
expected_env_value.to_string(),
)])),
env_vars: Vec::new(),
cwd: None,
},
enabled: true,
required: false,
supports_parallel_tool_calls: false,
disabled_reason: None,
startup_timeout_sec: Some(Duration::from_secs(10)),
tool_timeout_sec: None,
enabled_tools: None,
disabled_tools: None,
scopes: None,
oauth_resource: None,
tools: HashMap::new(),
},
insert_mcp_server(
config,
server_name,
stdio_transport(
rmcp_test_server_bin,
Some(HashMap::from([(
"MCP_TEST_VALUE".to_string(),
expected_env_value.to_string(),
)])),
Vec::new(),
),
TestMcpServerOptions::default(),
);
config
.mcp_servers
.set(servers)
.expect("test mcp servers should accept any configuration");
})
.build(&server)
.await?;
@@ -282,34 +344,12 @@ async fn stdio_mcp_tool_call_includes_sandbox_state_meta() -> anyhow::Result<()>
let rmcp_test_server_bin = stdio_server_bin()?;
let fixture = test_codex()
.with_config(move |config| {
let mut servers = config.mcp_servers.get().clone();
servers.insert(
server_name.to_string(),
McpServerConfig {
transport: McpServerTransportConfig::Stdio {
command: rmcp_test_server_bin,
args: Vec::new(),
env: None,
env_vars: Vec::new(),
cwd: None,
},
enabled: true,
required: false,
supports_parallel_tool_calls: false,
disabled_reason: None,
startup_timeout_sec: Some(Duration::from_secs(10)),
tool_timeout_sec: None,
enabled_tools: None,
disabled_tools: None,
scopes: None,
oauth_resource: None,
tools: HashMap::new(),
},
insert_mcp_server(
config,
server_name,
stdio_transport(rmcp_test_server_bin, /*env*/ None, Vec::new()),
TestMcpServerOptions::default(),
);
config
.mcp_servers
.set(servers)
.expect("test mcp servers should accept any configuration");
})
.build(&server)
.await?;
@@ -417,34 +457,15 @@ async fn stdio_mcp_parallel_tool_calls_default_false_runs_serially() -> anyhow::
let fixture = test_codex()
.with_config(move |config| {
let mut servers = config.mcp_servers.get().clone();
servers.insert(
server_name.to_string(),
McpServerConfig {
transport: McpServerTransportConfig::Stdio {
command: rmcp_test_server_bin,
args: Vec::new(),
env: None,
env_vars: Vec::new(),
cwd: None,
},
enabled: true,
required: false,
supports_parallel_tool_calls: false,
disabled_reason: None,
startup_timeout_sec: Some(Duration::from_secs(10)),
insert_mcp_server(
config,
server_name,
stdio_transport(rmcp_test_server_bin, /*env*/ None, Vec::new()),
TestMcpServerOptions {
tool_timeout_sec: Some(Duration::from_secs(2)),
enabled_tools: None,
disabled_tools: None,
scopes: None,
oauth_resource: None,
tools: HashMap::new(),
..Default::default()
},
);
config
.mcp_servers
.set(servers)
.expect("test mcp servers should accept any configuration");
})
.build(&server)
.await?;
@@ -567,34 +588,15 @@ async fn stdio_mcp_parallel_tool_calls_opt_in_runs_concurrently() -> anyhow::Res
let fixture = test_codex()
.with_config(move |config| {
let mut servers = config.mcp_servers.get().clone();
servers.insert(
server_name.to_string(),
McpServerConfig {
transport: McpServerTransportConfig::Stdio {
command: rmcp_test_server_bin,
args: Vec::new(),
env: None,
env_vars: Vec::new(),
cwd: None,
},
enabled: true,
required: false,
insert_mcp_server(
config,
server_name,
stdio_transport(rmcp_test_server_bin, /*env*/ None, Vec::new()),
TestMcpServerOptions {
supports_parallel_tool_calls: true,
disabled_reason: None,
startup_timeout_sec: Some(Duration::from_secs(10)),
tool_timeout_sec: Some(Duration::from_secs(2)),
enabled_tools: None,
disabled_tools: None,
scopes: None,
oauth_resource: None,
tools: HashMap::new(),
},
);
config
.mcp_servers
.set(servers)
.expect("test mcp servers should accept any configuration");
})
.build(&server)
.await?;
@@ -676,66 +678,25 @@ async fn stdio_image_responses_round_trip() -> anyhow::Result<()> {
let fixture = test_codex()
.with_config(move |config| {
let mut servers = config.mcp_servers.get().clone();
servers.insert(
server_name.to_string(),
McpServerConfig {
transport: McpServerTransportConfig::Stdio {
command: rmcp_test_server_bin,
args: Vec::new(),
env: Some(HashMap::from([(
"MCP_TEST_IMAGE_DATA_URL".to_string(),
OPENAI_PNG.to_string(),
)])),
env_vars: Vec::new(),
cwd: None,
},
enabled: true,
required: false,
supports_parallel_tool_calls: false,
disabled_reason: None,
startup_timeout_sec: Some(Duration::from_secs(10)),
tool_timeout_sec: None,
enabled_tools: None,
disabled_tools: None,
scopes: None,
oauth_resource: None,
tools: HashMap::new(),
},
insert_mcp_server(
config,
server_name,
stdio_transport(
rmcp_test_server_bin,
Some(HashMap::from([(
"MCP_TEST_IMAGE_DATA_URL".to_string(),
OPENAI_PNG.to_string(),
)])),
Vec::new(),
),
TestMcpServerOptions::default(),
);
config
.mcp_servers
.set(servers)
.expect("test mcp servers should accept any configuration");
})
.build(&server)
.await?;
let session_model = fixture.session_configured.model.clone();
let tools_ready_deadline = Instant::now() + Duration::from_secs(30);
loop {
fixture.codex.submit(Op::ListMcpTools).await?;
let list_event = wait_for_event_with_timeout(
&fixture.codex,
|ev| matches!(ev, EventMsg::McpListToolsResponse(_)),
Duration::from_secs(10),
)
.await;
let EventMsg::McpListToolsResponse(tool_list) = list_event else {
unreachable!("event guard guarantees McpListToolsResponse");
};
if tool_list.tools.contains_key(&tool_name) {
break;
}
let available_tools: Vec<&str> = tool_list.tools.keys().map(String::as_str).collect();
if Instant::now() >= tools_ready_deadline {
panic!(
"timed out waiting for MCP tool {tool_name} to become available; discovered tools: {available_tools:?}"
);
}
sleep(Duration::from_millis(200)).await;
}
wait_for_mcp_tool(&fixture, &tool_name).await?;
fixture
.codex
@@ -830,6 +791,189 @@ async fn stdio_image_responses_round_trip() -> anyhow::Result<()> {
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
#[serial(mcp_test_value)]
async fn stdio_image_responses_preserve_original_detail_metadata() -> anyhow::Result<()> {
skip_if_no_network!(Ok(()));
let server = responses::start_mock_server().await;
let call_id = "img-original-detail-1";
let server_name = "rmcp";
let tool_name = format!("mcp__{server_name}__image_scenario");
let namespace = format!("mcp__{server_name}__");
mount_sse_once(
&server,
responses::sse(vec![
responses::ev_response_created("resp-1"),
responses::ev_function_call_with_namespace(
call_id,
&namespace,
"image_scenario",
r#"{"scenario":"image_only_original_detail"}"#,
),
responses::ev_completed("resp-1"),
]),
)
.await;
let final_mock = mount_sse_once(
&server,
responses::sse(vec![
responses::ev_assistant_message("msg-1", "rmcp original-detail image completed."),
responses::ev_completed("resp-2"),
]),
)
.await;
let rmcp_test_server_bin = stdio_server_bin()?;
let fixture = test_codex()
.with_model("gpt-5.3-codex")
.with_config(move |config| {
insert_mcp_server(
config,
server_name,
stdio_transport(rmcp_test_server_bin, /*env*/ None, Vec::new()),
TestMcpServerOptions::default(),
);
})
.build(&server)
.await?;
let session_model = fixture.session_configured.model.clone();
wait_for_mcp_tool(&fixture, &tool_name).await?;
fixture
.codex
.submit(Op::UserTurn {
items: vec![UserInput::Text {
text: "call the rmcp image_scenario tool".into(),
text_elements: Vec::new(),
}],
final_output_json_schema: None,
cwd: fixture.cwd.path().to_path_buf(),
approval_policy: AskForApproval::Never,
approvals_reviewer: None,
sandbox_policy: SandboxPolicy::new_read_only_policy(),
model: session_model,
effort: None,
summary: None,
service_tier: None,
collaboration_mode: None,
personality: None,
})
.await?;
wait_for_event(&fixture.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await;
let output_item = final_mock.single_request().function_call_output(call_id);
let output = output_item["output"]
.as_array()
.expect("image MCP output should be content items");
assert_eq!(output.len(), 2);
assert_wall_time_header(
output[0]["text"]
.as_str()
.expect("first MCP image output item should be wall-time text"),
);
assert_eq!(
output[1],
json!({
"type": "input_image",
"image_url": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4z8DwHwAFAAH/iZk9HQAAAABJRU5ErkJggg==",
"detail": "original",
})
);
server.verify().await;
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
#[serial(mcp_test_value)]
async fn js_repl_emit_image_preserves_original_detail_for_mcp_images() -> anyhow::Result<()> {
skip_if_no_network!(Ok(()));
let server = responses::start_mock_server().await;
let call_id = "js-repl-rmcp-image";
let rmcp_test_server_bin = stdio_server_bin()?;
let fixture = test_codex()
.with_model("gpt-5.3-codex")
.with_config(move |config| {
config
.features
.enable(Feature::JsRepl)
.expect("test config should allow feature update");
insert_mcp_server(
config,
"rmcp",
stdio_transport(rmcp_test_server_bin, /*env*/ None, Vec::new()),
TestMcpServerOptions::default(),
);
})
.build(&server)
.await?;
wait_for_mcp_tool(&fixture, "mcp__rmcp__image_scenario").await?;
mount_sse_once(
&server,
responses::sse(vec![
responses::ev_response_created("resp-1"),
ev_custom_tool_call(
call_id,
"js_repl",
r#"
const out = await codex.tool("mcp__rmcp__image_scenario", {
scenario: "image_only_original_detail",
});
const imageItem = out.output.find((item) => item.type === "input_image");
await codex.emitImage(imageItem);
"#,
),
responses::ev_completed("resp-1"),
]),
)
.await;
let final_mock = mount_sse_once(
&server,
responses::sse(vec![
responses::ev_assistant_message("msg-1", "done"),
responses::ev_completed("resp-2"),
]),
)
.await;
fixture
.submit_turn("use js_repl to emit the rmcp image scenario output")
.await?;
let output = final_mock.single_request().custom_tool_call_output(call_id);
let output_items = output["output"]
.as_array()
.expect("js_repl output should be content items");
let image_item = output_items
.iter()
.find(|item| item.get("type").and_then(Value::as_str) == Some("input_image"))
.expect("js_repl should emit an input_image item");
assert_eq!(
image_item.get("detail").and_then(Value::as_str),
Some("original")
);
assert!(
image_item
.get("image_url")
.and_then(Value::as_str)
.is_some_and(|image_url| image_url.starts_with("data:image/png;base64,")),
"js_repl should emit a png data URL"
);
server.verify().await;
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
#[serial(mcp_test_value)]
async fn stdio_image_responses_are_sanitized_for_text_only_model() -> anyhow::Result<()> {
@@ -909,37 +1053,19 @@ async fn stdio_image_responses_are_sanitized_for_text_only_model() -> anyhow::Re
let fixture = test_codex()
.with_auth(CodexAuth::create_dummy_chatgpt_auth_for_testing())
.with_config(move |config| {
let mut servers = config.mcp_servers.get().clone();
servers.insert(
server_name.to_string(),
McpServerConfig {
transport: McpServerTransportConfig::Stdio {
command: rmcp_test_server_bin,
args: Vec::new(),
env: Some(HashMap::from([(
"MCP_TEST_IMAGE_DATA_URL".to_string(),
OPENAI_PNG.to_string(),
)])),
env_vars: Vec::new(),
cwd: None,
},
enabled: true,
required: false,
supports_parallel_tool_calls: false,
disabled_reason: None,
startup_timeout_sec: Some(Duration::from_secs(10)),
tool_timeout_sec: None,
enabled_tools: None,
disabled_tools: None,
scopes: None,
oauth_resource: None,
tools: HashMap::new(),
},
insert_mcp_server(
config,
server_name,
stdio_transport(
rmcp_test_server_bin,
Some(HashMap::from([(
"MCP_TEST_IMAGE_DATA_URL".to_string(),
OPENAI_PNG.to_string(),
)])),
Vec::new(),
),
TestMcpServerOptions::default(),
);
config
.mcp_servers
.set(servers)
.expect("test mcp servers should accept any configuration");
})
.build(&server)
.await?;
@@ -1041,34 +1167,16 @@ async fn stdio_server_propagates_whitelisted_env_vars() -> anyhow::Result<()> {
let fixture = test_codex()
.with_config(move |config| {
let mut servers = config.mcp_servers.get().clone();
servers.insert(
server_name.to_string(),
McpServerConfig {
transport: McpServerTransportConfig::Stdio {
command: rmcp_test_server_bin,
args: Vec::new(),
env: None,
env_vars: vec!["MCP_TEST_VALUE".to_string()],
cwd: None,
},
enabled: true,
required: false,
supports_parallel_tool_calls: false,
disabled_reason: None,
startup_timeout_sec: Some(Duration::from_secs(10)),
tool_timeout_sec: None,
enabled_tools: None,
disabled_tools: None,
scopes: None,
oauth_resource: None,
tools: HashMap::new(),
},
insert_mcp_server(
config,
server_name,
stdio_transport(
rmcp_test_server_bin,
/*env*/ None,
vec!["MCP_TEST_VALUE".to_string()],
),
TestMcpServerOptions::default(),
);
config
.mcp_servers
.set(servers)
.expect("test mcp servers should accept any configuration");
})
.build(&server)
.await?;
@@ -1211,33 +1319,17 @@ async fn streamable_http_tool_call_round_trip() -> anyhow::Result<()> {
let fixture = test_codex()
.with_config(move |config| {
let mut servers = config.mcp_servers.get().clone();
servers.insert(
server_name.to_string(),
McpServerConfig {
transport: McpServerTransportConfig::StreamableHttp {
url: server_url,
bearer_token_env_var: None,
http_headers: None,
env_http_headers: None,
},
enabled: true,
required: false,
supports_parallel_tool_calls: false,
disabled_reason: None,
startup_timeout_sec: Some(Duration::from_secs(10)),
tool_timeout_sec: None,
enabled_tools: None,
disabled_tools: None,
scopes: None,
oauth_resource: None,
tools: HashMap::new(),
insert_mcp_server(
config,
server_name,
McpServerTransportConfig::StreamableHttp {
url: server_url,
bearer_token_env_var: None,
http_headers: None,
env_http_headers: None,
},
TestMcpServerOptions::default(),
);
config
.mcp_servers
.set(servers)
.expect("test mcp servers should accept any configuration");
})
.build(&server)
.await?;
@@ -1441,62 +1533,23 @@ async fn streamable_http_with_oauth_round_trip_impl() -> anyhow::Result<()> {
// runs the full core suite in one process.
config.mcp_oauth_credentials_store_mode = serde_json::from_value(json!("file"))
.expect("`file` should deserialize as OAuthCredentialsStoreMode");
let mut servers = config.mcp_servers.get().clone();
servers.insert(
server_name.to_string(),
McpServerConfig {
transport: McpServerTransportConfig::StreamableHttp {
url: server_url,
bearer_token_env_var: None,
http_headers: None,
env_http_headers: None,
},
enabled: true,
required: false,
supports_parallel_tool_calls: false,
disabled_reason: None,
startup_timeout_sec: Some(Duration::from_secs(10)),
tool_timeout_sec: None,
enabled_tools: None,
disabled_tools: None,
scopes: None,
oauth_resource: None,
tools: HashMap::new(),
insert_mcp_server(
config,
server_name,
McpServerTransportConfig::StreamableHttp {
url: server_url,
bearer_token_env_var: None,
http_headers: None,
env_http_headers: None,
},
TestMcpServerOptions::default(),
);
config
.mcp_servers
.set(servers)
.expect("test mcp servers should accept any configuration");
})
.build(&server)
.await?;
let session_model = fixture.session_configured.model.clone();
let tools_ready_deadline = Instant::now() + Duration::from_secs(30);
loop {
fixture.codex.submit(Op::ListMcpTools).await?;
let list_event = wait_for_event_with_timeout(
&fixture.codex,
|ev| matches!(ev, EventMsg::McpListToolsResponse(_)),
Duration::from_secs(10),
)
.await;
let EventMsg::McpListToolsResponse(tool_list) = list_event else {
unreachable!("event guard guarantees McpListToolsResponse");
};
if tool_list.tools.contains_key(&tool_name) {
break;
}
let available_tools: Vec<&str> = tool_list.tools.keys().map(String::as_str).collect();
if Instant::now() >= tools_ready_deadline {
panic!(
"timed out waiting for MCP tool {tool_name} to become available; discovered tools: {available_tools:?}"
);
}
sleep(Duration::from_millis(200)).await;
}
wait_for_mcp_tool(&fixture, &tool_name).await?;
fixture
.codex
+166
View File
@@ -1,12 +1,16 @@
#![cfg(not(target_os = "windows"))]
#![allow(clippy::unwrap_used, clippy::expect_used)]
use std::collections::HashMap;
use std::fs;
use std::sync::Arc;
use std::time::Duration;
use std::time::Instant;
use anyhow::Context;
use anyhow::Result;
use codex_config::types::McpServerConfig;
use codex_config::types::McpServerTransportConfig;
use codex_core::sandboxing::SandboxPermissions;
use codex_features::Feature;
use codex_protocol::protocol::AskForApproval;
@@ -22,10 +26,13 @@ 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::stdio_server_bin;
use core_test_support::test_codex::test_codex;
use regex_lite::Regex;
use serde_json::Value;
use serde_json::json;
use serial_test::serial;
use tempfile::TempDir;
fn tool_names(body: &Value) -> Vec<String> {
body.get("tools")
@@ -44,6 +51,24 @@ fn tool_names(body: &Value) -> Vec<String> {
.unwrap_or_default()
}
fn ev_namespaced_function_call(
call_id: &str,
namespace: &str,
name: &str,
arguments: &str,
) -> Value {
json!({
"type": "response.output_item.done",
"item": {
"type": "function_call",
"call_id": call_id,
"namespace": namespace,
"name": name,
"arguments": arguments,
}
})
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn custom_tool_unknown_returns_custom_output_error() -> Result<()> {
skip_if_no_network!(Ok(()));
@@ -91,6 +116,147 @@ async fn custom_tool_unknown_returns_custom_output_error() -> Result<()> {
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[serial(mcp_test_value)]
async fn historical_unavailable_mcp_call_is_exposed_as_placeholder_tool() -> Result<()> {
skip_if_no_network!(Ok(()));
let historical_call_id = "historical-mcp-call";
let retry_call_id = "retry-mcp-call";
let server_name = "rmcp";
let unavailable_tool_namespace = "mcp__rmcp__";
let unavailable_tool_name = "echo";
let unavailable_tool_display_name = "mcp__rmcp__echo";
let server = start_mock_server().await;
let rmcp_test_server_bin = match stdio_server_bin() {
Ok(bin) => bin,
Err(err) => {
eprintln!("test_stdio_server binary not available, skipping test: {err}");
return Ok(());
}
};
let codex_home = Arc::new(TempDir::new()?);
let mut builder = test_codex()
.with_model("gpt-5.1")
.with_home(Arc::clone(&codex_home))
.with_config(move |config| {
config
.features
.enable(Feature::UnavailableDummyTools)
.expect("unavailable dummy tools should be enabled for this test");
let mut servers = config.mcp_servers.get().clone();
servers.insert(
server_name.to_string(),
McpServerConfig {
transport: McpServerTransportConfig::Stdio {
command: rmcp_test_server_bin,
args: Vec::new(),
env: Some(HashMap::new()),
env_vars: Vec::new(),
cwd: None,
},
enabled: true,
required: false,
supports_parallel_tool_calls: false,
disabled_reason: None,
startup_timeout_sec: Some(Duration::from_secs(10)),
tool_timeout_sec: None,
enabled_tools: None,
disabled_tools: None,
scopes: None,
oauth_resource: None,
tools: HashMap::new(),
},
);
config
.mcp_servers
.set(servers)
.expect("test mcp servers should accept any configuration");
});
let test = builder.build(&server).await?;
let first_turn_mock = mount_sse_sequence(
&server,
vec![
sse(vec![
ev_response_created("resp-1"),
ev_namespaced_function_call(
historical_call_id,
unavailable_tool_namespace,
unavailable_tool_name,
r#"{"message":"ping"}"#,
),
ev_completed("resp-1"),
]),
sse(vec![
ev_response_created("resp-2"),
ev_assistant_message("msg-1", "rmcp echo tool completed"),
ev_completed("resp-2"),
]),
],
)
.await;
test.submit_turn("call the rmcp echo tool").await?;
let rollout_path = test.codex.rollout_path().context("rollout path")?;
assert_eq!(first_turn_mock.requests().len(), 2);
drop(test);
let retry_mock = mount_sse_sequence(
&server,
vec![
sse(vec![
ev_response_created("resp-3"),
ev_namespaced_function_call(
retry_call_id,
unavailable_tool_namespace,
unavailable_tool_name,
r#"{"message":"ping again"}"#,
),
ev_completed("resp-3"),
]),
sse(vec![
ev_response_created("resp-4"),
ev_assistant_message("msg-2", "done"),
ev_completed("resp-4"),
]),
],
)
.await;
let mut resume_builder = test_codex().with_model("gpt-5.1").with_config(|config| {
config
.features
.enable(Feature::UnavailableDummyTools)
.expect("unavailable dummy tools should be enabled for this test");
});
let test = resume_builder
.resume(&server, codex_home, rollout_path)
.await?;
test.submit_turn("retry the rmcp echo tool").await?;
let requests = retry_mock.requests();
assert_eq!(requests.len(), 2);
let first_request_tools = tool_names(&requests[0].body_json());
assert!(
first_request_tools
.iter()
.any(|name| name == unavailable_tool_display_name),
"historical unavailable MCP call should add a placeholder tool; got {first_request_tools:?}"
);
let output_text = requests[1]
.function_call_output_text(retry_call_id)
.context("placeholder tool output present")?;
assert!(output_text.contains("not currently available"));
assert!(
!output_text.contains("unsupported call"),
"placeholder handler should answer instead of falling back to unsupported call: {output_text}"
);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn shell_escalated_permissions_rejected_then_ok() -> Result<()> {
skip_if_no_network!(Ok(()));
+8
View File
@@ -146,6 +146,8 @@ pub enum Feature {
Apps,
/// Enable the tool_search tool for apps.
ToolSearch,
/// Expose placeholder tools for unavailable historical tool calls.
UnavailableDummyTools,
/// Enable discoverable tool suggestions for apps.
ToolSuggest,
/// Enable plugins.
@@ -791,6 +793,12 @@ pub const FEATURES: &[FeatureSpec] = &[
stage: Stage::UnderDevelopment,
default_enabled: false,
},
FeatureSpec {
id: Feature::UnavailableDummyTools,
key: "unavailable_dummy_tools",
stage: Stage::UnderDevelopment,
default_enabled: false,
},
FeatureSpec {
id: Feature::ToolSuggest,
key: "tool_suggest",
+9
View File
@@ -150,6 +150,15 @@ fn tool_search_is_under_development_and_disabled_by_default() {
assert_eq!(Feature::ToolSearch.default_enabled(), false);
}
#[test]
fn unavailable_dummy_tools_is_under_development_and_disabled_by_default() {
assert_eq!(
Feature::UnavailableDummyTools.stage(),
Stage::UnderDevelopment
);
assert_eq!(Feature::UnavailableDummyTools.default_enabled(), false);
}
#[test]
fn general_analytics_is_stable_and_enabled_by_default() {
assert_eq!(Feature::GeneralAnalytics.stage(), Stage::Stable);
+6
View File
@@ -0,0 +1,6 @@
load("//:defs.bzl", "codex_rust_crate")
codex_rust_crate(
name = "install-context",
crate_name = "codex_install_context",
)
+19
View File
@@ -0,0 +1,19 @@
[package]
name = "codex-install-context"
version.workspace = true
edition.workspace = true
license.workspace = true
[lib]
name = "codex_install_context"
path = "src/lib.rs"
[lints]
workspace = true
[dependencies]
codex-utils-home-dir = { workspace = true }
[dev-dependencies]
pretty_assertions = { workspace = true }
tempfile = { workspace = true }
+258
View File
@@ -0,0 +1,258 @@
use std::path::Path;
use std::path::PathBuf;
use std::sync::OnceLock;
const RELEASES_DIRNAME: &str = "releases";
const RESOURCES_DIRNAME: &str = "codex-resources";
const STANDALONE_PACKAGES_DIRNAME: &str = "standalone";
static INSTALL_CONTEXT: OnceLock<InstallContext> = OnceLock::new();
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum StandalonePlatform {
Unix,
Windows,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum InstallContext {
Standalone {
/// The managed standalone release directory, for example
/// `~/.codex/packages/standalone/releases/0.111.0-x86_64-unknown-linux-musl`.
release_dir: PathBuf,
/// The bundled resource directory that sits next to the executable when
/// this install ships managed dependencies.
resources_dir: Option<PathBuf>,
/// The platform of the standalone release, either `Unix` or `Windows`.
platform: StandalonePlatform,
},
/// A Codex binary launched through the npm-managed `codex.js` shim.
Npm,
/// A Codex binary launched through the bun-managed `codex.js` shim.
Bun,
/// A Codex binary that appears to come from a Homebrew install prefix.
Brew,
/// Any other execution environment.
///
/// This commonly covers `cargo run`, app-bundled Codex binaries, custom
/// internal launchers, and tests that execute Codex from an arbitrary path.
Other,
}
impl InstallContext {
pub fn from_exe(
is_macos: bool,
current_exe: Option<&Path>,
managed_by_npm: bool,
managed_by_bun: bool,
) -> Self {
let codex_home = codex_utils_home_dir::find_codex_home().ok();
Self::from_exe_with_codex_home(
is_macos,
current_exe,
managed_by_npm,
managed_by_bun,
codex_home.as_deref(),
)
}
fn from_exe_with_codex_home(
is_macos: bool,
current_exe: Option<&Path>,
managed_by_npm: bool,
managed_by_bun: bool,
codex_home: Option<&Path>,
) -> Self {
if managed_by_npm {
return Self::Npm;
}
if managed_by_bun {
return Self::Bun;
}
if let Some(exe_path) = current_exe
&& let Some(standalone_context) = standalone_install_context(exe_path, codex_home)
{
return standalone_context;
}
if is_macos
&& let Some(exe_path) = current_exe
&& (exe_path.starts_with("/opt/homebrew") || exe_path.starts_with("/usr/local"))
{
return Self::Brew;
}
Self::Other
}
pub fn current() -> &'static Self {
INSTALL_CONTEXT.get_or_init(|| {
let current_exe = std::env::current_exe().ok();
let managed_by_npm = std::env::var_os("CODEX_MANAGED_BY_NPM").is_some();
let managed_by_bun = std::env::var_os("CODEX_MANAGED_BY_BUN").is_some();
Self::from_exe(
cfg!(target_os = "macos"),
current_exe.as_deref(),
managed_by_npm,
managed_by_bun,
)
})
}
pub fn rg_command(&self) -> PathBuf {
match self {
Self::Standalone {
resources_dir: Some(resources_dir),
..
} => {
let bundled_rg = resources_dir.join(default_rg_command());
if bundled_rg.exists() {
bundled_rg
} else {
default_rg_command()
}
}
Self::Standalone {
resources_dir: None,
..
}
| Self::Npm
| Self::Bun
| Self::Brew
| Self::Other => default_rg_command(),
}
}
}
fn standalone_install_context(
exe_path: &Path,
codex_home: Option<&Path>,
) -> Option<InstallContext> {
let canonical_exe = std::fs::canonicalize(exe_path).ok()?;
let canonical_codex_home = std::fs::canonicalize(codex_home?).ok()?;
let release_dir = canonical_exe.parent()?.to_path_buf();
let releases_root = canonical_codex_home
.join("packages")
.join(STANDALONE_PACKAGES_DIRNAME)
.join(RELEASES_DIRNAME);
if !release_dir.starts_with(releases_root) {
return None;
}
let resources_dir = release_dir.join(RESOURCES_DIRNAME);
Some(InstallContext::Standalone {
release_dir,
resources_dir: resources_dir.is_dir().then_some(resources_dir),
platform: standalone_platform(),
})
}
fn standalone_platform() -> StandalonePlatform {
if cfg!(windows) {
StandalonePlatform::Windows
} else {
StandalonePlatform::Unix
}
}
fn default_rg_command() -> PathBuf {
if cfg!(windows) {
PathBuf::from("rg.exe")
} else {
PathBuf::from("rg")
}
}
#[cfg(test)]
mod tests {
use super::*;
use pretty_assertions::assert_eq;
use std::fs;
#[test]
fn detects_standalone_install_from_release_layout() -> std::io::Result<()> {
let codex_home = tempfile::tempdir()?;
let release_dir = codex_home
.path()
.join("packages/standalone/releases/1.2.3-x86_64-unknown-linux-musl");
let resources_dir = release_dir.join(RESOURCES_DIRNAME);
fs::create_dir_all(&resources_dir)?;
let exe_path = release_dir.join(if cfg!(windows) { "codex.exe" } else { "codex" });
fs::write(&exe_path, "")?;
fs::write(resources_dir.join(default_rg_command()), "")?;
let canonical_release_dir = release_dir.canonicalize()?;
let canonical_resources_dir = resources_dir.canonicalize()?;
let context = InstallContext::from_exe_with_codex_home(
/*is_macos*/ false,
/*current_exe*/ Some(&exe_path),
/*managed_by_npm*/ false,
/*managed_by_bun*/ false,
/*codex_home*/ Some(codex_home.path()),
);
assert_eq!(
context,
InstallContext::Standalone {
release_dir: canonical_release_dir,
resources_dir: Some(canonical_resources_dir),
platform: standalone_platform(),
}
);
Ok(())
}
#[test]
fn standalone_rg_falls_back_when_resources_are_missing() -> std::io::Result<()> {
let codex_home = tempfile::tempdir()?;
let release_dir = codex_home
.path()
.join("packages/standalone/releases/1.2.3-x86_64-unknown-linux-musl");
fs::create_dir_all(&release_dir)?;
let exe_path = release_dir.join(if cfg!(windows) { "codex.exe" } else { "codex" });
fs::write(&exe_path, "")?;
let context = InstallContext::from_exe_with_codex_home(
/*is_macos*/ false,
/*current_exe*/ Some(&exe_path),
/*managed_by_npm*/ false,
/*managed_by_bun*/ false,
/*codex_home*/ Some(codex_home.path()),
);
assert_eq!(context.rg_command(), default_rg_command());
Ok(())
}
#[test]
fn npm_and_bun_take_precedence() {
let npm_context = InstallContext::from_exe_with_codex_home(
/*is_macos*/ false,
/*current_exe*/ Some(Path::new("/tmp/codex")),
/*managed_by_npm*/ true,
/*managed_by_bun*/ false,
/*codex_home*/ None,
);
assert_eq!(npm_context, InstallContext::Npm);
let bun_context = InstallContext::from_exe_with_codex_home(
/*is_macos*/ false,
/*current_exe*/ Some(Path::new("/tmp/codex")),
/*managed_by_npm*/ false,
/*managed_by_bun*/ true,
/*codex_home*/ None,
);
assert_eq!(bun_context, InstallContext::Bun);
}
#[test]
fn brew_is_detected_on_macos_prefixes() {
let context = InstallContext::from_exe_with_codex_home(
/*is_macos*/ true,
/*current_exe*/ Some(Path::new("/opt/homebrew/bin/codex")),
/*managed_by_npm*/ false,
/*managed_by_bun*/ false,
/*codex_home*/ None,
);
assert_eq!(context, InstallContext::Brew);
}
}
+82 -2
View File
@@ -1362,6 +1362,8 @@ impl CallToolResult {
fn convert_mcp_content_to_items(
contents: &[serde_json::Value],
) -> Option<Vec<FunctionCallOutputContentItem>> {
const CODEX_IMAGE_DETAIL_META_KEY: &str = "codex/imageDetail";
#[derive(serde::Deserialize)]
#[serde(tag = "type")]
enum McpContent {
@@ -1372,6 +1374,8 @@ fn convert_mcp_content_to_items(
data: String,
#[serde(rename = "mimeType", alias = "mime_type")]
mime_type: Option<String>,
#[serde(rename = "_meta", default)]
meta: Option<serde_json::Value>,
},
#[serde(other)]
Unknown,
@@ -1383,7 +1387,11 @@ fn convert_mcp_content_to_items(
for content in contents {
let item = match serde_json::from_value::<McpContent>(content.clone()) {
Ok(McpContent::Text { text }) => FunctionCallOutputContentItem::InputText { text },
Ok(McpContent::Image { data, mime_type }) => {
Ok(McpContent::Image {
data,
mime_type,
meta,
}) => {
saw_image = true;
let image_url = if data.starts_with("data:") {
data
@@ -1393,7 +1401,15 @@ fn convert_mcp_content_to_items(
};
FunctionCallOutputContentItem::InputImage {
image_url,
detail: None,
detail: meta
.as_ref()
.and_then(serde_json::Value::as_object)
.and_then(|meta| meta.get(CODEX_IMAGE_DETAIL_META_KEY))
.and_then(serde_json::Value::as_str)
.and_then(|detail| match detail {
"original" => Some(ImageDetail::Original),
_ => None,
}),
}
}
Ok(McpContent::Unknown) | Err(_) => FunctionCallOutputContentItem::InputText {
@@ -2264,6 +2280,70 @@ mod tests {
Ok(())
}
#[test]
fn preserves_original_detail_metadata_on_mcp_images() -> Result<()> {
let call_tool_result = CallToolResult {
content: vec![serde_json::json!({
"type": "image",
"data": "BASE64",
"mimeType": "image/png",
"_meta": {
"codex/imageDetail": "original",
},
})],
structured_content: None,
is_error: Some(false),
meta: None,
};
let payload = call_tool_result.into_function_call_output_payload();
let Some(items) = payload.content_items() else {
panic!("expected content items");
};
let items = items.to_vec();
assert_eq!(
items,
vec![FunctionCallOutputContentItem::InputImage {
image_url: "data:image/png;base64,BASE64".into(),
detail: Some(ImageDetail::Original),
}]
);
Ok(())
}
#[test]
fn ignores_unknown_mcp_image_detail_metadata() -> Result<()> {
let call_tool_result = CallToolResult {
content: vec![serde_json::json!({
"type": "image",
"data": "BASE64",
"mimeType": "image/png",
"_meta": {
"codex/imageDetail": "high",
},
})],
structured_content: None,
is_error: Some(false),
meta: None,
};
let payload = call_tool_result.into_function_call_output_payload();
let Some(items) = payload.content_items() else {
panic!("expected content items");
};
let items = items.to_vec();
assert_eq!(
items,
vec![FunctionCallOutputContentItem::InputImage {
image_url: "data:image/png;base64,BASE64".into(),
detail: None,
}]
);
Ok(())
}
#[test]
fn deserializes_array_payload_into_items() -> Result<()> {
let json = r#"[
@@ -208,6 +208,7 @@ impl TestToolServer {
/// - `codex mcp add mcpimg -- /abs/path/to/test_stdio_server`
/// - Then in Codex TUI, ask it to call:
/// - `mcpimg.image_scenario({"scenario":"image_only"})`
/// - `mcpimg.image_scenario({"scenario":"image_only_original_detail"})`
/// - `mcpimg.image_scenario({"scenario":"text_then_image","caption":"Here is the image:"})`
/// - `mcpimg.image_scenario({"scenario":"invalid_base64_then_image"})`
/// - `mcpimg.image_scenario({"scenario":"invalid_image_bytes_then_image"})`
@@ -224,6 +225,7 @@ impl TestToolServer {
"type": "string",
"enum": [
"image_only",
"image_only_original_detail",
"text_then_image",
"invalid_base64_then_image",
"invalid_image_bytes_then_image",
@@ -339,6 +341,7 @@ fn sync_barrier_map() -> &'static tokio::sync::Mutex<HashMap<String, SyncBarrier
/// invalid image.
enum ImageScenario {
ImageOnly,
ImageOnlyOriginalDetail,
TextThenImage,
InvalidBase64ThenImage,
InvalidImageBytesThenImage,
@@ -552,6 +555,21 @@ impl TestToolServer {
ImageScenario::ImageOnly => {
content.push(rmcp::model::Content::image(valid_data_b64, mime_type));
}
ImageScenario::ImageOnlyOriginalDetail => {
let mut meta = rmcp::model::Meta::new();
meta.insert(
"codex/imageDetail".to_string(),
serde_json::json!("original"),
);
content.push(rmcp::model::Annotated::new(
rmcp::model::RawContent::Image(rmcp::model::RawImageContent {
data: valid_data_b64,
mime_type,
meta: Some(meta),
}),
None,
));
}
ImageScenario::TextThenImage => {
content.push(rmcp::model::Content::text(caption));
content.push(rmcp::model::Content::image(valid_data_b64, mime_type));
+18
View File
@@ -1,3 +1,4 @@
use codex_protocol::models::FunctionCallOutputContentItem;
use codex_protocol::models::ImageDetail;
use codex_protocol::openai_models::ModelInfo;
@@ -17,6 +18,23 @@ pub fn normalize_output_image_detail(
}
}
pub fn sanitize_original_image_detail(
can_request_original_image_detail: bool,
items: &mut [FunctionCallOutputContentItem],
) {
if can_request_original_image_detail {
return;
}
for item in items {
if let FunctionCallOutputContentItem::InputImage { detail, .. } = item
&& matches!(detail, Some(ImageDetail::Original))
{
*detail = None;
}
}
}
#[cfg(test)]
#[path = "image_detail_tests.rs"]
mod tests;
+37
View File
@@ -1,4 +1,5 @@
use super::*;
use codex_protocol::models::FunctionCallOutputContentItem;
use codex_protocol::models::ImageDetail;
use codex_protocol::openai_models::ModelInfo;
use pretty_assertions::assert_eq;
@@ -73,3 +74,39 @@ fn unsupported_non_original_detail_is_dropped() {
None
);
}
#[test]
fn sanitize_original_drops_original_without_support() {
let mut items = vec![
FunctionCallOutputContentItem::InputText {
text: "header".to_string(),
},
FunctionCallOutputContentItem::InputImage {
image_url: "data:image/png;base64,AAA".to_string(),
detail: Some(ImageDetail::Original),
},
FunctionCallOutputContentItem::InputImage {
image_url: "data:image/png;base64,BBB".to_string(),
detail: Some(ImageDetail::Low),
},
];
sanitize_original_image_detail(/*can_request_original_image_detail*/ false, &mut items);
assert_eq!(
items,
vec![
FunctionCallOutputContentItem::InputText {
text: "header".to_string(),
},
FunctionCallOutputContentItem::InputImage {
image_url: "data:image/png;base64,AAA".to_string(),
detail: None,
},
FunctionCallOutputContentItem::InputImage {
image_url: "data:image/png;base64,BBB".to_string(),
detail: Some(ImageDetail::Low),
},
]
);
}
+1
View File
@@ -53,6 +53,7 @@ pub use codex_protocol::ToolName;
pub use dynamic_tool::parse_dynamic_tool;
pub use image_detail::can_request_original_image_detail;
pub use image_detail::normalize_output_image_detail;
pub use image_detail::sanitize_original_image_detail;
pub use js_repl_tool::create_js_repl_reset_tool;
pub use js_repl_tool::create_js_repl_tool;
pub use json_schema::AdditionalProperties;
+1 -20
View File
@@ -1,4 +1,4 @@
load("//:defs.bzl", "MACOS_WEBRTC_RUSTC_LINK_FLAGS", "codex_rust_crate", "workspace_root_test")
load("//:defs.bzl", "MACOS_WEBRTC_RUSTC_LINK_FLAGS", "codex_rust_crate")
codex_rust_crate(
name = "tui",
@@ -20,27 +20,8 @@ codex_rust_crate(
"src/**/snapshots/**",
]) + ["//codex-rs/core:model_availability_nux_fixtures"],
integration_compile_data_extra = ["src/test_backend.rs"],
unit_test_args = [
"--skip",
"app::tests::update_memory_settings_updates_current_thread_memory_mode",
],
extra_binaries = [
"//codex-rs/cli:codex",
],
rustc_flags_extra = MACOS_WEBRTC_RUSTC_LINK_FLAGS,
)
workspace_root_test(
name = "tui-update-memory-settings-flaky-test",
env = {
"INSTA_WORKSPACE_ROOT": ".",
"INSTA_SNAPSHOT_PATH": "src",
},
test_bin = ":tui-unit-tests-bin",
workspace_root_marker = "//codex-rs/utils/cargo-bin:repo_root.marker",
args = [
"--exact",
"app::tests::update_memory_settings_updates_current_thread_memory_mode",
],
tags = ["flaky"],
)
+1
View File
@@ -29,6 +29,7 @@ codex-ansi-escape = { workspace = true }
codex-app-server-client = { workspace = true }
codex-app-server-protocol = { workspace = true }
codex-arg0 = { workspace = true }
codex-install-context = { workspace = true }
codex-chatgpt = { workspace = true }
codex-cloud-requirements = { workspace = true }
codex-config = { workspace = true }
+73 -62
View File
@@ -1,3 +1,8 @@
#[cfg(any(not(debug_assertions), test))]
use codex_install_context::InstallContext;
#[cfg(any(not(debug_assertions), test))]
use codex_install_context::StandalonePlatform;
/// Update action the CLI should perform after the TUI exits.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum UpdateAction {
@@ -7,15 +12,41 @@ pub enum UpdateAction {
BunGlobalLatest,
/// Update via `brew upgrade codex`.
BrewUpgrade,
/// Update via `curl -fsSL https://chatgpt.com/codex/install.sh | sh`.
StandaloneUnix,
/// Update via `irm https://chatgpt.com/codex/install.ps1|iex`.
StandaloneWindows,
}
impl UpdateAction {
#[cfg(any(not(debug_assertions), test))]
pub(crate) fn from_install_context(context: &InstallContext) -> Option<Self> {
match context {
InstallContext::Npm => Some(UpdateAction::NpmGlobalLatest),
InstallContext::Bun => Some(UpdateAction::BunGlobalLatest),
InstallContext::Brew => Some(UpdateAction::BrewUpgrade),
InstallContext::Standalone { platform, .. } => Some(match platform {
StandalonePlatform::Unix => UpdateAction::StandaloneUnix,
StandalonePlatform::Windows => UpdateAction::StandaloneWindows,
}),
InstallContext::Other => None,
}
}
/// Returns the list of command-line arguments for invoking the update.
pub fn command_args(self) -> (&'static str, &'static [&'static str]) {
match self {
UpdateAction::NpmGlobalLatest => ("npm", &["install", "-g", "@openai/codex"]),
UpdateAction::BunGlobalLatest => ("bun", &["install", "-g", "@openai/codex"]),
UpdateAction::BrewUpgrade => ("brew", &["upgrade", "--cask", "codex"]),
UpdateAction::StandaloneUnix => (
"sh",
&["-c", "curl -fsSL https://chatgpt.com/codex/install.sh | sh"],
),
UpdateAction::StandaloneWindows => (
"powershell",
&["-c", "irm https://chatgpt.com/codex/install.ps1|iex"],
),
}
}
@@ -29,88 +60,68 @@ impl UpdateAction {
#[cfg(not(debug_assertions))]
pub(crate) fn get_update_action() -> Option<UpdateAction> {
let exe = std::env::current_exe().unwrap_or_default();
let managed_by_npm = std::env::var_os("CODEX_MANAGED_BY_NPM").is_some();
let managed_by_bun = std::env::var_os("CODEX_MANAGED_BY_BUN").is_some();
detect_update_action(
cfg!(target_os = "macos"),
&exe,
managed_by_npm,
managed_by_bun,
)
}
#[cfg(any(not(debug_assertions), test))]
fn detect_update_action(
is_macos: bool,
current_exe: &std::path::Path,
managed_by_npm: bool,
managed_by_bun: bool,
) -> Option<UpdateAction> {
if managed_by_npm {
Some(UpdateAction::NpmGlobalLatest)
} else if managed_by_bun {
Some(UpdateAction::BunGlobalLatest)
} else if is_macos
&& (current_exe.starts_with("/opt/homebrew") || current_exe.starts_with("/usr/local"))
{
Some(UpdateAction::BrewUpgrade)
} else {
None
}
UpdateAction::from_install_context(InstallContext::current())
}
#[cfg(test)]
mod tests {
use super::*;
use pretty_assertions::assert_eq;
use std::path::PathBuf;
#[test]
fn detects_update_action_without_env_mutation() {
fn maps_install_context_to_update_action() {
let native_release_dir = PathBuf::from("/tmp/native-release");
assert_eq!(
detect_update_action(
/*is_macos*/ false,
std::path::Path::new("/any/path"),
/*managed_by_npm*/ false,
/*managed_by_bun*/ false
),
UpdateAction::from_install_context(&InstallContext::Other),
None
);
assert_eq!(
detect_update_action(
/*is_macos*/ false,
std::path::Path::new("/any/path"),
/*managed_by_npm*/ true,
/*managed_by_bun*/ false
),
UpdateAction::from_install_context(&InstallContext::Npm),
Some(UpdateAction::NpmGlobalLatest)
);
assert_eq!(
detect_update_action(
/*is_macos*/ false,
std::path::Path::new("/any/path"),
/*managed_by_npm*/ false,
/*managed_by_bun*/ true
),
UpdateAction::from_install_context(&InstallContext::Bun),
Some(UpdateAction::BunGlobalLatest)
);
assert_eq!(
detect_update_action(
/*is_macos*/ true,
std::path::Path::new("/opt/homebrew/bin/codex"),
/*managed_by_npm*/ false,
/*managed_by_bun*/ false
),
UpdateAction::from_install_context(&InstallContext::Brew),
Some(UpdateAction::BrewUpgrade)
);
assert_eq!(
detect_update_action(
/*is_macos*/ true,
std::path::Path::new("/usr/local/bin/codex"),
/*managed_by_npm*/ false,
/*managed_by_bun*/ false
),
Some(UpdateAction::BrewUpgrade)
UpdateAction::from_install_context(&InstallContext::Standalone {
platform: StandalonePlatform::Unix,
release_dir: native_release_dir.clone(),
resources_dir: Some(native_release_dir.join("codex-resources")),
}),
Some(UpdateAction::StandaloneUnix)
);
assert_eq!(
UpdateAction::from_install_context(&InstallContext::Standalone {
platform: StandalonePlatform::Windows,
release_dir: native_release_dir.clone(),
resources_dir: Some(native_release_dir.join("codex-resources")),
}),
Some(UpdateAction::StandaloneWindows)
);
}
#[test]
fn standalone_update_commands_rerun_latest_installer() {
assert_eq!(
UpdateAction::StandaloneUnix.command_args(),
(
"sh",
&["-c", "curl -fsSL https://chatgpt.com/codex/install.sh | sh"][..],
)
);
assert_eq!(
UpdateAction::StandaloneWindows.command_args(),
(
"powershell",
&["-c", "irm https://chatgpt.com/codex/install.ps1|iex"][..],
)
);
}
}
@@ -13,6 +13,8 @@ use tempfile::NamedTempFile;
use crate::logging::log_note;
use crate::sandbox_bin_dir;
const RESOURCES_DIRNAME: &str = "codex-resources";
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
pub(crate) enum HelperExecutable {
CommandRunner,
@@ -46,12 +48,9 @@ pub(crate) fn helper_bin_dir(codex_home: &Path) -> PathBuf {
pub(crate) fn legacy_lookup(kind: HelperExecutable) -> PathBuf {
if let Ok(exe) = std::env::current_exe()
&& let Some(dir) = exe.parent()
&& let Some(candidate) = source_path_for_exe(&exe, kind.file_name())
{
let candidate = dir.join(kind.file_name());
if candidate.exists() {
return candidate;
}
return candidate;
}
PathBuf::from(kind.file_name())
}
@@ -179,18 +178,23 @@ fn store_helper_path(cache_key: String, path: PathBuf) {
fn sibling_source_path(kind: HelperExecutable) -> Result<PathBuf> {
let exe = std::env::current_exe().context("resolve current executable for helper lookup")?;
let dir = exe
.parent()
.ok_or_else(|| anyhow!("current executable has no parent directory"))?;
let candidate = dir.join(kind.file_name());
if candidate.exists() {
Ok(candidate)
} else {
Err(anyhow!(
"helper not found next to current executable: {}",
candidate.display()
))
source_path_for_exe(&exe, kind.file_name()).ok_or_else(|| {
anyhow!(
"helper not found next to current executable or under {RESOURCES_DIRNAME}: {}",
exe.display()
)
})
}
fn source_path_for_exe(exe: &Path, file_name: &str) -> Option<PathBuf> {
let dir = exe.parent()?;
let direct_candidate = dir.join(file_name);
if direct_candidate.exists() {
return Some(direct_candidate);
}
let resource_candidate = dir.join(RESOURCES_DIRNAME).join(file_name);
resource_candidate.exists().then_some(resource_candidate)
}
fn copy_from_source_if_needed(source: &Path, destination: &Path) -> Result<CopyOutcome> {
@@ -292,10 +296,12 @@ fn destination_is_fresh(source: &Path, destination: &Path) -> Result<bool> {
#[cfg(test)]
mod tests {
use super::destination_is_fresh;
use super::helper_bin_dir;
use super::copy_from_source_if_needed;
use super::CopyOutcome;
use super::destination_is_fresh;
use super::helper_bin_dir;
use super::RESOURCES_DIRNAME;
use super::source_path_for_exe;
use pretty_assertions::assert_eq;
use std::fs;
use std::path::Path;
@@ -376,4 +382,40 @@ mod tests {
fs::read(&runner_destination).expect("read runner")
);
}
#[test]
fn helper_source_lookup_checks_resource_dir() {
let tmp = TempDir::new().expect("tempdir");
let release_dir = tmp.path().join("release");
let resources_dir = release_dir.join(RESOURCES_DIRNAME);
fs::create_dir_all(&resources_dir).expect("create resources dir");
let exe = release_dir.join("codex.exe");
let helper = resources_dir.join("codex-command-runner.exe");
fs::write(&exe, b"codex").expect("write exe");
fs::write(&helper, b"runner").expect("write helper");
let resolved =
source_path_for_exe(&exe, /*file_name*/ "codex-command-runner.exe").expect("helper path");
assert_eq!(resolved, helper);
}
#[test]
fn helper_source_lookup_prefers_direct_sibling_over_resource_dir() {
let tmp = TempDir::new().expect("tempdir");
let release_dir = tmp.path().join("release");
let resources_dir = release_dir.join(RESOURCES_DIRNAME);
fs::create_dir_all(&resources_dir).expect("create resources dir");
let exe = release_dir.join("codex.exe");
let sibling_helper = release_dir.join("codex-command-runner.exe");
let resource_helper = resources_dir.join("codex-command-runner.exe");
fs::write(&exe, b"codex").expect("write exe");
fs::write(&sibling_helper, b"sibling runner").expect("write sibling helper");
fs::write(&resource_helper, b"resource runner").expect("write resource helper");
let resolved =
source_path_for_exe(&exe, /*file_name*/ "codex-command-runner.exe").expect("helper path");
assert_eq!(resolved, sibling_helper);
}
}
@@ -589,6 +589,16 @@ fn find_setup_exe() -> PathBuf {
if candidate.exists() {
return candidate;
}
// Standalone installs keep Windows helper binaries under
// `codex-resources/` next to `codex.exe`, so elevation needs to probe
// that sibling folder before falling back to PATH.
let resource_candidate = dir
.join("codex-resources")
.join("codex-windows-sandbox-setup.exe");
if resource_candidate.exists() {
return resource_candidate;
}
}
PathBuf::from("codex-windows-sandbox-setup.exe")
}