Compare commits

...

6 Commits

Author SHA1 Message Date
Ahmed Ibrahim
b44337ee5e auth 2026-03-18 15:16:27 -07:00
Ahmed Ibrahim
50c8c745eb auth 2026-03-18 15:16:22 -07:00
Eric Traut
e5de13644d Add a startup deprecation warning for custom prompts (#15076)
## Summary
- detect custom prompts in `$CODEX_HOME/prompts` during TUI startup
- show a deprecation notice only when prompts are present, with guidance
to use `$skill-creator`
- add TUI tests and snapshot coverage for present, missing, and empty
prompts directories

## Testing
- Manually tested
2026-03-18 15:21:30 -06:00
pakrym-oai
5cada46ddf Return image URL from view_image tool (#15072)
Cleanup image semantics in code mode.

`view_image` now returns `{image_url:string, details?: string}` 

`image()` now allows both string parameter and `{image_url:string,
details?: string}`
2026-03-18 13:58:20 -07:00
pakrym-oai
88e5382fc4 Propagate tool errors to code mode (#15075)
Clean up error flow to push the FunctionCallError all the way up to
dispatcher and allow code mode to surface as exception.
2026-03-18 13:57:55 -07:00
Michael Bolin
392347d436 fix: try to fix "Stage npm package" step in ci.yml (#15092)
Fix the CI job by updating it to use artifacts from a more recent
release (`0.115.0`) instead of the existing one (`0.74.0`).

This step in our CI job on PRs started failing today:


334164a6f7/.github/workflows/ci.yml (L33-L47)

I believe it's because this test verifies that the "package npm" script
works, but we want it to be fast and not wait for binaries to be built,
so it uses a GitHub workflow that's already done. Because it was using a
GitHub workflow associated with `0.74.0`, it seems likely that
workflow's history has been reaped, so we need to use a newer one.
2026-03-18 13:52:33 -07:00
30 changed files with 612 additions and 283 deletions

View File

@@ -37,7 +37,7 @@ jobs:
run: |
set -euo pipefail
# Use a rust-release version that includes all native binaries.
CODEX_VERSION=0.74.0
CODEX_VERSION=0.115.0
OUTPUT_DIR="${RUNNER_TEMP}"
python3 ./scripts/stage_npm_packages.py \
--release-version "$CODEX_VERSION" \

9
codex-rs/Cargo.lock generated
View File

@@ -1935,6 +1935,13 @@ dependencies = [
"zstd",
]
[[package]]
name = "codex-core-auth"
version = "0.0.0"
dependencies = [
"codex-core",
]
[[package]]
name = "codex-debug-client"
version = "0.0.0"
@@ -2327,7 +2334,6 @@ dependencies = [
"icu_decimal",
"icu_locale_core",
"icu_provider",
"mime_guess",
"pretty_assertions",
"schemars 0.8.22",
"serde",
@@ -2758,6 +2764,7 @@ dependencies = [
"base64 0.22.1",
"codex-utils-cache",
"image",
"mime_guess",
"thiserror 2.0.18",
"tokio",
]

View File

@@ -22,6 +22,7 @@ members = [
"shell-escalation",
"skills",
"core",
"core/auth",
"environment",
"hooks",
"secrets",
@@ -104,6 +105,7 @@ codex-cloud-requirements = { path = "cloud-requirements" }
codex-connectors = { path = "connectors" }
codex-config = { path = "config" }
codex-core = { path = "core" }
codex-core-auth = { path = "core/auth" }
codex-environment = { path = "environment" }
codex-exec = { path = "exec" }
codex-execpolicy = { path = "execpolicy" }

View File

@@ -0,0 +1,6 @@
load("//:defs.bzl", "codex_rust_crate")
codex_rust_crate(
name = "auth",
crate_name = "codex_core_auth",
)

View File

@@ -0,0 +1,15 @@
[package]
name = "codex-core-auth"
version.workspace = true
edition.workspace = true
license.workspace = true
[lib]
name = "codex_core_auth"
path = "src/lib.rs"
[lints]
workspace = true
[dependencies]
codex-core = { workspace = true }

View File

@@ -0,0 +1 @@
pub use codex_core::auth::*;

View File

@@ -27,7 +27,6 @@ use crate::config::Config;
use crate::error::RefreshTokenFailedError;
use crate::error::RefreshTokenFailedReason;
use crate::token_data::KnownPlan as InternalKnownPlan;
use crate::token_data::PlanType as InternalPlanType;
use crate::token_data::TokenData;
use crate::token_data::parse_chatgpt_jwt_claims;
use crate::util::try_parse_error_message;
@@ -752,67 +751,6 @@ fn refresh_token_endpoint() -> String {
.unwrap_or_else(|_| REFRESH_TOKEN_URL.to_string())
}
impl AuthDotJson {
fn from_external_tokens(external: &ExternalAuthTokens) -> std::io::Result<Self> {
let mut token_info =
parse_chatgpt_jwt_claims(&external.access_token).map_err(std::io::Error::other)?;
token_info.chatgpt_account_id = Some(external.chatgpt_account_id.clone());
token_info.chatgpt_plan_type = external
.chatgpt_plan_type
.as_deref()
.map(InternalPlanType::from_raw_value)
.or(token_info.chatgpt_plan_type)
.or(Some(InternalPlanType::Unknown("unknown".to_string())));
let tokens = TokenData {
id_token: token_info,
access_token: external.access_token.clone(),
refresh_token: String::new(),
account_id: Some(external.chatgpt_account_id.clone()),
};
Ok(Self {
auth_mode: Some(ApiAuthMode::ChatgptAuthTokens),
openai_api_key: None,
tokens: Some(tokens),
last_refresh: Some(Utc::now()),
})
}
fn from_external_access_token(
access_token: &str,
chatgpt_account_id: &str,
chatgpt_plan_type: Option<&str>,
) -> std::io::Result<Self> {
let external = ExternalAuthTokens {
access_token: access_token.to_string(),
chatgpt_account_id: chatgpt_account_id.to_string(),
chatgpt_plan_type: chatgpt_plan_type.map(str::to_string),
};
Self::from_external_tokens(&external)
}
fn resolved_mode(&self) -> ApiAuthMode {
if let Some(mode) = self.auth_mode {
return mode;
}
if self.openai_api_key.is_some() {
return ApiAuthMode::ApiKey;
}
ApiAuthMode::Chatgpt
}
fn storage_mode(
&self,
auth_credentials_store_mode: AuthCredentialsStoreMode,
) -> AuthCredentialsStoreMode {
if self.resolved_mode() == ApiAuthMode::ChatgptAuthTokens {
AuthCredentialsStoreMode::Ephemeral
} else {
auth_credentials_store_mode
}
}
}
/// Internal cached auth state.
#[derive(Clone)]
struct CachedAuth {
@@ -1412,8 +1350,12 @@ impl AuthManager {
),
)));
}
let auth_dot_json =
AuthDotJson::from_external_tokens(&refreshed).map_err(RefreshTokenError::Transient)?;
let auth_dot_json = AuthDotJson::from_external_access_token(
&refreshed.access_token,
&refreshed.chatgpt_account_id,
refreshed.chatgpt_plan_type.as_deref(),
)
.map_err(RefreshTokenError::Transient)?;
save_auth(
&self.codex_home,
&auth_dot_json,

View File

@@ -19,7 +19,9 @@ use std::sync::Arc;
use std::sync::Mutex;
use tracing::warn;
use crate::token_data::PlanType;
use crate::token_data::TokenData;
use crate::token_data::parse_chatgpt_jwt_claims;
use codex_app_server_protocol::AuthMode;
use codex_keyring_store::DefaultKeyringStore;
use codex_keyring_store::KeyringStore;
@@ -56,6 +58,56 @@ pub struct AuthDotJson {
pub last_refresh: Option<DateTime<Utc>>,
}
impl AuthDotJson {
pub(super) fn from_external_access_token(
access_token: &str,
chatgpt_account_id: &str,
chatgpt_plan_type: Option<&str>,
) -> std::io::Result<Self> {
let mut token_info =
parse_chatgpt_jwt_claims(access_token).map_err(std::io::Error::other)?;
token_info.chatgpt_account_id = Some(chatgpt_account_id.to_string());
token_info.chatgpt_plan_type = chatgpt_plan_type
.map(PlanType::from_raw_value)
.or(token_info.chatgpt_plan_type)
.or(Some(PlanType::Unknown("unknown".to_string())));
let tokens = TokenData {
id_token: token_info,
access_token: access_token.to_string(),
refresh_token: String::new(),
account_id: Some(chatgpt_account_id.to_string()),
};
Ok(Self {
auth_mode: Some(AuthMode::ChatgptAuthTokens),
openai_api_key: None,
tokens: Some(tokens),
last_refresh: Some(Utc::now()),
})
}
pub(super) fn resolved_mode(&self) -> AuthMode {
if let Some(mode) = self.auth_mode {
return mode;
}
if self.openai_api_key.is_some() {
return AuthMode::ApiKey;
}
AuthMode::Chatgpt
}
pub(super) fn storage_mode(
&self,
auth_credentials_store_mode: AuthCredentialsStoreMode,
) -> AuthCredentialsStoreMode {
if self.resolved_mode() == AuthMode::ChatgptAuthTokens {
AuthCredentialsStoreMode::Ephemeral
} else {
auth_credentials_store_mode
}
}
}
pub(super) fn get_auth_file(codex_home: &Path) -> PathBuf {
codex_home.join("auth.json")
}

View File

@@ -4557,7 +4557,7 @@ async fn fatal_tool_error_stops_turn_and_reports_error() {
.expect("tool call present");
let tracker = Arc::new(tokio::sync::Mutex::new(TurnDiffTracker::new()));
let err = router
.dispatch_tool_call(
.dispatch_tool_call_with_code_mode_result(
Arc::clone(&session),
Arc::clone(&turn_context),
tracker,
@@ -4565,7 +4565,8 @@ async fn fatal_tool_error_stops_turn_and_reports_error() {
ToolCallSource::Direct,
)
.await
.expect_err("expected fatal error");
.err()
.expect("expected fatal error");
match err {
FunctionCallError::Fatal(message) => {

View File

@@ -11,7 +11,7 @@
- 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 and returns it. Non-string values are stringified with `JSON.stringify(...)` when possible.
- `image(imageUrl: string)`: Appends an image item and returns it. `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 })`: Appends an image item and returns it. `image_url` can be an HTTPS URL or a base64-encoded `data:` URL.
- `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(...)`.

View File

@@ -14,6 +14,7 @@ use serde_json::Value as JsonValue;
use crate::client_common::tools::ToolSpec;
use crate::codex::Session;
use crate::codex::TurnContext;
use crate::function_tool::FunctionCallError;
use crate::tools::ToolRouter;
use crate::tools::code_mode_description::augment_tool_spec_for_code_mode;
use crate::tools::code_mode_description::code_mode_tool_reference;
@@ -303,9 +304,11 @@ async fn call_nested_tool(
tool_name: String,
input: Option<JsonValue>,
cancellation_token: tokio_util::sync::CancellationToken,
) -> JsonValue {
) -> Result<JsonValue, FunctionCallError> {
if tool_name == PUBLIC_TOOL_NAME {
return JsonValue::String(format!("{PUBLIC_TOOL_NAME} cannot invoke itself"));
return Err(FunctionCallError::RespondToModel(format!(
"{PUBLIC_TOOL_NAME} cannot invoke itself"
)));
}
let payload =
@@ -316,12 +319,12 @@ async fn call_nested_tool(
tool,
raw_arguments,
},
Err(error) => return JsonValue::String(error),
Err(error) => return Err(FunctionCallError::RespondToModel(error)),
}
} else {
match build_nested_tool_payload(tool_runtime.find_spec(&tool_name), &tool_name, input) {
Ok(payload) => payload,
Err(error) => return JsonValue::String(error),
Err(error) => return Err(FunctionCallError::RespondToModel(error)),
}
};
@@ -333,12 +336,8 @@ async fn call_nested_tool(
};
let result = tool_runtime
.handle_tool_call_with_source(call, ToolCallSource::CodeMode, cancellation_token)
.await;
match result {
Ok(result) => result.code_mode_result(),
Err(error) => JsonValue::String(error.to_string()),
}
.await?;
Ok(result.code_mode_result())
}
fn tool_kind_for_spec(spec: &ToolSpec) -> protocol::CodeModeToolKind {

View File

@@ -70,6 +70,8 @@ pub(super) enum HostToNodeMessage {
request_id: String,
id: String,
code_mode_result: JsonValue,
#[serde(default)]
error_text: Option<String>,
},
}

View File

@@ -223,14 +223,48 @@ function codeModeWorkerMain() {
return String(value);
}
function normalizeOutputImageUrl(value) {
if (typeof value !== 'string' || !value) {
throw new TypeError('image expects a non-empty image URL string');
function normalizeOutputImage(value) {
let imageUrl;
let detail;
if (typeof value === 'string') {
imageUrl = value;
} else if (
value &&
typeof value === 'object' &&
!Array.isArray(value)
) {
if (typeof value.image_url === 'string') {
imageUrl = value.image_url;
}
if (typeof value.detail === 'string') {
detail = value.detail;
} else if (
Object.prototype.hasOwnProperty.call(value, 'detail') &&
value.detail !== null &&
typeof value.detail !== 'undefined'
) {
throw new TypeError('image detail must be a string when provided');
}
}
if (/^(?:https?:\/\/|data:)/i.test(value)) {
return value;
if (typeof imageUrl !== 'string' || !imageUrl) {
throw new TypeError(
'image expects a non-empty image URL string or an object with image_url and optional detail'
);
}
throw new TypeError('image expects an http(s) or data URL');
if (!/^(?:https?:\/\/|data:)/i.test(imageUrl)) {
throw new TypeError('image expects an http(s) or data URL');
}
if (typeof detail !== 'undefined' && !/^(?:auto|low|high|original)$/i.test(detail)) {
throw new TypeError('image detail must be one of: auto, low, high, original');
}
const normalized = { image_url: imageUrl };
if (typeof detail === 'string') {
normalized.detail = detail.toLowerCase();
}
return normalized;
}
function createCodeModeHelpers(context, state, toolCallId) {
@@ -258,10 +292,7 @@ function codeModeWorkerMain() {
return item;
};
const image = (value) => {
const item = {
type: 'input_image',
image_url: normalizeOutputImageUrl(value),
};
const item = Object.assign({ type: 'input_image' }, normalizeOutputImage(value));
ensureContentItems(context).push(item);
return item;
};
@@ -595,6 +626,10 @@ function createProtocol() {
return;
}
pending.delete(message.request_id + ':' + message.id);
if (typeof message.error_text === 'string') {
entry.reject(new Error(message.error_text));
return;
}
entry.resolve(message.code_mode_result ?? '');
return;
}

View File

@@ -14,6 +14,7 @@ use super::process::write_message;
use super::protocol::HostToNodeMessage;
use super::protocol::NodeToHostMessage;
use crate::tools::parallel::ToolCallRuntime;
pub(crate) struct CodeModeWorker {
shutdown_tx: Option<oneshot::Sender<()>>,
}
@@ -53,17 +54,23 @@ impl CodeModeProcess {
let tool_runtime = tool_runtime.clone();
let stdin = stdin.clone();
tokio::spawn(async move {
let result = call_nested_tool(
exec,
tool_runtime,
tool_call.name,
tool_call.input,
CancellationToken::new(),
)
.await;
let (code_mode_result, error_text) = match result {
Ok(code_mode_result) => (code_mode_result, None),
Err(error) => (serde_json::Value::Null, Some(error.to_string())),
};
let response = HostToNodeMessage::Response {
request_id: tool_call.request_id,
id: tool_call.id,
code_mode_result: call_nested_tool(
exec,
tool_runtime,
tool_call.name,
tool_call.input,
CancellationToken::new(),
)
.await,
code_mode_result,
error_text,
};
if let Err(err) = write_message(&stdin, &response).await {
warn!("failed to write {PUBLIC_TOOL_NAME} tool response: {err}");

View File

@@ -1,20 +1,22 @@
use async_trait::async_trait;
use codex_environment::ExecutorFileSystem;
use codex_protocol::models::ContentItem;
use codex_protocol::models::FunctionCallOutputBody;
use codex_protocol::models::FunctionCallOutputContentItem;
use codex_protocol::models::FunctionCallOutputPayload;
use codex_protocol::models::ImageDetail;
use codex_protocol::models::local_image_content_items_with_label_number;
use codex_protocol::models::ResponseInputItem;
use codex_protocol::openai_models::InputModality;
use codex_utils_absolute_path::AbsolutePathBuf;
use codex_utils_image::PromptImageMode;
use codex_utils_image::load_for_prompt_bytes;
use serde::Deserialize;
use crate::function_tool::FunctionCallError;
use crate::original_image_detail::can_request_original_image_detail;
use crate::protocol::EventMsg;
use crate::protocol::ViewImageToolCallEvent;
use crate::tools::context::FunctionToolOutput;
use crate::tools::context::ToolInvocation;
use crate::tools::context::ToolOutput;
use crate::tools::context::ToolPayload;
use crate::tools::handlers::parse_arguments;
use crate::tools::registry::ToolHandler;
@@ -38,7 +40,7 @@ enum ViewImageDetail {
#[async_trait]
impl ToolHandler for ViewImageHandler {
type Output = FunctionToolOutput;
type Output = ViewImageOutput;
fn kind(&self) -> ToolKind {
ToolKind::Function
@@ -135,22 +137,14 @@ impl ToolHandler for ViewImageHandler {
};
let image_detail = use_original_detail.then_some(ImageDetail::Original);
let content = local_image_content_items_with_label_number(
abs_path.as_path(),
file_bytes,
/*label_number*/ None,
image_mode,
)
.into_iter()
.map(|item| match item {
ContentItem::InputText { text } => FunctionCallOutputContentItem::InputText { text },
ContentItem::InputImage { image_url } => FunctionCallOutputContentItem::InputImage {
image_url,
detail: image_detail,
},
ContentItem::OutputText { text } => FunctionCallOutputContentItem::InputText { text },
})
.collect();
let image =
load_for_prompt_bytes(abs_path.as_path(), file_bytes, image_mode).map_err(|error| {
FunctionCallError::RespondToModel(format!(
"unable to process image at `{}`: {error}",
abs_path.display()
))
})?;
let image_url = image.into_data_url();
session
.send_event(
@@ -162,6 +156,75 @@ impl ToolHandler for ViewImageHandler {
)
.await;
Ok(FunctionToolOutput::from_content(content, Some(true)))
Ok(ViewImageOutput {
image_url,
image_detail,
})
}
}
pub struct ViewImageOutput {
image_url: String,
image_detail: Option<ImageDetail>,
}
impl ToolOutput for ViewImageOutput {
fn log_preview(&self) -> String {
self.image_url.clone()
}
fn success_for_logging(&self) -> bool {
true
}
fn to_response_item(&self, call_id: &str, _payload: &ToolPayload) -> ResponseInputItem {
let body =
FunctionCallOutputBody::ContentItems(vec![FunctionCallOutputContentItem::InputImage {
image_url: self.image_url.clone(),
detail: self.image_detail,
}]);
let output = FunctionCallOutputPayload {
body,
success: Some(true),
};
ResponseInputItem::FunctionCallOutput {
call_id: call_id.to_string(),
output,
}
}
fn code_mode_result(&self, _payload: &ToolPayload) -> serde_json::Value {
serde_json::json!({
"image_url": self.image_url,
"detail": self.image_detail
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use pretty_assertions::assert_eq;
use serde_json::json;
#[test]
fn code_mode_result_returns_image_url_object() {
let output = ViewImageOutput {
image_url: "data:image/png;base64,AAA".to_string(),
image_detail: None,
};
let result = output.code_mode_result(&ToolPayload::Function {
arguments: "{}".to_string(),
});
assert_eq!(
result,
json!({
"image_url": "data:image/png;base64,AAA",
"detail": null,
})
);
}
}

View File

@@ -1607,8 +1607,8 @@ impl JsReplManager {
let tracker = Arc::clone(&exec.tracker);
match router
.dispatch_tool_call(
session.clone(),
.dispatch_tool_call_with_code_mode_result(
session,
turn,
tracker,
call,
@@ -1616,7 +1616,8 @@ impl JsReplManager {
)
.await
{
Ok(response) => {
Ok(result) => {
let response = result.into_response();
let summary = Self::summarize_tool_call_response(&response);
match serde_json::to_value(response) {
Ok(value) => {

View File

@@ -16,6 +16,7 @@ use crate::error::CodexErr;
use crate::function_tool::FunctionCallError;
use crate::tools::context::AbortedToolOutput;
use crate::tools::context::SharedTurnDiffTracker;
use crate::tools::context::ToolPayload;
use crate::tools::registry::AnyToolResult;
use crate::tools::router::ToolCall;
use crate::tools::router::ToolCallSource;
@@ -57,9 +58,17 @@ impl ToolCallRuntime {
call: ToolCall,
cancellation_token: CancellationToken,
) -> impl std::future::Future<Output = Result<ResponseInputItem, CodexErr>> {
let error_call = call.clone();
let future =
self.handle_tool_call_with_source(call, ToolCallSource::Direct, cancellation_token);
async move { future.await.map(AnyToolResult::into_response) }.in_current_span()
async move {
match future.await {
Ok(response) => Ok(response.into_response()),
Err(FunctionCallError::Fatal(message)) => Err(CodexErr::Fatal(message)),
Err(other) => Ok(Self::failure_response(error_call, other)),
}
}
.in_current_span()
}
#[instrument(level = "trace", skip_all)]
@@ -68,7 +77,7 @@ impl ToolCallRuntime {
call: ToolCall,
source: ToolCallSource,
cancellation_token: CancellationToken,
) -> impl std::future::Future<Output = Result<AnyToolResult, CodexErr>> {
) -> impl std::future::Future<Output = Result<AnyToolResult, FunctionCallError>> {
let supports_parallel = self.router.tool_supports_parallel(&call.tool_name);
let router = Arc::clone(&self.router);
let session = Arc::clone(&self.session);
@@ -78,7 +87,7 @@ impl ToolCallRuntime {
let started = Instant::now();
let dispatch_span = trace_span!(
"dispatch_tool_call",
"dispatch_tool_call_with_code_mode_result",
otel.name = call.tool_name.as_str(),
tool_name = call.tool_name.as_str(),
call_id = call.call_id.as_str(),
@@ -115,20 +124,42 @@ impl ToolCallRuntime {
}));
async move {
match handle.await {
Ok(Ok(response)) => Ok(response),
Ok(Err(FunctionCallError::Fatal(message))) => Err(CodexErr::Fatal(message)),
Ok(Err(other)) => Err(CodexErr::Fatal(other.to_string())),
Err(err) => Err(CodexErr::Fatal(format!(
"tool task failed to receive: {err:?}"
))),
}
handle.await.map_err(|err| {
FunctionCallError::Fatal(format!("tool task failed to receive: {err:?}"))
})?
}
.in_current_span()
}
}
impl ToolCallRuntime {
fn failure_response(call: ToolCall, err: FunctionCallError) -> ResponseInputItem {
let message = err.to_string();
match call.payload {
ToolPayload::ToolSearch { .. } => ResponseInputItem::ToolSearchOutput {
call_id: call.call_id,
status: "completed".to_string(),
execution: "client".to_string(),
tools: Vec::new(),
},
ToolPayload::Custom { .. } => ResponseInputItem::CustomToolCallOutput {
call_id: call.call_id,
name: None,
output: codex_protocol::models::FunctionCallOutputPayload {
body: codex_protocol::models::FunctionCallOutputBody::Text(message),
success: Some(false),
},
},
_ => ResponseInputItem::FunctionCallOutput {
call_id: call.call_id,
output: codex_protocol::models::FunctionCallOutputPayload {
body: codex_protocol::models::FunctionCallOutputBody::Text(message),
success: Some(false),
},
},
}
}
fn aborted_response(call: &ToolCall, secs: f32) -> AnyToolResult {
AnyToolResult {
call_id: call.call_id.clone(),

View File

@@ -5,11 +5,9 @@ use crate::function_tool::FunctionCallError;
use crate::mcp_connection_manager::ToolInfo;
use crate::sandboxing::SandboxPermissions;
use crate::tools::code_mode::is_code_mode_nested_tool;
use crate::tools::context::FunctionToolOutput;
use crate::tools::context::SharedTurnDiffTracker;
use crate::tools::context::ToolInvocation;
use crate::tools::context::ToolPayload;
use crate::tools::context::ToolSearchOutput;
use crate::tools::discoverable::DiscoverableTool;
use crate::tools::registry::AnyToolResult;
use crate::tools::registry::ConfiguredToolSpec;
@@ -18,7 +16,6 @@ use crate::tools::spec::ToolsConfig;
use crate::tools::spec::build_specs_with_discoverable_tools;
use codex_protocol::dynamic_tools::DynamicToolSpec;
use codex_protocol::models::LocalShellAction;
use codex_protocol::models::ResponseInputItem;
use codex_protocol::models::ResponseItem;
use codex_protocol::models::SearchToolCallParams;
use codex_protocol::models::ShellToolCallParams;
@@ -214,21 +211,6 @@ impl ToolRouter {
}
}
#[instrument(level = "trace", skip_all, err)]
pub async fn dispatch_tool_call(
&self,
session: Arc<Session>,
turn: Arc<TurnContext>,
tracker: SharedTurnDiffTracker,
call: ToolCall,
source: ToolCallSource,
) -> Result<ResponseInputItem, FunctionCallError> {
Ok(self
.dispatch_tool_call_with_code_mode_result(session, turn, tracker, call, source)
.await?
.into_response())
}
#[instrument(level = "trace", skip_all, err)]
pub async fn dispatch_tool_call_with_code_mode_result(
&self,
@@ -244,23 +226,14 @@ impl ToolRouter {
call_id,
payload,
} = call;
let payload_outputs_custom = matches!(payload, ToolPayload::Custom { .. });
let payload_outputs_tool_search = matches!(payload, ToolPayload::ToolSearch { .. });
let failure_call_id = call_id.clone();
if source == ToolCallSource::Direct
&& turn.tools_config.js_repl_tools_only
&& !matches!(tool_name.as_str(), "js_repl" | "js_repl_reset")
{
let err = FunctionCallError::RespondToModel(
return Err(FunctionCallError::RespondToModel(
"direct tool calls are disabled; use js_repl and codex.tool(...) instead"
.to_string(),
);
return Ok(Self::failure_result(
failure_call_id,
payload_outputs_custom,
payload_outputs_tool_search,
err,
));
}
@@ -274,53 +247,7 @@ impl ToolRouter {
payload,
};
match self.registry.dispatch_any(invocation).await {
Ok(response) => Ok(response),
Err(FunctionCallError::Fatal(message)) => Err(FunctionCallError::Fatal(message)),
Err(err) => Ok(Self::failure_result(
failure_call_id,
payload_outputs_custom,
payload_outputs_tool_search,
err,
)),
}
}
fn failure_result(
call_id: String,
payload_outputs_custom: bool,
payload_outputs_tool_search: bool,
err: FunctionCallError,
) -> AnyToolResult {
let message = err.to_string();
if payload_outputs_tool_search {
AnyToolResult {
call_id,
payload: ToolPayload::ToolSearch {
arguments: SearchToolCallParams {
query: String::new(),
limit: None,
},
},
result: Box::new(ToolSearchOutput { tools: Vec::new() }),
}
} else if payload_outputs_custom {
AnyToolResult {
call_id,
payload: ToolPayload::Custom {
input: String::new(),
},
result: Box::new(FunctionToolOutput::from_text(message, Some(false))),
}
} else {
AnyToolResult {
call_id,
payload: ToolPayload::Function {
arguments: "{}".to_string(),
},
result: Box::new(FunctionToolOutput::from_text(message, Some(false))),
}
}
self.registry.dispatch_any(invocation).await
}
}
#[cfg(test)]

View File

@@ -1,9 +1,9 @@
use std::sync::Arc;
use crate::codex::make_session_and_context;
use crate::function_tool::FunctionCallError;
use crate::tools::context::ToolPayload;
use crate::turn_diff_tracker::TurnDiffTracker;
use codex_protocol::models::ResponseInputItem;
use codex_protocol::models::ResponseItem;
use super::ToolCall;
@@ -50,20 +50,21 @@ async fn js_repl_tools_only_blocks_direct_tool_calls() -> anyhow::Result<()> {
},
};
let tracker = Arc::new(tokio::sync::Mutex::new(TurnDiffTracker::new()));
let response = router
.dispatch_tool_call(session, turn, tracker, call, ToolCallSource::Direct)
.await?;
match response {
ResponseInputItem::FunctionCallOutput { output, .. } => {
let content = output.text_content().unwrap_or_default();
assert!(
content.contains("direct tool calls are disabled"),
"unexpected tool call message: {content}",
);
}
other => panic!("expected function call output, got {other:?}"),
}
let err = router
.dispatch_tool_call_with_code_mode_result(
session,
turn,
tracker,
call,
ToolCallSource::Direct,
)
.await
.err()
.expect("direct tool calls should be blocked");
let FunctionCallError::RespondToModel(message) = err else {
panic!("expected RespondToModel, got {err:?}");
};
assert!(message.contains("direct tool calls are disabled"));
Ok(())
}
@@ -107,20 +108,22 @@ async fn js_repl_tools_only_allows_js_repl_source_calls() -> anyhow::Result<()>
},
};
let tracker = Arc::new(tokio::sync::Mutex::new(TurnDiffTracker::new()));
let response = router
.dispatch_tool_call(session, turn, tracker, call, ToolCallSource::JsRepl)
.await?;
match response {
ResponseInputItem::FunctionCallOutput { output, .. } => {
let content = output.text_content().unwrap_or_default();
assert!(
!content.contains("direct tool calls are disabled"),
"js_repl source should bypass direct-call policy gate"
);
}
other => panic!("expected function call output, got {other:?}"),
}
let err = router
.dispatch_tool_call_with_code_mode_result(
session,
turn,
tracker,
call,
ToolCallSource::JsRepl,
)
.await
.err()
.expect("shell call with empty args should fail");
let message = err.to_string();
assert!(
!message.contains("direct tool calls are disabled"),
"js_repl source should bypass direct-call policy gate"
);
Ok(())
}

View File

@@ -980,7 +980,21 @@ fn create_view_image_tool(can_request_original_image_detail: bool) -> ToolSpec {
required: Some(vec!["path".to_string()]),
additional_properties: Some(false.into()),
},
output_schema: None,
output_schema: Some(serde_json::json!({
"type": "object",
"properties": {
"image_url": {
"type": "string",
"description": "Data URL for the loaded image."
},
"detail": {
"type": ["string", "null"],
"description": "Image detail hint returned by view_image. Returns `original` when original resolution is preserved, otherwise `null`."
}
},
"required": ["image_url", "detail"],
"additionalProperties": false
})),
})
}

View File

@@ -2627,7 +2627,7 @@ fn code_mode_augments_builtin_tool_descriptions_with_typed_sample() {
assert_eq!(
description,
"View a local image from the filesystem (only use if given a full filepath by the user, and the image isn't already attached to the thread context within <image ...> tags).\n\nexec tool declaration:\n```ts\ndeclare const tools: { view_image(args: { path: string; }): Promise<unknown>; };\n```"
"View a local image from the filesystem (only use if given a full filepath by the user, and the image isn't already attached to the thread context within <image ...> tags).\n\nexec tool declaration:\n```ts\ndeclare const tools: { view_image(args: { path: string; }): Promise<{ detail: string | null; image_url: string; }>; };\n```"
);
}

View File

@@ -1,6 +1,8 @@
#![allow(clippy::expect_used, clippy::unwrap_used)]
use anyhow::Result;
use base64::Engine;
use base64::engine::general_purpose::STANDARD as BASE64_STANDARD;
use codex_core::config::types::McpServerConfig;
use codex_core::config::types::McpServerTransportConfig;
use codex_core::features::Feature;
@@ -537,6 +539,46 @@ Error:\ boom\n
Ok(())
}
#[cfg_attr(windows, ignore = "no exec_command on Windows")]
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn code_mode_exec_surfaces_handler_errors_as_exceptions() -> Result<()> {
skip_if_no_network!(Ok(()));
let server = responses::start_mock_server().await;
let (_test, second_mock) = run_code_mode_turn(
&server,
"surface nested tool handler failures as script exceptions",
r#"
try {
await tools.exec_command({});
text("no-exception");
} catch (error) {
text(`caught:${error?.message ?? String(error)}`);
}
"#,
false,
)
.await?;
let request = second_mock.single_request();
let (output, success) = custom_tool_output_body_and_success(&request, "call-1");
assert_ne!(
success,
Some(false),
"script should catch the nested tool error: {output}"
);
assert!(
output.contains("caught:"),
"expected caught exception text in output: {output}"
);
assert!(
!output.contains("no-exception"),
"nested tool error should not allow success path: {output}"
);
Ok(())
}
#[cfg_attr(windows, ignore = "no exec_command on Windows")]
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn code_mode_can_yield_and_resume_with_wait() -> Result<()> {
@@ -1721,6 +1763,90 @@ image("data:image/png;base64,AAA");
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn code_mode_can_use_view_image_result_with_image_helper() -> Result<()> {
skip_if_no_network!(Ok(()));
let server = responses::start_mock_server().await;
let mut builder = test_codex()
.with_model("gpt-5.3-codex")
.with_config(move |config| {
let _ = config.features.enable(Feature::CodeMode);
let _ = config.features.enable(Feature::ImageDetailOriginal);
});
let test = builder.build(&server).await?;
let image_bytes = BASE64_STANDARD.decode(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4z8DwHwAFAAH/iZk9HQAAAABJRU5ErkJggg==",
)?;
let image_path = test.cwd_path().join("code_mode_view_image.png");
fs::write(&image_path, image_bytes)?;
let image_path_json = serde_json::to_string(&image_path.to_string_lossy().to_string())?;
let code = format!(
r#"
const out = await tools.view_image({{ path: {image_path_json}, detail: "original" }});
image(out);
"#
);
responses::mount_sse_once(
&server,
sse(vec![
ev_response_created("resp-1"),
ev_custom_tool_call("call-1", "exec", &code),
ev_completed("resp-1"),
]),
)
.await;
let second_mock = responses::mount_sse_once(
&server,
sse(vec![
ev_assistant_message("msg-1", "done"),
ev_completed("resp-2"),
]),
)
.await;
test.submit_turn("use exec to call view_image and emit its image output")
.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 view_image 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, 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(()));
@@ -2044,7 +2170,7 @@ text(JSON.stringify(tool));
parsed,
serde_json::json!({
"name": "view_image",
"description": "View a local image from the filesystem (only use if given a full filepath by the user, and the image isn't already attached to the thread context within <image ...> tags).\n\nexec tool declaration:\n```ts\ndeclare const tools: { view_image(args: { path: string; }): Promise<unknown>; };\n```",
"description": "View a local image from the filesystem (only use if given a full filepath by the user, and the image isn't already attached to the thread context within <image ...> tags).\n\nexec tool declaration:\n```ts\ndeclare const tools: { view_image(args: { path: string; }): Promise<{ detail: string | null; image_url: string; }>; };\n```",
})
);

View File

@@ -1087,7 +1087,7 @@ async fn view_image_tool_errors_when_path_is_directory() -> anyhow::Result<()> {
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn view_image_tool_placeholder_for_non_image_files() -> anyhow::Result<()> {
async fn view_image_tool_errors_for_non_image_files() -> anyhow::Result<()> {
skip_if_no_network!(Ok(()));
let server = start_mock_server().await;
@@ -1150,20 +1150,19 @@ async fn view_image_tool_placeholder_for_non_image_files() -> anyhow::Result<()>
request.inputs_of_type("input_image").is_empty(),
"non-image file should not produce an input_image message"
);
let (placeholder, success) = request
let (error_text, success) = request
.function_call_output_content_and_success(call_id)
.expect("function_call_output should be present");
assert_eq!(success, None);
let placeholder = placeholder.expect("placeholder text present");
let error_text = error_text.expect("error text present");
assert!(
placeholder.contains("Codex could not read the local image at")
&& placeholder.contains("unsupported MIME type `application/json`"),
"placeholder should describe the unsupported file type: {placeholder}"
let expected_error = format!(
"unable to process image at `{}`: unsupported image `application/json`",
abs_path.display()
);
assert!(
placeholder.contains(&abs_path.display().to_string()),
"placeholder should mention path: {placeholder}"
error_text.contains(&expected_error),
"error should describe unsupported file type: {error_text}"
);
Ok(())

View File

@@ -19,7 +19,6 @@ codex-utils-image = { workspace = true }
icu_decimal = { workspace = true }
icu_locale_core = { workspace = true }
icu_provider = { workspace = true, features = ["sync"] }
mime_guess = { workspace = true }
schemars = { workspace = true }
serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }

View File

@@ -941,7 +941,7 @@ fn invalid_image_error_placeholder(
fn unsupported_image_error_placeholder(path: &std::path::Path, mime: &str) -> ContentItem {
ContentItem::InputText {
text: format!(
"Codex cannot attach image at `{}`: unsupported image format `{}`.",
"Codex cannot attach image at `{}`: unsupported image `{}`.",
path.display(),
mime
),
@@ -972,28 +972,20 @@ pub fn local_image_content_items_with_label_number(
}
items
}
Err(err) => {
if matches!(&err, ImageProcessingError::Read { .. }) {
Err(err) => match &err {
ImageProcessingError::Read { .. } | ImageProcessingError::Encode { .. } => {
vec![local_image_error_placeholder(path, &err)]
} else if err.is_invalid_image() {
vec![invalid_image_error_placeholder(path, &err)]
} else {
let Some(mime_guess) = mime_guess::from_path(path).first() else {
return vec![local_image_error_placeholder(
path,
"unsupported MIME type (unknown)",
)];
};
let mime = mime_guess.essence_str().to_owned();
if !mime.starts_with("image/") {
return vec![local_image_error_placeholder(
path,
format!("unsupported MIME type `{mime}`"),
)];
}
vec![unsupported_image_error_placeholder(path, &mime)]
}
}
ImageProcessingError::Decode { .. } if err.is_invalid_image() => {
vec![invalid_image_error_placeholder(path, &err)]
}
ImageProcessingError::Decode { .. } => {
vec![local_image_error_placeholder(path, &err)]
}
ImageProcessingError::UnsupportedImageFormat { mime } => {
vec![unsupported_image_error_placeholder(path, mime)]
}
},
}
}
@@ -2908,8 +2900,8 @@ mod tests {
match &content[0] {
ContentItem::InputText { text } => {
assert!(
text.contains("unsupported MIME type `application/json`"),
"placeholder should mention unsupported MIME: {text}"
text.contains("unsupported image `application/json`"),
"placeholder should mention unsupported image MIME: {text}"
);
assert!(
text.contains(&json_path.display().to_string()),
@@ -2943,7 +2935,7 @@ mod tests {
ResponseInputItem::Message { content, .. } => {
assert_eq!(content.len(), 1);
let expected = format!(
"Codex cannot attach image at `{}`: unsupported image format `image/svg+xml`.",
"Codex cannot attach image at `{}`: unsupported image `image/svg+xml`.",
svg_path.display()
);
match &content[0] {

View File

@@ -285,6 +285,32 @@ fn emit_missing_system_bwrap_warning(app_event_tx: &AppEventSender) {
)));
}
async fn emit_custom_prompt_deprecation_notice(app_event_tx: &AppEventSender, codex_home: &Path) {
let prompts_dir = codex_home.join("prompts");
let prompt_count = codex_core::custom_prompts::discover_prompts_in(&prompts_dir)
.await
.len();
if prompt_count == 0 {
return;
}
let prompt_label = if prompt_count == 1 {
"prompt"
} else {
"prompts"
};
let details = format!(
"Detected {prompt_count} custom {prompt_label} in `$CODEX_HOME/prompts`. Use the `$skill-creator` skill to convert each custom prompt into a skill."
);
app_event_tx.send(AppEvent::InsertHistoryCell(Box::new(
history_cell::new_deprecation_notice(
"Custom prompts are deprecated and will soon be removed.".to_string(),
Some(details),
),
)));
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct SessionSummary {
usage_line: String,
@@ -1974,6 +2000,7 @@ impl App {
let app_event_tx = AppEventSender::new(app_event_tx);
emit_project_config_warnings(&app_event_tx, &config);
emit_missing_system_bwrap_warning(&app_event_tx);
emit_custom_prompt_deprecation_notice(&app_event_tx, &config.codex_home).await;
tui.set_notification_method(config.tui_notification_method);
let harness_overrides =
@@ -4324,6 +4351,62 @@ mod tests {
);
}
fn render_history_cell(cell: &dyn HistoryCell, width: u16) -> String {
cell.display_lines(width)
.into_iter()
.map(|line| line.to_string())
.collect::<Vec<_>>()
.join("\n")
}
#[tokio::test]
async fn startup_custom_prompt_deprecation_notice_emits_when_prompts_exist() -> Result<()> {
let codex_home = tempdir()?;
let prompts_dir = codex_home.path().join("prompts");
std::fs::create_dir_all(&prompts_dir)?;
std::fs::write(prompts_dir.join("review.md"), "# Review\n")?;
let (tx_raw, mut rx) = unbounded_channel();
let app_event_tx = AppEventSender::new(tx_raw);
emit_custom_prompt_deprecation_notice(&app_event_tx, codex_home.path()).await;
let cell = match rx.try_recv() {
Ok(AppEvent::InsertHistoryCell(cell)) => cell,
other => panic!("expected InsertHistoryCell event, got {other:?}"),
};
let rendered = render_history_cell(cell.as_ref(), 120);
assert_snapshot!("startup_custom_prompt_deprecation_notice", rendered);
assert!(rx.try_recv().is_err(), "expected only one startup notice");
Ok(())
}
#[tokio::test]
async fn startup_custom_prompt_deprecation_notice_skips_missing_prompts_dir() -> Result<()> {
let codex_home = tempdir()?;
let (tx_raw, mut rx) = unbounded_channel();
let app_event_tx = AppEventSender::new(tx_raw);
emit_custom_prompt_deprecation_notice(&app_event_tx, codex_home.path()).await;
assert!(rx.try_recv().is_err(), "expected no startup notice");
Ok(())
}
#[tokio::test]
async fn startup_custom_prompt_deprecation_notice_skips_empty_prompts_dir() -> Result<()> {
let codex_home = tempdir()?;
std::fs::create_dir_all(codex_home.path().join("prompts"))?;
let (tx_raw, mut rx) = unbounded_channel();
let app_event_tx = AppEventSender::new(tx_raw);
emit_custom_prompt_deprecation_notice(&app_event_tx, codex_home.path()).await;
assert!(rx.try_recv().is_err(), "expected no startup notice");
Ok(())
}
#[test]
fn startup_waiting_gate_not_applied_for_resume_or_fork_session_selection() {
let wait_for_resume = App::should_wait_for_initial_session(&SessionSelection::Resume(

View File

@@ -0,0 +1,7 @@
---
source: tui/src/app.rs
expression: rendered
---
⚠ Custom prompts are deprecated and will soon be removed.
Detected 1 custom prompt in `$CODEX_HOME/prompts`. Use the `$skill-creator` skill to convert each custom prompt into
a skill.

View File

@@ -11,6 +11,7 @@ workspace = true
base64 = { workspace = true }
image = { workspace = true, features = ["jpeg", "png", "gif", "webp"] }
codex-utils-cache = { workspace = true }
mime_guess = { workspace = true }
thiserror = { workspace = true }
tokio = { workspace = true, features = ["fs", "rt", "rt-multi-thread", "macros"] }

View File

@@ -23,9 +23,26 @@ pub enum ImageProcessingError {
#[source]
source: image::ImageError,
},
#[error("unsupported image `{mime}`")]
UnsupportedImageFormat { mime: String },
}
impl ImageProcessingError {
pub fn decode_error(path: &std::path::Path, source: image::ImageError) -> Self {
if matches!(source, ImageError::Decoding(_)) {
return ImageProcessingError::Decode {
path: path.to_path_buf(),
source,
};
}
let mime = mime_guess::from_path(path)
.first()
.map(|mime_guess| mime_guess.essence_str().to_owned())
.unwrap_or_else(|| "unknown".to_string());
ImageProcessingError::UnsupportedImageFormat { mime }
}
pub fn is_invalid_image(&self) -> bool {
matches!(
self,

View File

@@ -74,12 +74,8 @@ pub fn load_for_prompt_bytes(
_ => None,
};
let dynamic = image::load_from_memory(&file_bytes).map_err(|source| {
ImageProcessingError::Decode {
path: path_buf.clone(),
source,
}
})?;
let dynamic = image::load_from_memory(&file_bytes)
.map_err(|source| ImageProcessingError::decode_error(&path_buf, source))?;
let (width, height) = dynamic.dimensions();
@@ -294,10 +290,11 @@ mod tests {
PromptImageMode::ResizeToFit,
)
.expect_err("invalid image should fail");
match err {
ImageProcessingError::Decode { .. } => {}
_ => panic!("unexpected error variant"),
}
assert!(matches!(
err,
ImageProcessingError::Decode { .. }
| ImageProcessingError::UnsupportedImageFormat { .. }
));
}
#[tokio::test(flavor = "multi_thread")]