From 51307eaf0795f75c1d8e4528025020aec957c1a2 Mon Sep 17 00:00:00 2001 From: jif-oai Date: Wed, 3 Dec 2025 11:35:56 +0000 Subject: [PATCH 01/30] feat: retroactive image placeholder to prevent poisoning (#6774) If an image can't be read by the API, it will poison the entire history, preventing any new turn on the conversation. This detect such cases and replace the image by a placeholder --- codex-rs/core/src/api_bridge.rs | 18 +++-- codex-rs/core/src/codex.rs | 9 +++ codex-rs/core/src/context_manager/history.rs | 33 ++++++++ codex-rs/core/src/error.rs | 8 ++ codex-rs/core/src/util.rs | 6 +- codex-rs/core/tests/common/responses.rs | 26 +++++++ codex-rs/core/tests/suite/view_image.rs | 79 ++++++++++++++++++++ 7 files changed, 171 insertions(+), 8 deletions(-) diff --git a/codex-rs/core/src/api_bridge.rs b/codex-rs/core/src/api_bridge.rs index b9f802ae66..79fd67d650 100644 --- a/codex-rs/core/src/api_bridge.rs +++ b/codex-rs/core/src/api_bridge.rs @@ -33,12 +33,20 @@ pub(crate) fn map_api_error(err: ApiError) -> CodexErr { headers, body, } => { - if status == http::StatusCode::INTERNAL_SERVER_ERROR { + let body_text = body.unwrap_or_default(); + + if status == http::StatusCode::BAD_REQUEST { + if body_text + .contains("The image data you provided does not represent a valid image") + { + CodexErr::InvalidImageRequest() + } else { + CodexErr::InvalidRequest(body_text) + } + } else if status == http::StatusCode::INTERNAL_SERVER_ERROR { CodexErr::InternalServerError } else if status == http::StatusCode::TOO_MANY_REQUESTS { - if let Some(body) = body - && let Ok(err) = serde_json::from_str::(&body) - { + if let Ok(err) = serde_json::from_str::(&body_text) { if err.error.error_type.as_deref() == Some("usage_limit_reached") { let rate_limits = headers.as_ref().and_then(parse_rate_limit); let resets_at = err @@ -62,7 +70,7 @@ pub(crate) fn map_api_error(err: ApiError) -> CodexErr { } else { CodexErr::UnexpectedStatus(UnexpectedResponseError { status, - body: body.unwrap_or_default(), + body: body_text, request_id: extract_request_id(headers.as_ref()), }) } diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 8b1dfafd21..f76cd7de74 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -2039,6 +2039,13 @@ pub(crate) async fn run_task( // Aborted turn is reported via a different event. break; } + Err(CodexErr::InvalidImageRequest()) => { + let mut state = sess.state.lock().await; + error_or_panic( + "Invalid image detected, replacing it in the last turn to prevent poisoning", + ); + state.history.replace_last_turn_images("Invalid image"); + } Err(e) => { info!("Turn error: {e:#}"); let event = EventMsg::Error(e.to_error_event(None)); @@ -2146,6 +2153,8 @@ async fn run_turn( } Err(CodexErr::UsageNotIncluded) => return Err(CodexErr::UsageNotIncluded), Err(e @ CodexErr::QuotaExceeded) => return Err(e), + Err(e @ CodexErr::InvalidImageRequest()) => return Err(e), + Err(e @ CodexErr::InvalidRequest(_)) => return Err(e), Err(e @ CodexErr::RefreshTokenFailed(_)) => return Err(e), Err(e) => { // Use the configured provider-specific stream retry budget. diff --git a/codex-rs/core/src/context_manager/history.rs b/codex-rs/core/src/context_manager/history.rs index 7277f0b0c6..b9a9c58f63 100644 --- a/codex-rs/core/src/context_manager/history.rs +++ b/codex-rs/core/src/context_manager/history.rs @@ -5,6 +5,8 @@ use crate::truncate::approx_token_count; use crate::truncate::approx_tokens_from_byte_count; use crate::truncate::truncate_function_output_items_with_policy; use crate::truncate::truncate_text; +use codex_protocol::models::ContentItem; +use codex_protocol::models::FunctionCallOutputContentItem; use codex_protocol::models::FunctionCallOutputPayload; use codex_protocol::models::ResponseItem; use codex_protocol::protocol::TokenUsage; @@ -118,6 +120,37 @@ impl ContextManager { self.items = items; } + pub(crate) fn replace_last_turn_images(&mut self, placeholder: &str) { + let Some(last_item) = self.items.last_mut() else { + return; + }; + + match last_item { + ResponseItem::Message { role, content, .. } if role == "user" => { + for item in content.iter_mut() { + if matches!(item, ContentItem::InputImage { .. }) { + *item = ContentItem::InputText { + text: placeholder.to_string(), + }; + } + } + } + ResponseItem::FunctionCallOutput { output, .. } => { + let Some(content_items) = output.content_items.as_mut() else { + return; + }; + for item in content_items.iter_mut() { + if matches!(item, FunctionCallOutputContentItem::InputImage { .. }) { + *item = FunctionCallOutputContentItem::InputText { + text: placeholder.to_string(), + }; + } + } + } + _ => {} + } + } + pub(crate) fn update_token_info( &mut self, usage: &TokenUsage, diff --git a/codex-rs/core/src/error.rs b/codex-rs/core/src/error.rs index 9130b40e1e..a25261d649 100644 --- a/codex-rs/core/src/error.rs +++ b/codex-rs/core/src/error.rs @@ -103,6 +103,14 @@ pub enum CodexErr { #[error("{0}")] UnexpectedStatus(UnexpectedResponseError), + /// Invalid request. + #[error("{0}")] + InvalidRequest(String), + + /// Invalid image. + #[error("Image poisoning")] + InvalidImageRequest(), + #[error("{0}")] UsageLimitReached(UsageLimitReachedError), diff --git a/codex-rs/core/src/util.rs b/codex-rs/core/src/util.rs index 698e02fedb..5304a89ac9 100644 --- a/codex-rs/core/src/util.rs +++ b/codex-rs/core/src/util.rs @@ -16,11 +16,11 @@ pub(crate) fn backoff(attempt: u64) -> Duration { Duration::from_millis((base as f64 * jitter) as u64) } -pub(crate) fn error_or_panic(message: String) { +pub(crate) fn error_or_panic(message: impl std::string::ToString) { if cfg!(debug_assertions) || env!("CARGO_PKG_VERSION").contains("alpha") { - panic!("{message}"); + panic!("{}", message.to_string()); } else { - error!("{message}"); + error!("{}", message.to_string()); } } diff --git a/codex-rs/core/tests/common/responses.rs b/codex-rs/core/tests/common/responses.rs index 932ddb51a3..a8209b5139 100644 --- a/codex-rs/core/tests/common/responses.rs +++ b/codex-rs/core/tests/common/responses.rs @@ -518,6 +518,32 @@ pub fn sse_response(body: String) -> ResponseTemplate { .set_body_raw(body, "text/event-stream") } +pub async fn mount_response_once(server: &MockServer, response: ResponseTemplate) -> ResponseMock { + let (mock, response_mock) = base_mock(); + mock.respond_with(response) + .up_to_n_times(1) + .mount(server) + .await; + response_mock +} + +pub async fn mount_response_once_match( + server: &MockServer, + matcher: M, + response: ResponseTemplate, +) -> ResponseMock +where + M: wiremock::Match + Send + Sync + 'static, +{ + let (mock, response_mock) = base_mock(); + mock.and(matcher) + .respond_with(response) + .up_to_n_times(1) + .mount(server) + .await; + response_mock +} + fn base_mock() -> (MockBuilder, ResponseMock) { let response_mock = ResponseMock::new(); let mock = Mock::given(method("POST")) diff --git a/codex-rs/core/tests/suite/view_image.rs b/codex-rs/core/tests/suite/view_image.rs index 394c5c2f14..6c0f6dcc80 100644 --- a/codex-rs/core/tests/suite/view_image.rs +++ b/codex-rs/core/tests/suite/view_image.rs @@ -474,3 +474,82 @@ async fn view_image_tool_errors_when_file_missing() -> anyhow::Result<()> { Ok(()) } + +#[cfg(not(debug_assertions))] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn replaces_invalid_local_image_after_bad_request() -> anyhow::Result<()> { + skip_if_no_network!(Ok(())); + + let server = start_mock_server().await; + + const INVALID_IMAGE_ERROR: &str = + "The image data you provided does not represent a valid image"; + + let invalid_image_mock = responses::mount_response_once_match( + &server, + body_string_contains("\"input_image\""), + ResponseTemplate::new(400) + .insert_header("content-type", "text/plain") + .set_body_string(INVALID_IMAGE_ERROR), + ) + .await; + + let success_response = sse(vec![ + ev_response_created("resp-2"), + ev_assistant_message("msg-1", "done"), + ev_completed("resp-2"), + ]); + + let completion_mock = responses::mount_sse_once(&server, success_response).await; + + let TestCodex { + codex, + cwd, + session_configured, + .. + } = test_codex().build(&server).await?; + + let rel_path = "assets/poisoned.png"; + let abs_path = cwd.path().join(rel_path); + if let Some(parent) = abs_path.parent() { + std::fs::create_dir_all(parent)?; + } + let image = ImageBuffer::from_pixel(1024, 512, Rgba([10u8, 20, 30, 255])); + image.save(&abs_path)?; + + let session_model = session_configured.model.clone(); + + codex + .submit(Op::UserTurn { + items: vec![UserInput::LocalImage { + path: abs_path.clone(), + }], + final_output_json_schema: None, + cwd: cwd.path().to_path_buf(), + approval_policy: AskForApproval::Never, + sandbox_policy: SandboxPolicy::DangerFullAccess, + model: session_model, + effort: None, + summary: ReasoningSummary::Auto, + }) + .await?; + + wait_for_event(&codex, |event| matches!(event, EventMsg::TaskComplete(_))).await; + + let first_body = invalid_image_mock.single_request().body_json(); + assert!( + find_image_message(&first_body).is_some(), + "initial request should include the uploaded image" + ); + + let second_request = completion_mock.single_request(); + let second_body = second_request.body_json(); + assert!( + find_image_message(&second_body).is_none(), + "second request should replace the invalid image" + ); + let user_texts = second_request.message_input_texts("user"); + assert!(user_texts.iter().any(|text| text == "Invalid image")); + + Ok(()) +} From 45f3250eecf641fc566a227deb4e31ab9c6500cd Mon Sep 17 00:00:00 2001 From: jif-oai Date: Wed, 3 Dec 2025 16:29:13 +0000 Subject: [PATCH 02/30] feat: codex tool tips (#7440) Screenshot 2025-12-01 at 12 22 26 --- codex-rs/core/src/config/mod.rs | 9 ++++ codex-rs/core/src/config/types.rs | 5 ++ codex-rs/tui/src/app.rs | 4 ++ codex-rs/tui/src/app_backtrack.rs | 1 + codex-rs/tui/src/chatwidget.rs | 7 ++- codex-rs/tui/src/chatwidget/tests.rs | 1 + codex-rs/tui/src/history_cell.rs | 78 +++++++++++++++++++--------- codex-rs/tui/src/lib.rs | 2 + codex-rs/tui/src/tooltips.rs | 49 +++++++++++++++++ codex-rs/tui/tooltips.txt | 11 ++++ 10 files changed, 141 insertions(+), 26 deletions(-) create mode 100644 codex-rs/tui/src/tooltips.rs create mode 100644 codex-rs/tui/tooltips.txt diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index 5e768ba9d4..6276d3b6e3 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -161,6 +161,9 @@ pub struct Config { /// Enable ASCII animations and shimmer effects in the TUI. pub animations: bool, + /// Show startup tooltips in the TUI welcome screen. + pub show_tooltips: bool, + /// The directory that should be treated as the current working directory /// for the session. All relative paths inside the business-logic layer are /// resolved against this path. @@ -1252,6 +1255,7 @@ impl Config { .map(|t| t.notifications.clone()) .unwrap_or_default(), animations: cfg.tui.as_ref().map(|t| t.animations).unwrap_or(true), + show_tooltips: cfg.tui.as_ref().map(|t| t.show_tooltips).unwrap_or(true), otel: { let t: OtelConfigToml = cfg.otel.unwrap_or_default(); let log_user_prompt = t.log_user_prompt.unwrap_or(false); @@ -1426,6 +1430,7 @@ persistence = "none" let tui = parsed.tui.expect("config should include tui section"); assert_eq!(tui.notifications, Notifications::Enabled(true)); + assert!(tui.show_tooltips); } #[test] @@ -2999,6 +3004,7 @@ model_verbosity = "high" disable_paste_burst: false, tui_notifications: Default::default(), animations: true, + show_tooltips: true, otel: OtelConfig::default(), }, o3_profile_config @@ -3072,6 +3078,7 @@ model_verbosity = "high" disable_paste_burst: false, tui_notifications: Default::default(), animations: true, + show_tooltips: true, otel: OtelConfig::default(), }; @@ -3160,6 +3167,7 @@ model_verbosity = "high" disable_paste_burst: false, tui_notifications: Default::default(), animations: true, + show_tooltips: true, otel: OtelConfig::default(), }; @@ -3234,6 +3242,7 @@ model_verbosity = "high" disable_paste_burst: false, tui_notifications: Default::default(), animations: true, + show_tooltips: true, otel: OtelConfig::default(), }; diff --git a/codex-rs/core/src/config/types.rs b/codex-rs/core/src/config/types.rs index c47a570969..5e1b78aa7b 100644 --- a/codex-rs/core/src/config/types.rs +++ b/codex-rs/core/src/config/types.rs @@ -368,6 +368,11 @@ pub struct Tui { /// Defaults to `true`. #[serde(default = "default_true")] pub animations: bool, + + /// Show startup tooltips in the TUI welcome screen. + /// Defaults to `true`. + #[serde(default = "default_true")] + pub show_tooltips: bool, } const fn default_true() -> bool { diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 2ba7e4d871..fa3e610e22 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -252,6 +252,7 @@ impl App { initial_images: Vec, resume_selection: ResumeSelection, feedback: codex_feedback::CodexFeedback, + is_first_run: bool, ) -> Result { use tokio_stream::StreamExt; let (app_event_tx, mut app_event_rx) = unbounded_channel(); @@ -297,6 +298,7 @@ impl App { enhanced_keys_supported, auth_manager: auth_manager.clone(), feedback: feedback.clone(), + is_first_run, }; ChatWidget::new(init, conversation_manager.clone()) } @@ -320,6 +322,7 @@ impl App { enhanced_keys_supported, auth_manager: auth_manager.clone(), feedback: feedback.clone(), + is_first_run, }; ChatWidget::new_from_existing( init, @@ -473,6 +476,7 @@ impl App { enhanced_keys_supported: self.enhanced_keys_supported, auth_manager: self.auth_manager.clone(), feedback: self.feedback.clone(), + is_first_run: false, }; self.chat_widget = ChatWidget::new(init, self.server.clone()); if let Some(summary) = summary { diff --git a/codex-rs/tui/src/app_backtrack.rs b/codex-rs/tui/src/app_backtrack.rs index b161867e44..677f29abdd 100644 --- a/codex-rs/tui/src/app_backtrack.rs +++ b/codex-rs/tui/src/app_backtrack.rs @@ -347,6 +347,7 @@ impl App { enhanced_keys_supported: self.enhanced_keys_supported, auth_manager: self.auth_manager.clone(), feedback: self.feedback.clone(), + is_first_run: false, }; self.chat_widget = crate::chatwidget::ChatWidget::new_from_existing(init, conv, session_configured); diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 6872178445..e0bb0d3a69 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -256,6 +256,7 @@ pub(crate) struct ChatWidgetInit { pub(crate) enhanced_keys_supported: bool, pub(crate) auth_manager: Arc, pub(crate) feedback: codex_feedback::CodexFeedback, + pub(crate) is_first_run: bool, } #[derive(Default)] @@ -1230,6 +1231,7 @@ impl ChatWidget { enhanced_keys_supported, auth_manager, feedback, + is_first_run, } = common; let mut rng = rand::rng(); let placeholder = EXAMPLE_PROMPTS[rng.random_range(0..EXAMPLE_PROMPTS.len())].to_string(); @@ -1274,7 +1276,7 @@ impl ChatWidget { retry_status_header: None, conversation_id: None, queued_user_messages: VecDeque::new(), - show_welcome_banner: true, + show_welcome_banner: is_first_run, suppress_session_configured_redraw: false, pending_notification: None, is_review_mode: false, @@ -1305,6 +1307,7 @@ impl ChatWidget { enhanced_keys_supported, auth_manager, feedback, + .. } = common; let mut rng = rand::rng(); let placeholder = EXAMPLE_PROMPTS[rng.random_range(0..EXAMPLE_PROMPTS.len())].to_string(); @@ -1351,7 +1354,7 @@ impl ChatWidget { retry_status_header: None, conversation_id: None, queued_user_messages: VecDeque::new(), - show_welcome_banner: true, + show_welcome_banner: false, suppress_session_configured_redraw: true, pending_notification: None, is_review_mode: false, diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index 890e8bbe1d..c44401b3e7 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -355,6 +355,7 @@ async fn helpers_are_available_and_do_not_panic() { enhanced_keys_supported: false, auth_manager, feedback: codex_feedback::CodexFeedback::new(), + is_first_run: true, }; let mut w = ChatWidget::new(init, conversation_manager); // Basic construction sanity. diff --git a/codex-rs/tui/src/history_cell.rs b/codex-rs/tui/src/history_cell.rs index 02ab0d243b..475eb1db17 100644 --- a/codex-rs/tui/src/history_cell.rs +++ b/codex-rs/tui/src/history_cell.rs @@ -15,6 +15,7 @@ use crate::render::renderable::Renderable; use crate::style::user_message_style; use crate::text_formatting::format_and_truncate_tool_result; use crate::text_formatting::truncate_text; +use crate::tooltips; use crate::ui_consts::LIVE_PREFIX_COLS; use crate::update_action::UpdateAction; use crate::version::CODEX_CLI_VERSION; @@ -559,6 +560,34 @@ pub(crate) fn padded_emoji(emoji: &str) -> String { format!("{emoji}\u{200A}") } +#[derive(Debug)] +struct TooltipHistoryCell { + tip: &'static str, +} + +impl TooltipHistoryCell { + fn new(tip: &'static str) -> Self { + Self { tip } + } +} + +impl HistoryCell for TooltipHistoryCell { + fn display_lines(&self, width: u16) -> Vec> { + let indent: Line<'static> = " ".into(); + let mut lines = Vec::new(); + let tooltip_line: Line<'static> = vec!["Tip: ".cyan(), self.tip.into()].into(); + let wrap_opts = RtOptions::new(usize::from(width.max(1))) + .initial_indent(indent.clone()) + .subsequent_indent(indent.clone()); + lines.extend( + word_wrap_line(&tooltip_line, wrap_opts.clone()) + .into_iter() + .map(|line| line_to_static(&line)), + ); + lines + } +} + #[derive(Debug)] pub struct SessionInfoCell(CompositeHistoryCell); @@ -586,15 +615,16 @@ pub(crate) fn new_session_info( reasoning_effort, .. } = event; - SessionInfoCell(if is_first_event { - // Header box rendered as history (so it appears at the very top) - let header = SessionHeaderHistoryCell::new( - model, - reasoning_effort, - config.cwd.clone(), - crate::version::CODEX_CLI_VERSION, - ); + // Header box rendered as history (so it appears at the very top) + let header = SessionHeaderHistoryCell::new( + model.clone(), + reasoning_effort, + config.cwd.clone(), + CODEX_CLI_VERSION, + ); + let mut parts: Vec> = vec![Box::new(header)]; + if is_first_event { // Help lines below the header (new copy and list) let help_lines: Vec> = vec![ " To get started, describe a task or try one of these commands:" @@ -628,24 +658,24 @@ pub(crate) fn new_session_info( ]), ]; - CompositeHistoryCell { - parts: vec![ - Box::new(header), - Box::new(PlainHistoryCell { lines: help_lines }), - ], - } - } else if config.model == model { - CompositeHistoryCell { parts: vec![] } + parts.push(Box::new(PlainHistoryCell { lines: help_lines })); } else { - let lines = vec![ - "model changed:".magenta().bold().into(), - format!("requested: {}", config.model).into(), - format!("used: {model}").into(), - ]; - CompositeHistoryCell { - parts: vec![Box::new(PlainHistoryCell { lines })], + if config.show_tooltips + && let Some(tooltips) = tooltips::random_tooltip().map(TooltipHistoryCell::new) + { + parts.push(Box::new(tooltips)); } - }) + if config.model != model { + let lines = vec![ + "model changed:".magenta().bold().into(), + format!("requested: {}", config.model).into(), + format!("used: {model}").into(), + ]; + parts.push(Box::new(PlainHistoryCell { lines })); + } + } + + SessionInfoCell(CompositeHistoryCell { parts }) } pub(crate) fn new_user_prompt(message: String) -> UserHistoryCell { diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index 15e9ccb92b..0aa422cc61 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -75,6 +75,7 @@ mod streaming; mod style; mod terminal_palette; mod text_formatting; +mod tooltips; mod tui; mod ui_consts; pub mod update_action; @@ -505,6 +506,7 @@ async fn run_ratatui_app( images, resume_selection, feedback, + should_show_trust_screen, // Proxy to: is it a first run in this directory? ) .await; diff --git a/codex-rs/tui/src/tooltips.rs b/codex-rs/tui/src/tooltips.rs new file mode 100644 index 0000000000..eb419c2ea3 --- /dev/null +++ b/codex-rs/tui/src/tooltips.rs @@ -0,0 +1,49 @@ +use lazy_static::lazy_static; +use rand::Rng; + +const RAW_TOOLTIPS: &str = include_str!("../tooltips.txt"); + +lazy_static! { + static ref TOOLTIPS: Vec<&'static str> = RAW_TOOLTIPS + .lines() + .map(str::trim) + .filter(|line| !line.is_empty() && !line.starts_with('#')) + .collect(); +} + +pub(crate) fn random_tooltip() -> Option<&'static str> { + let mut rng = rand::rng(); + pick_tooltip(&mut rng) +} + +fn pick_tooltip(rng: &mut R) -> Option<&'static str> { + if TOOLTIPS.is_empty() { + None + } else { + TOOLTIPS.get(rng.random_range(0..TOOLTIPS.len())).copied() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use rand::SeedableRng; + use rand::rngs::StdRng; + + #[test] + fn random_tooltip_returns_some_tip_when_available() { + let mut rng = StdRng::seed_from_u64(42); + assert!(pick_tooltip(&mut rng).is_some()); + } + + #[test] + fn random_tooltip_is_reproducible_with_seed() { + let expected = { + let mut rng = StdRng::seed_from_u64(7); + pick_tooltip(&mut rng) + }; + + let mut rng = StdRng::seed_from_u64(7); + assert_eq!(expected, pick_tooltip(&mut rng)); + } +} diff --git a/codex-rs/tui/tooltips.txt b/codex-rs/tui/tooltips.txt new file mode 100644 index 0000000000..09167eb4fc --- /dev/null +++ b/codex-rs/tui/tooltips.txt @@ -0,0 +1,11 @@ +Use /compact when the conversation gets long to summarize history and free up context. +Start a fresh idea with /new; the previous session stays available in history. +If a turn went sideways, /undo asks Codex to revert the last changes. +Use /feedback to send logs to the maintainers when something looks off. +Switch models or reasoning effort quickly with /model. +You can run any shell commands from codex using `!` (e.g. `!ls`) +Type / to open the command popup; Tab autocompletes slash commands and saved prompts. +Use /prompts: key=value to expand a saved prompt with placeholders before sending. +With the composer empty, press Esc to step back and edit your last message; Enter confirms. +Paste an image with Ctrl+V to attach it to your next message. +You can resume a previous conversation by doing `codex resume` \ No newline at end of file From 9b3251f28f2dfc85d75ae5ff4b7d9ff9e74b48c4 Mon Sep 17 00:00:00 2001 From: Jeremy Rose <172423086+nornagon-openai@users.noreply.github.com> Date: Wed, 3 Dec 2025 09:15:38 -0800 Subject: [PATCH 03/30] seatbelt: allow openpty() (#7507) This allows `openpty(3)` to run in the default sandbox. Also permit reading `kern.argmax`, which is the maximum number of arguments to exec(). --- codex-rs/core/src/seatbelt_base_policy.sbpl | 13 +++++-- codex-rs/core/tests/suite/seatbelt.rs | 15 ++++---- codex-rs/exec/tests/suite/sandbox.rs | 39 +++++++++++++++++++++ 3 files changed, 57 insertions(+), 10 deletions(-) diff --git a/codex-rs/core/src/seatbelt_base_policy.sbpl b/codex-rs/core/src/seatbelt_base_policy.sbpl index 824e028033..8ccfa6e824 100644 --- a/codex-rs/core/src/seatbelt_base_policy.sbpl +++ b/codex-rs/core/src/seatbelt_base_policy.sbpl @@ -53,6 +53,7 @@ (sysctl-name "hw.physicalcpu_max") (sysctl-name "hw.tbfrequency_compat") (sysctl-name "hw.vectorunit") + (sysctl-name "kern.argmax") (sysctl-name "kern.hostname") (sysctl-name "kern.maxfilesperproc") (sysctl-name "kern.maxproc") @@ -72,7 +73,8 @@ (sysctl-name-prefix "net.routetable.") ) -; Allow Java to set CPU type grade when required +; Allow Java to read some CPU info. This is misclassified as a "write" because +; userspace passes a memory buffer to the sysctl, but conceptually it is a read. (allow sysctl-write (sysctl-name "kern.grade_cputype")) @@ -86,10 +88,17 @@ (global-name "com.apple.system.opendirectoryd.libinfo") ) -; Added on top of Chrome profile ; Needed for python multiprocessing on MacOS for the SemLock (allow ipc-posix-sem) (allow mach-lookup (global-name "com.apple.PowerManagement.control") ) + +; allow openpty() +(allow pseudo-tty) +(allow file-read* file-write* file-ioctl (literal "/dev/ptmx")) +(allow file-read* file-write* + (require-all + (regex #"^/dev/ttys[0-9]+") + (extension "com.apple.sandbox.pty"))) diff --git a/codex-rs/core/tests/suite/seatbelt.rs b/codex-rs/core/tests/suite/seatbelt.rs index 53175fca11..52150b0511 100644 --- a/codex-rs/core/tests/suite/seatbelt.rs +++ b/codex-rs/core/tests/suite/seatbelt.rs @@ -159,23 +159,18 @@ async fn read_only_forbids_all_writes() { .await; } -/// Verify that user lookups via `pwd.getpwuid(os.getuid())` work under the -/// seatbelt sandbox. Prior to allowing the necessary mach‑lookup for -/// OpenDirectory libinfo, this would fail with `KeyError: getpwuid(): uid not found`. #[tokio::test] -async fn python_getpwuid_works_under_seatbelt() { +async fn openpty_works_under_seatbelt() { if std::env::var(CODEX_SANDBOX_ENV_VAR) == Ok("seatbelt".to_string()) { eprintln!("{CODEX_SANDBOX_ENV_VAR} is set to 'seatbelt', skipping test."); return; } - // For local dev. if which::which("python3").is_err() { eprintln!("python3 not found in PATH, skipping test."); return; } - // ReadOnly is sufficient here since we are only exercising user lookup. let policy = SandboxPolicy::ReadOnly; let command_cwd = std::env::current_dir().expect("getcwd"); let sandbox_cwd = command_cwd.clone(); @@ -184,8 +179,12 @@ async fn python_getpwuid_works_under_seatbelt() { vec![ "python3".to_string(), "-c".to_string(), - // Print the passwd struct; success implies lookup worked. - "import pwd, os; print(pwd.getpwuid(os.getuid()))".to_string(), + r#"import os + +master, slave = os.openpty() +os.write(slave, b"ping") +assert os.read(master, 4) == b"ping""# + .to_string(), ], command_cwd, &policy, diff --git a/codex-rs/exec/tests/suite/sandbox.rs b/codex-rs/exec/tests/suite/sandbox.rs index 73a7f0d5bd..f0faa8b438 100644 --- a/codex-rs/exec/tests/suite/sandbox.rs +++ b/codex-rs/exec/tests/suite/sandbox.rs @@ -109,6 +109,45 @@ if __name__ == '__main__': assert!(status.success(), "python exited with {status:?}"); } +#[tokio::test] +async fn python_getpwuid_works_under_sandbox() { + core_test_support::skip_if_sandbox!(); + + if std::process::Command::new("python3") + .arg("--version") + .status() + .is_err() + { + eprintln!("python3 not found in PATH, skipping test."); + return; + } + + let policy = SandboxPolicy::ReadOnly; + let command_cwd = std::env::current_dir().expect("should be able to get current dir"); + let sandbox_cwd = command_cwd.clone(); + + let mut child = spawn_command_under_sandbox( + vec![ + "python3".to_string(), + "-c".to_string(), + "import pwd, os; print(pwd.getpwuid(os.getuid()))".to_string(), + ], + command_cwd, + &policy, + sandbox_cwd.as_path(), + StdioPolicy::RedirectForShellTool, + HashMap::new(), + ) + .await + .expect("should be able to spawn python under sandbox"); + + let status = child + .wait() + .await + .expect("should be able to wait for child process"); + assert!(status.success(), "python exited with {status:?}"); +} + #[tokio::test] async fn sandbox_distinguishes_command_and_policy_cwds() { core_test_support::skip_if_sandbox!(); From 4785344c9c17e5580a54d7abb6fdeb118ebad61f Mon Sep 17 00:00:00 2001 From: Shijie Rao Date: Wed, 3 Dec 2025 09:51:46 -0800 Subject: [PATCH 04/30] feat: support list mcp servers in app server (#7505) ### Summary Added `mcp/servers/list` which is equivalent to `/mcp` slash command in CLI for response. This will be used in VSCE MCP settings to show log in status, available tools etc. --- .../src/protocol/common.rs | 5 + .../app-server-protocol/src/protocol/v2.rs | 43 +++++ codex-rs/app-server/Cargo.toml | 1 + .../app-server/src/codex_message_processor.rs | 88 +++++++++ codex-rs/core/src/codex.rs | 27 +-- codex-rs/core/src/mcp/mod.rs | 167 ++++++++++++++++++ 6 files changed, 312 insertions(+), 19 deletions(-) diff --git a/codex-rs/app-server-protocol/src/protocol/common.rs b/codex-rs/app-server-protocol/src/protocol/common.rs index 51d1bd2361..2858366739 100644 --- a/codex-rs/app-server-protocol/src/protocol/common.rs +++ b/codex-rs/app-server-protocol/src/protocol/common.rs @@ -139,6 +139,11 @@ client_request_definitions! { response: v2::ModelListResponse, }, + McpServersList => "mcpServers/list" { + params: v2::ListMcpServersParams, + response: v2::ListMcpServersResponse, + }, + LoginAccount => "account/login/start" { params: v2::LoginAccountParams, response: v2::LoginAccountResponse, diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index 0e2be70c93..e3990432f6 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -22,6 +22,9 @@ use codex_protocol::protocol::TokenUsage as CoreTokenUsage; use codex_protocol::protocol::TokenUsageInfo as CoreTokenUsageInfo; use codex_protocol::user_input::UserInput as CoreUserInput; use mcp_types::ContentBlock as McpContentBlock; +use mcp_types::Resource as McpResource; +use mcp_types::ResourceTemplate as McpResourceTemplate; +use mcp_types::Tool as McpTool; use schemars::JsonSchema; use serde::Deserialize; use serde::Serialize; @@ -138,6 +141,15 @@ v2_enum_from_core!( } ); +v2_enum_from_core!( + pub enum McpAuthStatus from codex_protocol::protocol::McpAuthStatus { + Unsupported, + NotLoggedIn, + BearerToken, + OAuth + } +); + #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] #[ts(export_to = "v2/")] @@ -615,6 +627,37 @@ pub struct ModelListResponse { pub next_cursor: Option, } +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ListMcpServersParams { + /// Opaque pagination cursor returned by a previous call. + pub cursor: Option, + /// Optional page size; defaults to a server-defined value. + pub limit: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct McpServer { + pub name: String, + pub tools: std::collections::HashMap, + pub resources: Vec, + pub resource_templates: Vec, + pub auth_status: McpAuthStatus, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ListMcpServersResponse { + pub data: Vec, + /// Opaque cursor to pass to the next call to continue after the last item. + /// If None, there are no more items to return. + pub next_cursor: Option, +} + #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] #[ts(export_to = "v2/")] diff --git a/codex-rs/app-server/Cargo.toml b/codex-rs/app-server/Cargo.toml index 4ffe2d8913..99d5a7a141 100644 --- a/codex-rs/app-server/Cargo.toml +++ b/codex-rs/app-server/Cargo.toml @@ -31,6 +31,7 @@ chrono = { workspace = true } serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } sha2 = { workspace = true } +mcp-types = { workspace = true } tempfile = { workspace = true } toml = { workspace = true } tokio = { workspace = true, features = [ diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index cd8aa9b704..245486e482 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -45,6 +45,8 @@ use codex_app_server_protocol::InterruptConversationParams; use codex_app_server_protocol::JSONRPCErrorError; use codex_app_server_protocol::ListConversationsParams; use codex_app_server_protocol::ListConversationsResponse; +use codex_app_server_protocol::ListMcpServersParams; +use codex_app_server_protocol::ListMcpServersResponse; use codex_app_server_protocol::LoginAccountParams; use codex_app_server_protocol::LoginApiKeyParams; use codex_app_server_protocol::LoginApiKeyResponse; @@ -52,6 +54,7 @@ use codex_app_server_protocol::LoginChatGptCompleteNotification; use codex_app_server_protocol::LoginChatGptResponse; use codex_app_server_protocol::LogoutAccountResponse; use codex_app_server_protocol::LogoutChatGptResponse; +use codex_app_server_protocol::McpServer; use codex_app_server_protocol::ModelListParams; use codex_app_server_protocol::ModelListResponse; use codex_app_server_protocol::NewConversationParams; @@ -119,6 +122,8 @@ use codex_core::exec_env::create_env; use codex_core::features::Feature; use codex_core::find_conversation_path_by_id_str; use codex_core::git_info::git_diff_to_remote; +use codex_core::mcp::collect_mcp_snapshot; +use codex_core::mcp::group_tools_by_server; use codex_core::parse_cursor; use codex_core::protocol::EventMsg; use codex_core::protocol::Op; @@ -136,6 +141,7 @@ use codex_protocol::config_types::ForcedLoginMethod; use codex_protocol::items::TurnItem; use codex_protocol::models::ResponseItem; use codex_protocol::protocol::GitInfo as CoreGitInfo; +use codex_protocol::protocol::McpAuthStatus as CoreMcpAuthStatus; use codex_protocol::protocol::RateLimitSnapshot as CoreRateLimitSnapshot; use codex_protocol::protocol::RolloutItem; use codex_protocol::protocol::SessionMetaLine; @@ -363,6 +369,9 @@ impl CodexMessageProcessor { ClientRequest::ModelList { request_id, params } => { self.list_models(request_id, params).await; } + ClientRequest::McpServersList { request_id, params } => { + self.list_mcp_servers(request_id, params).await; + } ClientRequest::LoginAccount { request_id, params } => { self.login_v2(request_id, params).await; } @@ -1908,6 +1917,85 @@ impl CodexMessageProcessor { self.outgoing.send_response(request_id, response).await; } + async fn list_mcp_servers(&self, request_id: RequestId, params: ListMcpServersParams) { + let snapshot = collect_mcp_snapshot(self.config.as_ref()).await; + + let tools_by_server = group_tools_by_server(&snapshot.tools); + + let mut server_names: Vec = self + .config + .mcp_servers + .keys() + .cloned() + .chain(snapshot.auth_statuses.keys().cloned()) + .chain(snapshot.resources.keys().cloned()) + .chain(snapshot.resource_templates.keys().cloned()) + .collect(); + server_names.sort(); + server_names.dedup(); + + let total = server_names.len(); + let limit = params.limit.unwrap_or(total as u32).max(1) as usize; + let effective_limit = limit.min(total); + let start = match params.cursor { + Some(cursor) => match cursor.parse::() { + Ok(idx) => idx, + Err(_) => { + let error = JSONRPCErrorError { + code: INVALID_REQUEST_ERROR_CODE, + message: format!("invalid cursor: {cursor}"), + data: None, + }; + self.outgoing.send_error(request_id, error).await; + return; + } + }, + None => 0, + }; + + if start > total { + let error = JSONRPCErrorError { + code: INVALID_REQUEST_ERROR_CODE, + message: format!("cursor {start} exceeds total MCP servers {total}"), + data: None, + }; + self.outgoing.send_error(request_id, error).await; + return; + } + + let end = start.saturating_add(effective_limit).min(total); + + let data: Vec = server_names[start..end] + .iter() + .map(|name| McpServer { + name: name.clone(), + tools: tools_by_server.get(name).cloned().unwrap_or_default(), + resources: snapshot.resources.get(name).cloned().unwrap_or_default(), + resource_templates: snapshot + .resource_templates + .get(name) + .cloned() + .unwrap_or_default(), + auth_status: snapshot + .auth_statuses + .get(name) + .cloned() + .unwrap_or(CoreMcpAuthStatus::Unsupported) + .into(), + }) + .collect(); + + let next_cursor = if end < total { + Some(end.to_string()) + } else { + None + }; + + let response = ListMcpServersResponse { data, next_cursor }; + + self.outgoing.send_response(request_id, response).await; + } + async fn handle_resume_conversation( &self, request_id: RequestId, diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index f76cd7de74..682861d648 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -1493,6 +1493,7 @@ mod handlers { use crate::codex::spawn_review_thread; use crate::config::Config; use crate::mcp::auth::compute_auth_statuses; + use crate::mcp::collect_mcp_snapshot_from_manager; use crate::review_prompts::resolve_review_request; use crate::tasks::CompactTask; use crate::tasks::RegularTask; @@ -1689,30 +1690,18 @@ mod handlers { pub async fn list_mcp_tools(sess: &Session, config: &Arc, sub_id: String) { let mcp_connection_manager = sess.services.mcp_connection_manager.read().await; - let (tools, auth_status_entries, resources, resource_templates) = tokio::join!( - mcp_connection_manager.list_all_tools(), + let snapshot = collect_mcp_snapshot_from_manager( + &mcp_connection_manager, compute_auth_statuses( config.mcp_servers.iter(), config.mcp_oauth_credentials_store_mode, - ), - mcp_connection_manager.list_all_resources(), - mcp_connection_manager.list_all_resource_templates(), - ); - let auth_statuses = auth_status_entries - .iter() - .map(|(name, entry)| (name.clone(), entry.auth_status)) - .collect(); + ) + .await, + ) + .await; let event = Event { id: sub_id, - msg: EventMsg::McpListToolsResponse(crate::protocol::McpListToolsResponseEvent { - tools: tools - .into_iter() - .map(|(name, tool)| (name, tool.tool)) - .collect(), - resources, - resource_templates, - auth_statuses, - }), + msg: EventMsg::McpListToolsResponse(snapshot), }; sess.send_event_raw(event).await; } diff --git a/codex-rs/core/src/mcp/mod.rs b/codex-rs/core/src/mcp/mod.rs index 0e4a05d597..ed5f2ea69f 100644 --- a/codex-rs/core/src/mcp/mod.rs +++ b/codex-rs/core/src/mcp/mod.rs @@ -1 +1,168 @@ pub mod auth; +use std::collections::HashMap; + +use async_channel::unbounded; +use codex_protocol::protocol::McpListToolsResponseEvent; +use mcp_types::Tool as McpTool; +use tokio_util::sync::CancellationToken; + +use crate::config::Config; +use crate::mcp::auth::compute_auth_statuses; +use crate::mcp_connection_manager::McpConnectionManager; + +const MCP_TOOL_NAME_PREFIX: &str = "mcp"; +const MCP_TOOL_NAME_DELIMITER: &str = "__"; + +pub async fn collect_mcp_snapshot(config: &Config) -> McpListToolsResponseEvent { + if config.mcp_servers.is_empty() { + return McpListToolsResponseEvent { + tools: HashMap::new(), + resources: HashMap::new(), + resource_templates: HashMap::new(), + auth_statuses: HashMap::new(), + }; + } + + let auth_status_entries = compute_auth_statuses( + config.mcp_servers.iter(), + config.mcp_oauth_credentials_store_mode, + ) + .await; + + let mut mcp_connection_manager = McpConnectionManager::default(); + let (tx_event, rx_event) = unbounded(); + drop(rx_event); + let cancel_token = CancellationToken::new(); + + mcp_connection_manager + .initialize( + config.mcp_servers.clone(), + config.mcp_oauth_credentials_store_mode, + auth_status_entries.clone(), + tx_event, + cancel_token.clone(), + ) + .await; + + let snapshot = + collect_mcp_snapshot_from_manager(&mcp_connection_manager, auth_status_entries).await; + + cancel_token.cancel(); + + snapshot +} + +pub fn split_qualified_tool_name(qualified_name: &str) -> Option<(String, String)> { + let mut parts = qualified_name.split(MCP_TOOL_NAME_DELIMITER); + let prefix = parts.next()?; + if prefix != MCP_TOOL_NAME_PREFIX { + return None; + } + let server_name = parts.next()?; + let tool_name: String = parts.collect::>().join(MCP_TOOL_NAME_DELIMITER); + if tool_name.is_empty() { + return None; + } + Some((server_name.to_string(), tool_name)) +} + +pub fn group_tools_by_server( + tools: &HashMap, +) -> HashMap> { + let mut grouped = HashMap::new(); + for (qualified_name, tool) in tools { + if let Some((server_name, tool_name)) = split_qualified_tool_name(qualified_name) { + grouped + .entry(server_name) + .or_insert_with(HashMap::new) + .insert(tool_name, tool.clone()); + } + } + grouped +} + +pub(crate) async fn collect_mcp_snapshot_from_manager( + mcp_connection_manager: &McpConnectionManager, + auth_status_entries: HashMap, +) -> McpListToolsResponseEvent { + let (tools, resources, resource_templates) = tokio::join!( + mcp_connection_manager.list_all_tools(), + mcp_connection_manager.list_all_resources(), + mcp_connection_manager.list_all_resource_templates(), + ); + + let auth_statuses = auth_status_entries + .iter() + .map(|(name, entry)| (name.clone(), entry.auth_status)) + .collect(); + + McpListToolsResponseEvent { + tools: tools + .into_iter() + .map(|(name, tool)| (name, tool.tool)) + .collect(), + resources, + resource_templates, + auth_statuses, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use mcp_types::ToolInputSchema; + use pretty_assertions::assert_eq; + + fn make_tool(name: &str) -> McpTool { + McpTool { + annotations: None, + description: None, + input_schema: ToolInputSchema { + properties: None, + required: None, + r#type: "object".to_string(), + }, + name: name.to_string(), + output_schema: None, + title: None, + } + } + + #[test] + fn split_qualified_tool_name_returns_server_and_tool() { + assert_eq!( + split_qualified_tool_name("mcp__alpha__do_thing"), + Some(("alpha".to_string(), "do_thing".to_string())) + ); + } + + #[test] + fn split_qualified_tool_name_rejects_invalid_names() { + assert_eq!(split_qualified_tool_name("other__alpha__do_thing"), None); + assert_eq!(split_qualified_tool_name("mcp__alpha__"), None); + } + + #[test] + fn group_tools_by_server_strips_prefix_and_groups() { + let mut tools = HashMap::new(); + tools.insert("mcp__alpha__do_thing".to_string(), make_tool("do_thing")); + tools.insert( + "mcp__alpha__nested__op".to_string(), + make_tool("nested__op"), + ); + tools.insert("mcp__beta__do_other".to_string(), make_tool("do_other")); + + let mut expected_alpha = HashMap::new(); + expected_alpha.insert("do_thing".to_string(), make_tool("do_thing")); + expected_alpha.insert("nested__op".to_string(), make_tool("nested__op")); + + let mut expected_beta = HashMap::new(); + expected_beta.insert("do_other".to_string(), make_tool("do_other")); + + let mut expected = HashMap::new(); + expected.insert("alpha".to_string(), expected_alpha); + expected.insert("beta".to_string(), expected_beta); + + assert_eq!(group_tools_by_server(&tools), expected); + } +} From c3e4f920b4e965085164d6ee0249a873ef96da77 Mon Sep 17 00:00:00 2001 From: Shijie Rao Date: Wed, 3 Dec 2025 10:23:45 -0800 Subject: [PATCH 05/30] chore: remove bun env var detect (#7534) ### Summary [Thread](https://openai.slack.com/archives/C08JZTV654K/p1764780129457519) We were a bit aggressive on assuming package installer based on env variables for BUN. Here we are removing those checks. --- codex-cli/bin/codex.js | 8 -------- 1 file changed, 8 deletions(-) diff --git a/codex-cli/bin/codex.js b/codex-cli/bin/codex.js index 805be85af8..138796e5d6 100644 --- a/codex-cli/bin/codex.js +++ b/codex-cli/bin/codex.js @@ -95,14 +95,6 @@ function detectPackageManager() { return "bun"; } - if ( - process.env.BUN_INSTALL || - process.env.BUN_INSTALL_GLOBAL_DIR || - process.env.BUN_INSTALL_BIN_DIR - ) { - return "bun"; - } - return userAgent ? "npm" : null; } From 343aa35db1ec369185ebc961f237fe7f7a0994ac Mon Sep 17 00:00:00 2001 From: Owen Lin Date: Wed, 3 Dec 2025 10:41:38 -0800 Subject: [PATCH 06/30] chore: update app-server README (#7510) Just keeping the README up to date. - Reorganize structure a bit to read more naturally - Update RPC methods - Update events --- codex-rs/app-server/README.md | 71 ++++++++++++++++++----------------- 1 file changed, 36 insertions(+), 35 deletions(-) diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index 4e94a2c133..ee28637b2a 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -5,11 +5,11 @@ ## Table of Contents - [Protocol](#protocol) - [Message Schema](#message-schema) +- [Core Primitives](#core-primitives) - [Lifecycle Overview](#lifecycle-overview) - [Initialization](#initialization) -- [Core primitives](#core-primitives) -- [Thread & turn endpoints](#thread--turn-endpoints) -- [Events (work-in-progress)](#events-work-in-progress) +- [API Overview](#api-overview) +- [Events](#events) - [Auth endpoints](#auth-endpoints) ## Protocol @@ -25,6 +25,15 @@ codex app-server generate-ts --out DIR codex app-server generate-json-schema --out DIR ``` +## Core Primitives + +The API exposes three top level primitives representing an interaction between a user and Codex: +- **Thread**: A conversation between a user and the Codex agent. Each thread contains multiple turns. +- **Turn**: One turn of the conversation, typically starting with a user message and finishing with an agent message. Each turn contains multiple items. +- **Item**: Represents user inputs and agent outputs as part of the turn, persisted and used as the context for future conversations. Example items include user message, agent reasoning, agent message, shell command, file edit, etc. + +Use the thread APIs to create, list, or archive conversations. Drive a conversation with turn APIs and stream progress via turn notifications. + ## Lifecycle Overview - Initialize once: Immediately after launching the codex app-server process, send an `initialize` request with your client metadata, then emit an `initialized` notification. Any other request before this handshake gets rejected. @@ -37,28 +46,16 @@ codex app-server generate-json-schema --out DIR Clients must send a single `initialize` request before invoking any other method, then acknowledge with an `initialized` notification. The server returns the user agent string it will present to upstream services; subsequent requests issued before initialization receive a `"Not initialized"` error, and repeated `initialize` calls receive an `"Already initialized"` error. -Example: +Applications building on top of `codex app-server` should identify themselves via the `clientInfo` parameter. +Example (from OpenAI's official VSCode extension): ```json { "method": "initialize", "id": 0, "params": { "clientInfo": { "name": "codex-vscode", "title": "Codex VS Code Extension", "version": "0.1.0" } } } -{ "id": 0, "result": { "userAgent": "codex-app-server/0.1.0 codex-vscode/0.1.0" } } -{ "method": "initialized" } ``` -## Core primitives - -We have 3 top level primitives: -- Thread - a conversation between the Codex agent and a user. Each thread contains multiple turns. -- Turn - one turn of the conversation, typically starting with a user message and finishing with an agent message. Each turn contains multiple items. -- Item - represents user inputs and agent outputs as part of the turn, persisted and used as the context for future conversations. - -## Thread & turn endpoints - -The JSON-RPC API exposes dedicated methods for managing Codex conversations. Threads store long-lived conversation metadata, and turns store the per-message exchange (input → Codex output, including streamed items). Use the thread APIs to create, list, or archive sessions, then drive the conversation with turn APIs and notifications. - -### Quick reference +## API Overview - `thread/start` — create a new thread; emits `thread/started` and auto-subscribes you to turn/item events for that thread. - `thread/resume` — reopen an existing thread by id so subsequent `turn/start` calls append to it. - `thread/list` — page through stored rollouts; supports cursor-based pagination and optional `modelProviders` filtering. @@ -67,8 +64,14 @@ The JSON-RPC API exposes dedicated methods for managing Codex conversations. Thr - `turn/interrupt` — request cancellation of an in-flight turn by `(thread_id, turn_id)`; success is an empty `{}` response and the turn finishes with `status: "interrupted"`. - `review/start` — kick off Codex’s automated reviewer for a thread; responds like `turn/start` and emits `item/started`/`item/completed` notifications with `enteredReviewMode` and `exitedReviewMode` items, plus a final assistant `agentMessage` containing the review. - `command/exec` — run a single command under the server sandbox without starting a thread/turn (handy for utilities and validation). +- `model/list` — list available models (with reasoning effort options). +- `feedback/upload` — submit a feedback report (classification + optional reason/logs and conversation_id); returns the tracking thread id. +- `command/exec` — run a single command under the server sandbox without starting a thread/turn (handy for utilities and validation). +- `config/read` — fetch the effective config on disk after resolving config layering. +- `config/value/write` — write a single config key/value to the user's config.toml on disk. +- `config/batchWrite` — apply multiple config edits atomically to the user's config.toml on disk. -### 1) Start or resume a thread +### Example: Start or resume a thread Start a fresh thread when you need a new Codex conversation. @@ -99,7 +102,7 @@ To continue a stored session, call `thread/resume` with the `thread.id` you prev { "id": 11, "result": { "thread": { "id": "thr_123", … } } } ``` -### 2) List threads (pagination & filters) +### Example: List threads (with pagination & filters) `thread/list` lets you render a history UI. Pass any combination of: - `cursor` — opaque string from a prior response; omit for the first page. @@ -124,7 +127,7 @@ Example: When `nextCursor` is `null`, you’ve reached the final page. -### 3) Archive a thread +### Example: Archive a thread Use `thread/archive` to move the persisted rollout (stored as a JSONL file on disk) into the archived sessions directory. @@ -135,7 +138,7 @@ Use `thread/archive` to move the persisted rollout (stored as a JSONL file on di An archived thread will not appear in future calls to `thread/list`. -### 4) Start a turn (send user input) +### Example: Start a turn (send user input) Turns attach user input (text or images) to a thread and trigger Codex generation. The `input` field is a list of discriminated unions: @@ -169,7 +172,7 @@ You can optionally specify config overrides on the new turn. If specified, these } } } ``` -### 5) Interrupt an active turn +### Example: Interrupt an active turn You can cancel a running Turn with `turn/interrupt`. @@ -183,7 +186,7 @@ You can cancel a running Turn with `turn/interrupt`. The server requests cancellations for running subprocesses, then emits a `turn/completed` event with `status: "interrupted"`. Rely on the `turn/completed` to know when Codex-side cleanup is done. -### 6) Request a code review +### Example: Request a code review Use `review/start` to run Codex’s reviewer on the currently checked-out project. The request takes the thread id plus a `target` describing what should be reviewed: @@ -242,7 +245,7 @@ containing an `exitedReviewMode` item with the final review text: The `review` string is plain text that already bundles the overall explanation plus a bullet list for each structured finding (matching `ThreadItem::ExitedReviewMode` in the generated schema). Use this notification to render the reviewer output in your client. -### 7) One-off command execution +### Example: One-off command execution Run a standalone command (argv vector) in the server’s sandbox without creating a thread or turn: @@ -261,7 +264,7 @@ Notes: - `sandboxPolicy` accepts the same shape used by `turn/start` (e.g., `dangerFullAccess`, `readOnly`, `workspaceWrite` with flags). - When omitted, `timeoutMs` falls back to the server default. -## Events (work-in-progress) +## Events Event notifications are the server-initiated event stream for thread lifecycles, turn lifecycles, and the items within them. After you start or resume a thread, keep reading stdout for `thread/started`, `turn/*`, and `item/*` notifications. @@ -271,11 +274,12 @@ The app-server streams JSON-RPC notifications while a turn is running. Each turn - `turn/started` — `{ turn }` with the turn id, empty `items`, and `status: "inProgress"`. - `turn/completed` — `{ turn }` where `turn.status` is `completed`, `interrupted`, or `failed`; failures carry `{ error: { message, codexErrorInfo? } }`. +- `turn/diff/updated` — `{ threadId, turnId, diff }` represents the up-to-date snapshot of the turn-level unified diff, emitted after every FileChange item. `diff` is the latest aggregated unified diff across every file change in the turn. UIs can render this to show the full "what changed" view without stitching individual `fileChange` items. - `turn/plan/updated` — `{ turnId, explanation?, plan }` whenever the agent shares or changes its plan; each `plan` entry is `{ step, status }` with `status` in `pending`, `inProgress`, or `completed`. Today both notifications carry an empty `items` array even when item events were streamed; rely on `item/*` notifications for the canonical item list until this is fixed. -#### Thread items +#### Items `ThreadItem` is the tagged union carried in turn responses and `item/*` notifications. Currently we support events for the following items: - `userMessage` — `{id, content}` where `content` is a list of user inputs (`text`, `image`, or `localImage`). @@ -285,6 +289,9 @@ Today both notifications carry an empty `items` array even when item events were - `fileChange` — `{id, changes, status}` describing proposed edits; `changes` list `{path, kind, diff}` and `status` is `inProgress`, `completed`, `failed`, or `declined`. - `mcpToolCall` — `{id, server, tool, status, arguments, result?, error?}` describing MCP calls; `status` is `inProgress`, `completed`, or `failed`. - `webSearch` — `{id, query}` for a web search request issued by the agent. +- `imageView` — `{id, path}` emitted when the agent invokes the image viewer tool. +- `enteredReviewMode` — `{id, review}` sent when the reviewer starts; `review` is a short user-facing label such as `"current changes"` or the requested target description. +- `exitedReviewMode` — `{id, review}` emitted when the reviewer finishes; `review` is the full plain-text review (usually, overall notes plus bullet point findings). - `compacted` - `{threadId, turnId}` when codex compacts the conversation history. This can happen automatically. All items emit two shared lifecycle events: @@ -302,7 +309,7 @@ There are additional item-specific events: - `item/commandExecution/outputDelta` — streams stdout/stderr for the command; append deltas in order to render live output alongside `aggregatedOutput` in the final item. Final `commandExecution` items include parsed `commandActions`, `status`, `exitCode`, and `durationMs` so the UI can summarize what ran and whether it succeeded. #### fileChange -`fileChange` items contain a `changes` list with `{path, kind, diff}` entries (`kind` is `add`, `delete`, or `update` with an optional `movePath`). The `status` tracks whether apply succeeded (`completed`), failed, or was `declined`. +- `item/fileChange/outputDelta` - contains the tool call response of the underlying `apply_patch` tool call. ### Errors `error` event is emitted whenever the server hits an error mid-turn (for example, upstream model errors or quota limits). Carries the same `{ error: { message, codexErrorInfo? } }` payload as `turn.status: "failed"` and may precede that terminal notification. @@ -351,7 +358,7 @@ UI guidance for IDEs: surface an approval dialog as soon as the request arrives. The JSON-RPC auth/account surface exposes request/response methods plus server-initiated notifications (no `id`). Use these to determine auth state, start or cancel logins, logout, and inspect ChatGPT rate limits. -### Quick reference +### API Overview - `account/read` — fetch current account info; optionally refresh tokens. - `account/login/start` — begin login (`apiKey` or `chatgpt`). - `account/login/completed` (notify) — emitted when a login attempt finishes (success or error). @@ -436,9 +443,3 @@ Field notes: - `usedPercent` is current usage within the OpenAI quota window. - `windowDurationMins` is the quota window length. - `resetsAt` is a Unix timestamp (seconds) for the next reset. - -### Dev notes - -- `codex app-server generate-ts --out ` emits v2 types under `v2/`. -- `codex app-server generate-json-schema --out ` outputs `codex_app_server_protocol.schemas.json`. -- See [“Authentication and authorization” in the config docs](../../docs/config.md#authentication-and-authorization) for configuration knobs. From 844de19561b87f76c60d77db3ccfc9415c43b230 Mon Sep 17 00:00:00 2001 From: Owen Lin Date: Wed, 3 Dec 2025 10:47:12 -0800 Subject: [PATCH 07/30] chore: delete unused TodoList item from app-server (#7537) This item is sent as a turn notification instead: `turn/plan/updated`, similar to Turn diffs (which is `turn/diff/updated`). We treat these concepts as ephemeral compared to Items which are usually persisted. --- codex-rs/app-server-protocol/src/protocol/v2.rs | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index e3990432f6..e1122e8146 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -1144,9 +1144,6 @@ pub enum ThreadItem { WebSearch { id: String, query: String }, #[serde(rename_all = "camelCase")] #[ts(rename_all = "camelCase")] - TodoList { id: String, items: Vec }, - #[serde(rename_all = "camelCase")] - #[ts(rename_all = "camelCase")] ImageView { id: String, path: String }, #[serde(rename_all = "camelCase")] #[ts(rename_all = "camelCase")] @@ -1249,15 +1246,6 @@ pub struct McpToolCallError { pub message: String, } -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct TodoItem { - pub id: String, - pub text: String, - pub completed: bool, -} - // === Server Notifications === // Thread/Turn lifecycle notifications and item progress events #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] From 3ef76ff29d5eed258fb6b8550e0e2b973d0dca21 Mon Sep 17 00:00:00 2001 From: Owen Lin Date: Wed, 3 Dec 2025 10:47:35 -0800 Subject: [PATCH 08/30] chore: conversation_id -> thread_id in app-server feedback/upload (#7538) Use `thread_id: Option` instead of `conversation_id: Option` to be consistent with the rest of app-server v2 APIs. --- .../app-server-protocol/src/protocol/v2.rs | 3 +-- .../app-server/src/codex_message_processor.rs | 18 +++++++++++++++++- 2 files changed, 18 insertions(+), 3 deletions(-) diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index e1122e8146..c672080720 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -2,7 +2,6 @@ use std::collections::HashMap; use std::path::PathBuf; use crate::protocol::common::AuthMode; -use codex_protocol::ConversationId; use codex_protocol::account::PlanType; use codex_protocol::approvals::SandboxCommandAssessment as CoreSandboxCommandAssessment; use codex_protocol::config_types::ReasoningEffort; @@ -664,7 +663,7 @@ pub struct ListMcpServersResponse { pub struct FeedbackUploadParams { pub classification: String, pub reason: Option, - pub conversation_id: Option, + pub thread_id: Option, pub include_logs: bool, } diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index 245486e482..6b85049d28 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -3021,10 +3021,26 @@ impl CodexMessageProcessor { let FeedbackUploadParams { classification, reason, - conversation_id, + thread_id, include_logs, } = params; + let conversation_id = match thread_id.as_deref() { + Some(thread_id) => match ConversationId::from_string(thread_id) { + Ok(conversation_id) => Some(conversation_id), + Err(err) => { + let error = JSONRPCErrorError { + code: INVALID_REQUEST_ERROR_CODE, + message: format!("invalid thread id: {err}"), + data: None, + }; + self.outgoing.send_error(request_id, error).await; + return; + } + }, + None => None, + }; + let snapshot = self.feedback.snapshot(conversation_id); let thread_id = snapshot.thread_id.clone(); From 2ad980abf47128a20668e5ccadf6e2565b2f46fc Mon Sep 17 00:00:00 2001 From: Ahmed Ibrahim Date: Wed, 3 Dec 2025 11:25:44 -0800 Subject: [PATCH 09/30] add slash resume (#7302) `codex resume` isn't that discoverable. Adding it to the slash commands can help --- codex-rs/Cargo.lock | 1 + codex-rs/tui/Cargo.toml | 1 + codex-rs/tui/src/app.rs | 67 ++++++ codex-rs/tui/src/app_event.rs | 3 + codex-rs/tui/src/bottom_pane/chat_composer.rs | 56 +++++ ...chat_composer__tests__slash_popup_res.snap | 11 + codex-rs/tui/src/chatwidget.rs | 3 + codex-rs/tui/src/chatwidget/tests.rs | 9 + codex-rs/tui/src/resume_picker.rs | 191 ++++++++++++++++-- codex-rs/tui/src/slash_command.rs | 3 + ...e_picker__tests__resume_picker_screen.snap | 14 ++ docs/slash_commands.md | 1 + 12 files changed, 346 insertions(+), 14 deletions(-) create mode 100644 codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__slash_popup_res.snap create mode 100644 codex-rs/tui/src/snapshots/codex_tui__resume_picker__tests__resume_picker_screen.snap diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index b9fcc969b3..a1684bf0f5 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -1625,6 +1625,7 @@ dependencies = [ "unicode-segmentation", "unicode-width 0.2.1", "url", + "uuid", "vt100", ] diff --git a/codex-rs/tui/Cargo.toml b/codex-rs/tui/Cargo.toml index be4f5aead7..828255a582 100644 --- a/codex-rs/tui/Cargo.toml +++ b/codex-rs/tui/Cargo.toml @@ -110,3 +110,4 @@ pretty_assertions = { workspace = true } rand = { workspace = true } serial_test = { workspace = true } vt100 = { workspace = true } +uuid = { workspace = true } diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index fa3e610e22..504452bc80 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -489,6 +489,73 @@ impl App { } tui.frame_requester().schedule_frame(); } + AppEvent::OpenResumePicker => { + match crate::resume_picker::run_resume_picker( + tui, + &self.config.codex_home, + &self.config.model_provider_id, + false, + ) + .await? + { + ResumeSelection::Resume(path) => { + let summary = session_summary( + self.chat_widget.token_usage(), + self.chat_widget.conversation_id(), + ); + match self + .server + .resume_conversation_from_rollout( + self.config.clone(), + path.clone(), + self.auth_manager.clone(), + ) + .await + { + Ok(resumed) => { + self.shutdown_current_conversation().await; + let init = crate::chatwidget::ChatWidgetInit { + config: self.config.clone(), + frame_requester: tui.frame_requester(), + app_event_tx: self.app_event_tx.clone(), + initial_prompt: None, + initial_images: Vec::new(), + enhanced_keys_supported: self.enhanced_keys_supported, + auth_manager: self.auth_manager.clone(), + feedback: self.feedback.clone(), + }; + self.chat_widget = ChatWidget::new_from_existing( + init, + resumed.conversation, + resumed.session_configured, + ); + if let Some(summary) = summary { + let mut lines: Vec> = + vec![summary.usage_line.clone().into()]; + if let Some(command) = summary.resume_command { + let spans = vec![ + "To continue this session, run ".into(), + command.cyan(), + ]; + lines.push(spans.into()); + } + self.chat_widget.add_plain_history_lines(lines); + } + } + Err(err) => { + self.chat_widget.add_error_message(format!( + "Failed to resume session from {}: {err}", + path.display() + )); + } + } + } + ResumeSelection::Exit | ResumeSelection::StartFresh => {} + } + + // Leaving alt-screen may blank the inline viewport; force a redraw either way. + tui.frame_requester().schedule_frame(); + } AppEvent::InsertHistoryCell(cell) => { let cell: Arc = cell.into(); if let Some(Overlay::Transcript(t)) = &mut self.overlay { diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs index cf494f57d6..944eeda810 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -22,6 +22,9 @@ pub(crate) enum AppEvent { /// Start a new session. NewSession, + /// Open the resume picker inside the running TUI session. + OpenResumePicker, + /// Request to exit the application gracefully. ExitRequest, diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index f78e544ea3..e9343fd8a4 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -2376,6 +2376,62 @@ mod tests { } } + #[test] + fn slash_popup_resume_for_res_ui() { + use ratatui::Terminal; + use ratatui::backend::TestBackend; + + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + + let mut composer = ChatComposer::new( + true, + sender, + false, + "Ask Codex to do anything".to_string(), + false, + ); + + // Type "/res" humanlike so paste-burst doesn’t interfere. + type_chars_humanlike(&mut composer, &['/', 'r', 'e', 's']); + + let mut terminal = Terminal::new(TestBackend::new(60, 6)).expect("terminal"); + terminal + .draw(|f| composer.render(f.area(), f.buffer_mut())) + .expect("draw composer"); + + // Snapshot should show /resume as the first entry for /res. + insta::assert_snapshot!("slash_popup_res", terminal.backend()); + } + + #[test] + fn slash_popup_resume_for_res_logic() { + use super::super::command_popup::CommandItem; + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + true, + sender, + false, + "Ask Codex to do anything".to_string(), + false, + ); + type_chars_humanlike(&mut composer, &['/', 'r', 'e', 's']); + + match &composer.active_popup { + ActivePopup::Command(popup) => match popup.selected_item() { + Some(CommandItem::Builtin(cmd)) => { + assert_eq!(cmd.command(), "resume") + } + Some(CommandItem::UserPrompt(_)) => { + panic!("unexpected prompt selected for '/res'") + } + None => panic!("no selected command for '/res'"), + }, + _ => panic!("slash popup not active after typing '/res'"), + } + } + // Test helper: simulate human typing with a brief delay and flush the paste-burst buffer fn type_chars_humanlike(composer: &mut ChatComposer, chars: &[char]) { use crossterm::event::KeyCode; diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__slash_popup_res.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__slash_popup_res.snap new file mode 100644 index 0000000000..df8ea36e63 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__slash_popup_res.snap @@ -0,0 +1,11 @@ +--- +source: tui/src/bottom_pane/chat_composer.rs +assertion_line: 2385 +expression: terminal.backend() +--- +" " +"› /res " +" " +" " +" " +" /resume resume a saved chat " diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index e0bb0d3a69..0f1d6918d0 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -1482,6 +1482,9 @@ impl ChatWidget { SlashCommand::New => { self.app_event_tx.send(AppEvent::NewSession); } + SlashCommand::Resume => { + self.app_event_tx.send(AppEvent::OpenResumePicker); + } SlashCommand::Init => { let init_target = self.config.cwd.join(DEFAULT_PROJECT_DOC_FILENAME); if init_target.exists() { diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index c44401b3e7..43056464de 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -1185,6 +1185,15 @@ fn slash_exit_requests_exit() { assert_matches!(rx.try_recv(), Ok(AppEvent::ExitRequest)); } +#[test] +fn slash_resume_opens_picker() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(); + + chat.dispatch_command(SlashCommand::Resume); + + assert_matches!(rx.try_recv(), Ok(AppEvent::OpenResumePicker)); +} + #[test] fn slash_undo_sends_op() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(); diff --git a/codex-rs/tui/src/resume_picker.rs b/codex-rs/tui/src/resume_picker.rs index 1cc9624ec3..f2c6a3269d 100644 --- a/codex-rs/tui/src/resume_picker.rs +++ b/codex-rs/tui/src/resume_picker.rs @@ -113,7 +113,7 @@ pub async fn run_resume_picker( show_all, filter_cwd, ); - state.load_initial_page().await?; + state.start_initial_load(); state.request_frame(); let mut tui_events = alt.tui.event_stream().fuse(); @@ -359,25 +359,28 @@ impl PickerState { Ok(None) } - async fn load_initial_page(&mut self) -> Result<()> { - let provider_filter = vec![self.default_provider.clone()]; - let page = RolloutRecorder::list_conversations( - &self.codex_home, - PAGE_SIZE, - None, - INTERACTIVE_SESSION_SOURCES, - Some(provider_filter.as_slice()), - self.default_provider.as_str(), - ) - .await?; + fn start_initial_load(&mut self) { self.reset_pagination(); self.all_rows.clear(); self.filtered_rows.clear(); self.seen_paths.clear(); self.search_state = SearchState::Idle; self.selected = 0; - self.ingest_page(page); - Ok(()) + + let request_token = self.allocate_request_token(); + self.pagination.loading = LoadingState::Pending(PendingLoad { + request_token, + search_token: None, + }); + self.request_frame(); + + (self.page_loader)(PageLoadRequest { + codex_home: self.codex_home.clone(), + cursor: None, + request_token, + search_token: None, + default_provider: self.default_provider.clone(), + }); } fn handle_background_event(&mut self, event: BackgroundEvent) -> Result<()> { @@ -1260,6 +1263,166 @@ mod tests { assert_snapshot!("resume_picker_table", snapshot); } + #[test] + fn resume_picker_screen_snapshot() { + use crate::custom_terminal::Terminal; + use crate::test_backend::VT100Backend; + use uuid::Uuid; + + // Create real rollout files so the snapshot uses the actual listing pipeline. + let tempdir = tempfile::tempdir().expect("tempdir"); + let sessions_root = tempdir.path().join("sessions"); + std::fs::create_dir_all(&sessions_root).expect("mkdir sessions root"); + + let now = Utc::now(); + + // Helper to write a rollout file with minimal meta + one user message. + let write_rollout = |ts: DateTime, cwd: &str, branch: &str, preview: &str| { + let dir = sessions_root + .join(ts.format("%Y").to_string()) + .join(ts.format("%m").to_string()) + .join(ts.format("%d").to_string()); + std::fs::create_dir_all(&dir).expect("mkdir date dirs"); + let filename = format!( + "rollout-{}-{}.jsonl", + ts.format("%Y-%m-%dT%H-%M-%S"), + Uuid::new_v4() + ); + let path = dir.join(filename); + let meta = serde_json::json!({ + "timestamp": ts.to_rfc3339(), + "item": { + "SessionMeta": { + "meta": { + "id": Uuid::new_v4(), + "timestamp": ts.to_rfc3339(), + "cwd": cwd, + "originator": "user", + "cli_version": "0.0.0", + "instructions": null, + "source": "Cli", + "model_provider": "openai", + } + } + } + }); + let user = serde_json::json!({ + "timestamp": ts.to_rfc3339(), + "item": { + "EventMsg": { + "UserMessage": { + "message": preview, + "images": null + } + } + } + }); + let branch_meta = serde_json::json!({ + "timestamp": ts.to_rfc3339(), + "item": { + "EventMsg": { + "SessionMeta": { + "meta": { + "git_branch": branch + } + } + } + } + }); + std::fs::write(&path, format!("{meta}\n{user}\n{branch_meta}\n")) + .expect("write rollout"); + }; + + write_rollout( + now - Duration::seconds(42), + "/tmp/project", + "feature/resume", + "Fix resume picker timestamps", + ); + write_rollout( + now - Duration::minutes(35), + "/tmp/other", + "main", + "Investigate lazy pagination cap", + ); + + let loader: PageLoader = Arc::new(|_| {}); + let mut state = PickerState::new( + PathBuf::from("/tmp"), + FrameRequester::test_dummy(), + loader, + String::from("openai"), + true, + None, + ); + + let page = block_on_future(RolloutRecorder::list_conversations( + &state.codex_home, + PAGE_SIZE, + None, + INTERACTIVE_SESSION_SOURCES, + Some(&[String::from("openai")]), + "openai", + )) + .expect("list conversations"); + + let rows = rows_from_items(page.items); + state.all_rows = rows.clone(); + state.filtered_rows = rows; + state.view_rows = Some(4); + state.selected = 0; + state.scroll_top = 0; + state.update_view_rows(4); + + let metrics = calculate_column_metrics(&state.filtered_rows, state.show_all); + + let width: u16 = 80; + let height: u16 = 9; + let backend = VT100Backend::new(width, height); + let mut terminal = Terminal::with_options(backend).expect("terminal"); + terminal.set_viewport_area(Rect::new(0, 0, width, height)); + + { + let mut frame = terminal.get_frame(); + let area = frame.area(); + let [header, search, columns, list, hint] = Layout::vertical([ + Constraint::Length(1), + Constraint::Length(1), + Constraint::Length(1), + Constraint::Min(area.height.saturating_sub(4)), + Constraint::Length(1), + ]) + .areas(area); + + frame.render_widget_ref( + Line::from(vec!["Resume a previous session".bold().cyan()]), + header, + ); + + frame.render_widget_ref(Line::from("Type to search".dim()), search); + + render_column_headers(&mut frame, columns, &metrics); + render_list(&mut frame, list, &state, &metrics); + + let hint_line: Line = vec![ + key_hint::plain(KeyCode::Enter).into(), + " to resume ".dim(), + " ".dim(), + key_hint::plain(KeyCode::Esc).into(), + " to start new ".dim(), + " ".dim(), + key_hint::ctrl(KeyCode::Char('c')).into(), + " to quit ".dim(), + ] + .into(); + frame.render_widget_ref(hint_line, hint); + } + terminal.flush().expect("flush"); + + let snapshot = terminal.backend().to_string(); + assert_snapshot!("resume_picker_screen", snapshot); + } + #[test] fn pageless_scrolling_deduplicates_and_keeps_order() { let loader: PageLoader = Arc::new(|_| {}); diff --git a/codex-rs/tui/src/slash_command.rs b/codex-rs/tui/src/slash_command.rs index 969d279b07..47b330cba6 100644 --- a/codex-rs/tui/src/slash_command.rs +++ b/codex-rs/tui/src/slash_command.rs @@ -16,6 +16,7 @@ pub enum SlashCommand { Approvals, Review, New, + Resume, Init, Compact, Undo, @@ -40,6 +41,7 @@ impl SlashCommand { SlashCommand::Init => "create an AGENTS.md file with instructions for Codex", SlashCommand::Compact => "summarize conversation to prevent hitting the context limit", SlashCommand::Review => "review my current changes and find issues", + SlashCommand::Resume => "resume a saved chat", SlashCommand::Undo => "ask Codex to undo a turn", SlashCommand::Quit | SlashCommand::Exit => "exit Codex", SlashCommand::Diff => "show git diff (including untracked files)", @@ -64,6 +66,7 @@ impl SlashCommand { pub fn available_during_task(self) -> bool { match self { SlashCommand::New + | SlashCommand::Resume | SlashCommand::Init | SlashCommand::Compact | SlashCommand::Undo diff --git a/codex-rs/tui/src/snapshots/codex_tui__resume_picker__tests__resume_picker_screen.snap b/codex-rs/tui/src/snapshots/codex_tui__resume_picker__tests__resume_picker_screen.snap new file mode 100644 index 0000000000..79a169a06d --- /dev/null +++ b/codex-rs/tui/src/snapshots/codex_tui__resume_picker__tests__resume_picker_screen.snap @@ -0,0 +1,14 @@ +--- +source: tui/src/resume_picker.rs +assertion_line: 1438 +expression: snapshot +--- +Resume a previous session +Type to search + Updated Branch CWD Conversation +No sessions yet + + + + +enter to resume esc to start new ctrl + c to quit diff --git a/docs/slash_commands.md b/docs/slash_commands.md index 4c1a244764..6961461d42 100644 --- a/docs/slash_commands.md +++ b/docs/slash_commands.md @@ -16,6 +16,7 @@ Control Codex’s behavior during an interactive session with slash commands. | `/approvals` | choose what Codex can do without approval | | `/review` | review my current changes and find issues | | `/new` | start a new chat during a conversation | +| `/resume` | resume an old chat | | `/init` | create an AGENTS.md file with instructions for Codex | | `/compact` | summarize conversation to prevent hitting the context limit | | `/undo` | ask Codex to undo a turn | From 8d0f023fa9d691f4d850953da046d2918d427c78 Mon Sep 17 00:00:00 2001 From: jif-oai Date: Wed, 3 Dec 2025 20:06:47 +0000 Subject: [PATCH 10/30] chore: update unified exec sandboxing detection (#7541) No integration test for now because it would make them flaky. Tracking it in my todos to add some once we have a clock based system for integration tests --- codex-rs/core/src/exec.rs | 13 +++++++ codex-rs/core/src/message_history.rs | 2 +- codex-rs/core/src/unified_exec/session.rs | 15 +++----- .../core/src/unified_exec/session_manager.rs | 37 +++++++++++++++++++ 4 files changed, 56 insertions(+), 11 deletions(-) diff --git a/codex-rs/core/src/exec.rs b/codex-rs/core/src/exec.rs index f46444675f..ba1ac43004 100644 --- a/codex-rs/core/src/exec.rs +++ b/codex-rs/core/src/exec.rs @@ -485,6 +485,19 @@ pub struct ExecToolCallOutput { pub timed_out: bool, } +impl Default for ExecToolCallOutput { + fn default() -> Self { + Self { + exit_code: 0, + stdout: StreamOutput::new(String::new()), + stderr: StreamOutput::new(String::new()), + aggregated_output: StreamOutput::new(String::new()), + duration: Duration::ZERO, + timed_out: false, + } + } +} + #[cfg_attr(not(target_os = "windows"), allow(unused_variables))] async fn exec( params: ExecParams, diff --git a/codex-rs/core/src/message_history.rs b/codex-rs/core/src/message_history.rs index e46dd93067..ecc6851336 100644 --- a/codex-rs/core/src/message_history.rs +++ b/codex-rs/core/src/message_history.rs @@ -590,7 +590,7 @@ mod tests { assert_eq!(entries.len(), 1); assert_eq!(entries[0].text, long_entry); - let pruned_len = std::fs::metadata(&history_path).expect("metadata").len() as u64; + let pruned_len = std::fs::metadata(&history_path).expect("metadata").len(); let max_bytes = config .history .max_bytes diff --git a/codex-rs/core/src/unified_exec/session.rs b/codex-rs/core/src/unified_exec/session.rs index 710334c806..a6e4167ade 100644 --- a/codex-rs/core/src/unified_exec/session.rs +++ b/codex-rs/core/src/unified_exec/session.rs @@ -149,7 +149,7 @@ impl UnifiedExecSession { guard.snapshot() } - fn sandbox_type(&self) -> SandboxType { + pub(crate) fn sandbox_type(&self) -> SandboxType { self.sandbox_type } @@ -172,10 +172,8 @@ impl UnifiedExecSession { let exec_output = ExecToolCallOutput { exit_code, stdout: StreamOutput::new(aggregated_text.clone()), - stderr: StreamOutput::new(String::new()), aggregated_output: StreamOutput::new(aggregated_text.clone()), - duration: Duration::ZERO, - timed_out: false, + ..Default::default() }; if is_likely_sandbox_denied(self.sandbox_type(), &exec_output) { @@ -184,7 +182,7 @@ impl UnifiedExecSession { TruncationPolicy::Tokens(UNIFIED_EXEC_OUTPUT_MAX_TOKENS), ); let message = if snippet.is_empty() { - format!("exit code {exit_code}") + format!("Session creation failed with exit code {exit_code}") } else { snippet }; @@ -205,10 +203,7 @@ impl UnifiedExecSession { } = spawned; let managed = Self::new(session, output_rx, sandbox_type); - let exit_ready = match exit_rx.try_recv() { - Ok(_) | Err(TryRecvError::Closed) => true, - Err(TryRecvError::Empty) => false, - }; + let exit_ready = matches!(exit_rx.try_recv(), Ok(_) | Err(TryRecvError::Closed)); if exit_ready { managed.signal_exit(); @@ -216,7 +211,7 @@ impl UnifiedExecSession { return Ok(managed); } - if tokio::time::timeout(Duration::from_millis(50), &mut exit_rx) + if tokio::time::timeout(Duration::from_millis(150), &mut exit_rx) .await .is_ok() { diff --git a/codex-rs/core/src/unified_exec/session_manager.rs b/codex-rs/core/src/unified_exec/session_manager.rs index 72c02cdb99..d37ad4d3fc 100644 --- a/codex-rs/core/src/unified_exec/session_manager.rs +++ b/codex-rs/core/src/unified_exec/session_manager.rs @@ -153,6 +153,7 @@ impl UnifiedExecSessionManager { let output = formatted_truncate_text(&text, TruncationPolicy::Tokens(max_tokens)); let has_exited = session.has_exited(); let exit_code = session.exit_code(); + let sandbox_type = session.sandbox_type(); let chunk_id = generate_chunk_id(); let process_id = if has_exited { None @@ -201,6 +202,9 @@ impl UnifiedExecSessionManager { Some(request.process_id), ) .await; + + // Exit code should always be Some + sandboxing::check_sandboxing(sandbox_type, &text, exit_code.unwrap_or_default())?; } Ok(response) @@ -703,6 +707,39 @@ impl UnifiedExecSessionManager { } } +mod sandboxing { + use super::*; + use crate::exec::SandboxType; + use crate::exec::is_likely_sandbox_denied; + use crate::unified_exec::UNIFIED_EXEC_OUTPUT_MAX_TOKENS; + + pub(crate) fn check_sandboxing( + sandbox_type: SandboxType, + text: &str, + exit_code: i32, + ) -> Result<(), UnifiedExecError> { + let exec_output = ExecToolCallOutput { + exit_code, + stderr: StreamOutput::new(text.to_string()), + aggregated_output: StreamOutput::new(text.to_string()), + ..Default::default() + }; + if is_likely_sandbox_denied(sandbox_type, &exec_output) { + let snippet = formatted_truncate_text( + text, + TruncationPolicy::Tokens(UNIFIED_EXEC_OUTPUT_MAX_TOKENS), + ); + let message = if snippet.is_empty() { + format!("Session exited with code {exit_code}") + } else { + snippet + }; + return Err(UnifiedExecError::sandbox_denied(message, exec_output)); + } + Ok(()) + } +} + enum SessionStatus { Alive { exit_code: Option, From 9e6c2c1e64cd327a9e9975fadc7bd127583f084c Mon Sep 17 00:00:00 2001 From: jif-oai Date: Wed, 3 Dec 2025 20:06:55 +0000 Subject: [PATCH 11/30] feat: add pycache to excluded directories (#7545) --- codex-rs/utils/git/src/ghost_commits.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/codex-rs/utils/git/src/ghost_commits.rs b/codex-rs/utils/git/src/ghost_commits.rs index 6a3eec4894..8544525f0f 100644 --- a/codex-rs/utils/git/src/ghost_commits.rs +++ b/codex-rs/utils/git/src/ghost_commits.rs @@ -42,6 +42,7 @@ const DEFAULT_IGNORED_DIR_NAMES: &[&str] = &[ ".mypy_cache", ".cache", ".tox", + "__pycache__", ]; /// Options to control ghost commit creation. From 7f068cfbcca0b0028ac23625e4097a93f9beeacf Mon Sep 17 00:00:00 2001 From: jif-oai Date: Wed, 3 Dec 2025 20:15:12 +0000 Subject: [PATCH 12/30] fix: main (#7546) --- codex-rs/tui/src/app.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 504452bc80..32fc095662 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -523,6 +523,7 @@ impl App { enhanced_keys_supported: self.enhanced_keys_supported, auth_manager: self.auth_manager.clone(), feedback: self.feedback.clone(), + is_first_run: false, }; self.chat_widget = ChatWidget::new_from_existing( init, From 71504325d3d34a399f5ad958fd13043d6d74062c Mon Sep 17 00:00:00 2001 From: Ahmed Ibrahim Date: Wed, 3 Dec 2025 12:30:43 -0800 Subject: [PATCH 13/30] Migrate model preset (#7542) - Introduce `openai_models` in `/core` - Move `PRESETS` under it - Move `ModelPreset`, `ModelUpgrade`, `ReasoningEffortPreset`, `ReasoningEffortPreset`, and `ReasoningEffortPreset` to `protocol` - Introduce `Op::ListModels` and `EventMsg::AvailableModels` Next steps: - migrate `app-server` and `tui` to use the introduced Operation --- codex-rs/Cargo.lock | 2 - .../app-server-protocol/src/protocol/v1.rs | 2 +- .../app-server-protocol/src/protocol/v2.rs | 2 +- codex-rs/app-server/src/models.rs | 8 +- .../suite/codex_message_processor_flow.rs | 2 +- codex-rs/app-server/tests/suite/config.rs | 2 +- .../app-server/tests/suite/v2/model_list.rs | 2 +- .../app-server/tests/suite/v2/turn_start.rs | 2 +- codex-rs/codex-api/src/common.rs | 2 +- codex-rs/common/Cargo.toml | 2 - codex-rs/common/src/lib.rs | 2 - codex-rs/core/src/client.rs | 2 +- codex-rs/core/src/codex.rs | 26 ++- codex-rs/core/src/config/edit.rs | 4 +- codex-rs/core/src/config/mod.rs | 2 +- codex-rs/core/src/config/profile.rs | 2 +- codex-rs/core/src/lib.rs | 1 + codex-rs/core/src/model_family.rs | 2 +- codex-rs/core/src/openai_models/mod.rs | 1 + .../src/openai_models}/model_presets.rs | 184 +++++++---------- codex-rs/core/src/rollout/policy.rs | 1 + codex-rs/core/src/sandboxing/assessment.rs | 2 +- codex-rs/core/tests/suite/client.rs | 2 +- codex-rs/core/tests/suite/list_models.rs | 187 ++++++++++++++++++ codex-rs/core/tests/suite/mod.rs | 1 + codex-rs/core/tests/suite/model_overrides.rs | 2 +- codex-rs/core/tests/suite/prompt_caching.rs | 2 +- .../src/event_processor_with_human_output.rs | 1 + codex-rs/mcp-server/src/codex_tool_runner.rs | 1 + codex-rs/mcp-server/src/outgoing_message.rs | 2 +- codex-rs/otel/src/otel_event_manager.rs | 2 +- codex-rs/protocol/src/config_types.rs | 29 --- codex-rs/protocol/src/lib.rs | 1 + codex-rs/protocol/src/openai_models.rs | 80 ++++++++ codex-rs/protocol/src/protocol.rs | 7 +- codex-rs/tui/src/app.rs | 10 +- codex-rs/tui/src/app_event.rs | 4 +- codex-rs/tui/src/chatwidget.rs | 13 +- codex-rs/tui/src/chatwidget/tests.rs | 18 +- codex-rs/tui/src/history_cell.rs | 2 +- codex-rs/tui/src/model_migration.rs | 6 +- codex-rs/tui/src/status/tests.rs | 2 +- 42 files changed, 430 insertions(+), 197 deletions(-) create mode 100644 codex-rs/core/src/openai_models/mod.rs rename codex-rs/{common/src => core/src/openai_models}/model_presets.rs (68%) create mode 100644 codex-rs/core/tests/suite/list_models.rs create mode 100644 codex-rs/protocol/src/openai_models.rs diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index a1684bf0f5..4429858c91 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -1117,12 +1117,10 @@ name = "codex-common" version = "0.0.0" dependencies = [ "clap", - "codex-app-server-protocol", "codex-core", "codex-lmstudio", "codex-ollama", "codex-protocol", - "once_cell", "serde", "toml", ] diff --git a/codex-rs/app-server-protocol/src/protocol/v1.rs b/codex-rs/app-server-protocol/src/protocol/v1.rs index 54f80c9fd4..1576eb0d93 100644 --- a/codex-rs/app-server-protocol/src/protocol/v1.rs +++ b/codex-rs/app-server-protocol/src/protocol/v1.rs @@ -3,11 +3,11 @@ use std::path::PathBuf; use codex_protocol::ConversationId; use codex_protocol::config_types::ForcedLoginMethod; -use codex_protocol::config_types::ReasoningEffort; use codex_protocol::config_types::ReasoningSummary; use codex_protocol::config_types::SandboxMode; use codex_protocol::config_types::Verbosity; use codex_protocol::models::ResponseItem; +use codex_protocol::openai_models::ReasoningEffort; use codex_protocol::parse_command::ParsedCommand; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::EventMsg; diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index c672080720..e3c9fc0558 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -4,11 +4,11 @@ use std::path::PathBuf; use crate::protocol::common::AuthMode; use codex_protocol::account::PlanType; use codex_protocol::approvals::SandboxCommandAssessment as CoreSandboxCommandAssessment; -use codex_protocol::config_types::ReasoningEffort; use codex_protocol::config_types::ReasoningSummary; use codex_protocol::items::AgentMessageContent as CoreAgentMessageContent; use codex_protocol::items::TurnItem as CoreTurnItem; use codex_protocol::models::ResponseItem; +use codex_protocol::openai_models::ReasoningEffort; use codex_protocol::parse_command::ParsedCommand as CoreParsedCommand; use codex_protocol::plan_tool::PlanItemArg as CorePlanItemArg; use codex_protocol::plan_tool::StepStatus as CorePlanStepStatus; diff --git a/codex-rs/app-server/src/models.rs b/codex-rs/app-server/src/models.rs index d03795c2d4..78f6fd5851 100644 --- a/codex-rs/app-server/src/models.rs +++ b/codex-rs/app-server/src/models.rs @@ -1,9 +1,9 @@ use codex_app_server_protocol::AuthMode; use codex_app_server_protocol::Model; use codex_app_server_protocol::ReasoningEffortOption; -use codex_common::model_presets::ModelPreset; -use codex_common::model_presets::ReasoningEffortPreset; -use codex_common::model_presets::builtin_model_presets; +use codex_core::openai_models::model_presets::builtin_model_presets; +use codex_protocol::openai_models::ModelPreset; +use codex_protocol::openai_models::ReasoningEffortPreset; pub fn supported_models(auth_mode: Option) -> Vec { builtin_model_presets(auth_mode) @@ -27,7 +27,7 @@ fn model_from_preset(preset: ModelPreset) -> Model { } fn reasoning_efforts_from_preset( - efforts: &'static [ReasoningEffortPreset], + efforts: Vec, ) -> Vec { efforts .iter() diff --git a/codex-rs/app-server/tests/suite/codex_message_processor_flow.rs b/codex-rs/app-server/tests/suite/codex_message_processor_flow.rs index a64aca8051..4b206436c8 100644 --- a/codex-rs/app-server/tests/suite/codex_message_processor_flow.rs +++ b/codex-rs/app-server/tests/suite/codex_message_processor_flow.rs @@ -23,10 +23,10 @@ use codex_app_server_protocol::SendUserTurnResponse; use codex_app_server_protocol::ServerRequest; use codex_core::protocol::AskForApproval; use codex_core::protocol::SandboxPolicy; -use codex_core::protocol_config_types::ReasoningEffort; use codex_core::protocol_config_types::ReasoningSummary; use codex_core::spawn::CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR; use codex_protocol::config_types::SandboxMode; +use codex_protocol::openai_models::ReasoningEffort; use codex_protocol::parse_command::ParsedCommand; use codex_protocol::protocol::Event; use codex_protocol::protocol::EventMsg; diff --git a/codex-rs/app-server/tests/suite/config.rs b/codex-rs/app-server/tests/suite/config.rs index 75dba57229..88e74a6fb4 100644 --- a/codex-rs/app-server/tests/suite/config.rs +++ b/codex-rs/app-server/tests/suite/config.rs @@ -10,10 +10,10 @@ use codex_app_server_protocol::Tools; use codex_app_server_protocol::UserSavedConfig; use codex_core::protocol::AskForApproval; use codex_protocol::config_types::ForcedLoginMethod; -use codex_protocol::config_types::ReasoningEffort; use codex_protocol::config_types::ReasoningSummary; use codex_protocol::config_types::SandboxMode; use codex_protocol::config_types::Verbosity; +use codex_protocol::openai_models::ReasoningEffort; use pretty_assertions::assert_eq; use std::collections::HashMap; use std::path::Path; diff --git a/codex-rs/app-server/tests/suite/v2/model_list.rs b/codex-rs/app-server/tests/suite/v2/model_list.rs index 3c4844fed9..8ca85c9c3b 100644 --- a/codex-rs/app-server/tests/suite/v2/model_list.rs +++ b/codex-rs/app-server/tests/suite/v2/model_list.rs @@ -11,7 +11,7 @@ use codex_app_server_protocol::ModelListParams; use codex_app_server_protocol::ModelListResponse; use codex_app_server_protocol::ReasoningEffortOption; use codex_app_server_protocol::RequestId; -use codex_protocol::config_types::ReasoningEffort; +use codex_protocol::openai_models::ReasoningEffort; use pretty_assertions::assert_eq; use tempfile::TempDir; use tokio::time::timeout; diff --git a/codex-rs/app-server/tests/suite/v2/turn_start.rs b/codex-rs/app-server/tests/suite/v2/turn_start.rs index 03ee279e51..e4cd722947 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start.rs @@ -30,8 +30,8 @@ use codex_app_server_protocol::TurnStartResponse; use codex_app_server_protocol::TurnStartedNotification; use codex_app_server_protocol::TurnStatus; use codex_app_server_protocol::UserInput as V2UserInput; -use codex_core::protocol_config_types::ReasoningEffort; use codex_core::protocol_config_types::ReasoningSummary; +use codex_protocol::openai_models::ReasoningEffort; use core_test_support::skip_if_no_network; use pretty_assertions::assert_eq; use std::path::Path; diff --git a/codex-rs/codex-api/src/common.rs b/codex-rs/codex-api/src/common.rs index addab02dc7..19e82de332 100644 --- a/codex-rs/codex-api/src/common.rs +++ b/codex-rs/codex-api/src/common.rs @@ -1,8 +1,8 @@ use crate::error::ApiError; -use codex_protocol::config_types::ReasoningEffort as ReasoningEffortConfig; use codex_protocol::config_types::ReasoningSummary as ReasoningSummaryConfig; use codex_protocol::config_types::Verbosity as VerbosityConfig; use codex_protocol::models::ResponseItem; +use codex_protocol::openai_models::ReasoningEffort as ReasoningEffortConfig; use codex_protocol::protocol::RateLimitSnapshot; use codex_protocol::protocol::TokenUsage; use futures::Stream; diff --git a/codex-rs/common/Cargo.toml b/codex-rs/common/Cargo.toml index 377d054483..25264eff09 100644 --- a/codex-rs/common/Cargo.toml +++ b/codex-rs/common/Cargo.toml @@ -9,12 +9,10 @@ workspace = true [dependencies] clap = { workspace = true, features = ["derive", "wrap_help"], optional = true } -codex-app-server-protocol = { workspace = true } codex-core = { workspace = true } codex-lmstudio = { workspace = true } codex-ollama = { workspace = true } codex-protocol = { workspace = true } -once_cell = { workspace = true } serde = { workspace = true, optional = true } toml = { workspace = true, optional = true } diff --git a/codex-rs/common/src/lib.rs b/codex-rs/common/src/lib.rs index 5092b3be24..d5513b8325 100644 --- a/codex-rs/common/src/lib.rs +++ b/codex-rs/common/src/lib.rs @@ -32,8 +32,6 @@ mod config_summary; pub use config_summary::create_config_summary_entries; // Shared fuzzy matcher (used by TUI selection popups and other UI filtering) pub mod fuzzy_match; -// Shared model presets used by TUI and MCP server -pub mod model_presets; // Shared approval presets (AskForApproval + Sandbox) used by TUI and MCP server // Not to be confused with AskForApproval, which we should probably rename to EscalationPolicy. pub mod approval_presets; diff --git a/codex-rs/core/src/client.rs b/codex-rs/core/src/client.rs index 82839522c9..f4248f30ab 100644 --- a/codex-rs/core/src/client.rs +++ b/codex-rs/core/src/client.rs @@ -20,9 +20,9 @@ use codex_api::error::ApiError; use codex_app_server_protocol::AuthMode; use codex_otel::otel_event_manager::OtelEventManager; use codex_protocol::ConversationId; -use codex_protocol::config_types::ReasoningEffort as ReasoningEffortConfig; use codex_protocol::config_types::ReasoningSummary as ReasoningSummaryConfig; use codex_protocol::models::ResponseItem; +use codex_protocol::openai_models::ReasoningEffort as ReasoningEffortConfig; use codex_protocol::protocol::SessionSource; use eventsource_stream::Event; use eventsource_stream::EventStreamError; diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 682861d648..29e7a1ce88 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -22,6 +22,7 @@ use crate::user_notification::UserNotifier; use crate::util::error_or_panic; use async_channel::Receiver; use async_channel::Sender; +use codex_app_server_protocol::AuthMode; use codex_protocol::ConversationId; use codex_protocol::items::TurnItem; use codex_protocol::protocol::FileChange; @@ -126,12 +127,12 @@ use crate::util::backoff; use codex_async_utils::OrCancelExt; use codex_execpolicy::Policy as ExecPolicy; use codex_otel::otel_event_manager::OtelEventManager; -use codex_protocol::config_types::ReasoningEffort as ReasoningEffortConfig; use codex_protocol::config_types::ReasoningSummary as ReasoningSummaryConfig; use codex_protocol::models::ContentItem; use codex_protocol::models::FunctionCallOutputPayload; use codex_protocol::models::ResponseInputItem; use codex_protocol::models::ResponseItem; +use codex_protocol::openai_models::ReasoningEffort as ReasoningEffortConfig; use codex_protocol::protocol::CodexErrorInfo; use codex_protocol::protocol::InitialHistory; use codex_protocol::user_input::UserInput; @@ -638,6 +639,14 @@ impl Session { Ok(sess) } + pub(crate) fn get_auth_mode(&self) -> AuthMode { + self.services + .auth_manager + .auth() + .map(|a| a.mode) + .unwrap_or(AuthMode::ApiKey) + } + pub(crate) fn get_tx_event(&self) -> Sender { self.tx_event.clone() } @@ -1478,6 +1487,9 @@ async fn submission_loop(sess: Arc, config: Arc, rx_sub: Receiv Op::Review { review_request } => { handlers::review(&sess, &config, sub.id.clone(), review_request).await; } + Op::ListModels => { + handlers::list_models(&sess, sub.id.clone(), Some(sess.get_auth_mode())).await; + } _ => {} // Ignore unknown ops; enum is non_exhaustive to allow extensions. } } @@ -1494,12 +1506,15 @@ mod handlers { use crate::config::Config; use crate::mcp::auth::compute_auth_statuses; use crate::mcp::collect_mcp_snapshot_from_manager; + use crate::openai_models::model_presets::builtin_model_presets; use crate::review_prompts::resolve_review_request; use crate::tasks::CompactTask; use crate::tasks::RegularTask; use crate::tasks::UndoTask; use crate::tasks::UserShellCommandTask; + use codex_app_server_protocol::AuthMode; use codex_protocol::custom_prompts::CustomPrompt; + use codex_protocol::openai_models::AvailableModelsEvent; use codex_protocol::protocol::CodexErrorInfo; use codex_protocol::protocol::ErrorEvent; use codex_protocol::protocol::Event; @@ -1814,6 +1829,15 @@ mod handlers { } } } + + pub async fn list_models(sess: &Arc, sub_id: String, auth_mode: Option) { + let models = builtin_model_presets(auth_mode); + let event = Event { + id: sub_id, + msg: EventMsg::ListModelsResponse(AvailableModelsEvent { models }), + }; + sess.send_event_raw(event).await; + } } /// Spawn a review thread using the given prompt. diff --git a/codex-rs/core/src/config/edit.rs b/codex-rs/core/src/config/edit.rs index b8862fa5c5..68e2d206f0 100644 --- a/codex-rs/core/src/config/edit.rs +++ b/codex-rs/core/src/config/edit.rs @@ -2,8 +2,8 @@ use crate::config::CONFIG_TOML_FILE; use crate::config::types::McpServerConfig; use crate::config::types::Notice; use anyhow::Context; -use codex_protocol::config_types::ReasoningEffort; use codex_protocol::config_types::TrustLevel; +use codex_protocol::openai_models::ReasoningEffort; use std::collections::BTreeMap; use std::path::Path; use std::path::PathBuf; @@ -574,7 +574,7 @@ impl ConfigEditsBuilder { mod tests { use super::*; use crate::config::types::McpServerTransportConfig; - use codex_protocol::config_types::ReasoningEffort; + use codex_protocol::openai_models::ReasoningEffort; use pretty_assertions::assert_eq; use tempfile::tempdir; use tokio::runtime::Builder; diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index 6276d3b6e3..dccf0556f1 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -38,11 +38,11 @@ use crate::util::resolve_path; use codex_app_server_protocol::Tools; use codex_app_server_protocol::UserSavedConfig; use codex_protocol::config_types::ForcedLoginMethod; -use codex_protocol::config_types::ReasoningEffort; use codex_protocol::config_types::ReasoningSummary; use codex_protocol::config_types::SandboxMode; use codex_protocol::config_types::TrustLevel; use codex_protocol::config_types::Verbosity; +use codex_protocol::openai_models::ReasoningEffort; use codex_rmcp_client::OAuthCredentialsStoreMode; use dirs::home_dir; use dunce::canonicalize; diff --git a/codex-rs/core/src/config/profile.rs b/codex-rs/core/src/config/profile.rs index 3d9e60b8e5..5629465c40 100644 --- a/codex-rs/core/src/config/profile.rs +++ b/codex-rs/core/src/config/profile.rs @@ -2,10 +2,10 @@ use serde::Deserialize; use std::path::PathBuf; use crate::protocol::AskForApproval; -use codex_protocol::config_types::ReasoningEffort; use codex_protocol::config_types::ReasoningSummary; use codex_protocol::config_types::SandboxMode; use codex_protocol::config_types::Verbosity; +use codex_protocol::openai_models::ReasoningEffort; /// Collection of common configuration options that a user can define as a unit /// in `config.toml`. diff --git a/codex-rs/core/src/lib.rs b/codex-rs/core/src/lib.rs index d9ab6ee51f..d32366476a 100644 --- a/codex-rs/core/src/lib.rs +++ b/codex-rs/core/src/lib.rs @@ -32,6 +32,7 @@ pub mod git_info; pub mod landlock; pub mod mcp; mod mcp_connection_manager; +pub mod openai_models; pub use mcp_connection_manager::MCP_SANDBOX_STATE_CAPABILITY; pub use mcp_connection_manager::MCP_SANDBOX_STATE_NOTIFICATION; pub use mcp_connection_manager::SandboxState; diff --git a/codex-rs/core/src/model_family.rs b/codex-rs/core/src/model_family.rs index 5dea1c0168..0417f13b12 100644 --- a/codex-rs/core/src/model_family.rs +++ b/codex-rs/core/src/model_family.rs @@ -1,5 +1,5 @@ -use codex_protocol::config_types::ReasoningEffort; use codex_protocol::config_types::Verbosity; +use codex_protocol::openai_models::ReasoningEffort; use crate::config::types::ReasoningSummaryFormat; use crate::tools::handlers::apply_patch::ApplyPatchToolType; diff --git a/codex-rs/core/src/openai_models/mod.rs b/codex-rs/core/src/openai_models/mod.rs new file mode 100644 index 0000000000..7df68c4ab7 --- /dev/null +++ b/codex-rs/core/src/openai_models/mod.rs @@ -0,0 +1 @@ +pub mod model_presets; diff --git a/codex-rs/common/src/model_presets.rs b/codex-rs/core/src/openai_models/model_presets.rs similarity index 68% rename from codex-rs/common/src/model_presets.rs rename to codex-rs/core/src/openai_models/model_presets.rs index a031f23b1d..f649d88741 100644 --- a/codex-rs/common/src/model_presets.rs +++ b/codex-rs/core/src/openai_models/model_presets.rs @@ -1,76 +1,38 @@ -use std::collections::HashMap; - use codex_app_server_protocol::AuthMode; -use codex_core::protocol_config_types::ReasoningEffort; +use codex_protocol::openai_models::ModelPreset; +use codex_protocol::openai_models::ModelUpgrade; +use codex_protocol::openai_models::ReasoningEffort; +use codex_protocol::openai_models::ReasoningEffortPreset; use once_cell::sync::Lazy; pub const HIDE_GPT5_1_MIGRATION_PROMPT_CONFIG: &str = "hide_gpt5_1_migration_prompt"; pub const HIDE_GPT_5_1_CODEX_MAX_MIGRATION_PROMPT_CONFIG: &str = "hide_gpt-5.1-codex-max_migration_prompt"; -/// A reasoning effort option that can be surfaced for a model. -#[derive(Debug, Clone, Copy)] -pub struct ReasoningEffortPreset { - /// Effort level that the model supports. - pub effort: ReasoningEffort, - /// Short human description shown next to the effort in UIs. - pub description: &'static str, -} - -#[derive(Debug, Clone)] -pub struct ModelUpgrade { - pub id: &'static str, - pub reasoning_effort_mapping: Option>, - pub migration_config_key: &'static str, -} - -/// Metadata describing a Codex-supported model. -#[derive(Debug, Clone)] -pub struct ModelPreset { - /// Stable identifier for the preset. - pub id: &'static str, - /// Model slug (e.g., "gpt-5"). - pub model: &'static str, - /// Display name shown in UIs. - pub display_name: &'static str, - /// Short human description shown in UIs. - pub description: &'static str, - /// Reasoning effort applied when none is explicitly chosen. - pub default_reasoning_effort: ReasoningEffort, - /// Supported reasoning effort options. - pub supported_reasoning_efforts: &'static [ReasoningEffortPreset], - /// Whether this is the default model for new users. - pub is_default: bool, - /// recommended upgrade model - pub upgrade: Option, - /// Whether this preset should appear in the picker UI. - pub show_in_picker: bool, -} - static PRESETS: Lazy> = Lazy::new(|| { vec![ ModelPreset { - id: "gpt-5.1-codex-max", - model: "gpt-5.1-codex-max", - display_name: "gpt-5.1-codex-max", - description: "Latest Codex-optimized flagship for deep and fast reasoning.", + id: "gpt-5.1-codex-max".to_string(), + model: "gpt-5.1-codex-max".to_string(), + display_name: "gpt-5.1-codex-max".to_string(), + description: "Latest Codex-optimized flagship for deep and fast reasoning.".to_string(), default_reasoning_effort: ReasoningEffort::Medium, - supported_reasoning_efforts: &[ + supported_reasoning_efforts: vec![ ReasoningEffortPreset { effort: ReasoningEffort::Low, - description: "Fast responses with lighter reasoning", + description: "Fast responses with lighter reasoning".to_string(), }, ReasoningEffortPreset { effort: ReasoningEffort::Medium, - description: "Balances speed and reasoning depth for everyday tasks", + description: "Balances speed and reasoning depth for everyday tasks".to_string(), }, ReasoningEffortPreset { effort: ReasoningEffort::High, - description: "Maximizes reasoning depth for complex problems", + description: "Maximizes reasoning depth for complex problems".to_string(), }, ReasoningEffortPreset { effort: ReasoningEffort::XHigh, - description: "Extra high reasoning depth for complex problems", + description: "Extra high reasoning depth for complex problems".to_string(), }, ], is_default: true, @@ -78,167 +40,169 @@ static PRESETS: Lazy> = Lazy::new(|| { show_in_picker: true, }, ModelPreset { - id: "gpt-5.1-codex", - model: "gpt-5.1-codex", - display_name: "gpt-5.1-codex", - description: "Optimized for codex.", + id: "gpt-5.1-codex".to_string(), + model: "gpt-5.1-codex".to_string(), + display_name: "gpt-5.1-codex".to_string(), + description: "Optimized for codex.".to_string(), default_reasoning_effort: ReasoningEffort::Medium, - supported_reasoning_efforts: &[ + supported_reasoning_efforts: vec![ ReasoningEffortPreset { effort: ReasoningEffort::Low, - description: "Fastest responses with limited reasoning", + description: "Fastest responses with limited reasoning".to_string(), }, ReasoningEffortPreset { effort: ReasoningEffort::Medium, - description: "Dynamically adjusts reasoning based on the task", + description: "Dynamically adjusts reasoning based on the task".to_string(), }, ReasoningEffortPreset { effort: ReasoningEffort::High, - description: "Maximizes reasoning depth for complex or ambiguous problems", + description: "Maximizes reasoning depth for complex or ambiguous problems" + .to_string(), }, ], is_default: false, upgrade: Some(ModelUpgrade { - id: "gpt-5.1-codex-max", + id: "gpt-5.1-codex-max".to_string(), reasoning_effort_mapping: None, - migration_config_key: HIDE_GPT_5_1_CODEX_MAX_MIGRATION_PROMPT_CONFIG, + migration_config_key: HIDE_GPT_5_1_CODEX_MAX_MIGRATION_PROMPT_CONFIG.to_string(), }), show_in_picker: true, }, ModelPreset { - id: "gpt-5.1-codex-mini", - model: "gpt-5.1-codex-mini", - display_name: "gpt-5.1-codex-mini", - description: "Optimized for codex. Cheaper, faster, but less capable.", + id: "gpt-5.1-codex-mini".to_string(), + model: "gpt-5.1-codex-mini".to_string(), + display_name: "gpt-5.1-codex-mini".to_string(), + description: "Optimized for codex. Cheaper, faster, but less capable.".to_string(), default_reasoning_effort: ReasoningEffort::Medium, - supported_reasoning_efforts: &[ + supported_reasoning_efforts: vec![ ReasoningEffortPreset { effort: ReasoningEffort::Medium, - description: "Dynamically adjusts reasoning based on the task", + description: "Dynamically adjusts reasoning based on the task".to_string(), }, ReasoningEffortPreset { effort: ReasoningEffort::High, - description: "Maximizes reasoning depth for complex or ambiguous problems", + description: "Maximizes reasoning depth for complex or ambiguous problems" + .to_string(), }, ], is_default: false, upgrade: Some(ModelUpgrade { - id: "gpt-5.1-codex-max", + id: "gpt-5.1-codex-max".to_string(), reasoning_effort_mapping: None, - migration_config_key: HIDE_GPT_5_1_CODEX_MAX_MIGRATION_PROMPT_CONFIG, + migration_config_key: HIDE_GPT_5_1_CODEX_MAX_MIGRATION_PROMPT_CONFIG.to_string(), }), show_in_picker: true, }, ModelPreset { - id: "gpt-5.1", - model: "gpt-5.1", - display_name: "gpt-5.1", - description: "Broad world knowledge with strong general reasoning.", + id: "gpt-5.1".to_string(), + model: "gpt-5.1".to_string(), + display_name: "gpt-5.1".to_string(), + description: "Broad world knowledge with strong general reasoning.".to_string(), default_reasoning_effort: ReasoningEffort::Medium, - supported_reasoning_efforts: &[ + supported_reasoning_efforts: vec![ ReasoningEffortPreset { effort: ReasoningEffort::Low, - description: "Balances speed with some reasoning; useful for straightforward queries and short explanations", + description: "Balances speed with some reasoning; useful for straightforward queries and short explanations".to_string(), }, ReasoningEffortPreset { effort: ReasoningEffort::Medium, - description: "Provides a solid balance of reasoning depth and latency for general-purpose tasks", + description: "Provides a solid balance of reasoning depth and latency for general-purpose tasks".to_string(), }, ReasoningEffortPreset { effort: ReasoningEffort::High, - description: "Maximizes reasoning depth for complex or ambiguous problems", + description: "Maximizes reasoning depth for complex or ambiguous problems".to_string(), }, ], is_default: false, upgrade: Some(ModelUpgrade { - id: "gpt-5.1-codex-max", + id: "gpt-5.1-codex-max".to_string(), reasoning_effort_mapping: None, - migration_config_key: HIDE_GPT_5_1_CODEX_MAX_MIGRATION_PROMPT_CONFIG, + migration_config_key: HIDE_GPT_5_1_CODEX_MAX_MIGRATION_PROMPT_CONFIG.to_string(), }), show_in_picker: true, }, // Deprecated models. ModelPreset { - id: "gpt-5-codex", - model: "gpt-5-codex", - display_name: "gpt-5-codex", - description: "Optimized for codex.", + id: "gpt-5-codex".to_string(), + model: "gpt-5-codex".to_string(), + display_name: "gpt-5-codex".to_string(), + description: "Optimized for codex.".to_string(), default_reasoning_effort: ReasoningEffort::Medium, - supported_reasoning_efforts: &[ + supported_reasoning_efforts: vec![ ReasoningEffortPreset { effort: ReasoningEffort::Low, - description: "Fastest responses with limited reasoning", + description: "Fastest responses with limited reasoning".to_string(), }, ReasoningEffortPreset { effort: ReasoningEffort::Medium, - description: "Dynamically adjusts reasoning based on the task", + description: "Dynamically adjusts reasoning based on the task".to_string(), }, ReasoningEffortPreset { effort: ReasoningEffort::High, - description: "Maximizes reasoning depth for complex or ambiguous problems", + description: "Maximizes reasoning depth for complex or ambiguous problems".to_string(), }, ], is_default: false, upgrade: Some(ModelUpgrade { - id: "gpt-5.1-codex-max", + id: "gpt-5.1-codex-max".to_string(), reasoning_effort_mapping: None, - migration_config_key: HIDE_GPT_5_1_CODEX_MAX_MIGRATION_PROMPT_CONFIG, + migration_config_key: HIDE_GPT_5_1_CODEX_MAX_MIGRATION_PROMPT_CONFIG.to_string(), }), show_in_picker: false, }, ModelPreset { - id: "gpt-5-codex-mini", - model: "gpt-5-codex-mini", - display_name: "gpt-5-codex-mini", - description: "Optimized for codex. Cheaper, faster, but less capable.", + id: "gpt-5-codex-mini".to_string(), + model: "gpt-5-codex-mini".to_string(), + display_name: "gpt-5-codex-mini".to_string(), + description: "Optimized for codex. Cheaper, faster, but less capable.".to_string(), default_reasoning_effort: ReasoningEffort::Medium, - supported_reasoning_efforts: &[ + supported_reasoning_efforts: vec![ ReasoningEffortPreset { effort: ReasoningEffort::Medium, - description: "Dynamically adjusts reasoning based on the task", + description: "Dynamically adjusts reasoning based on the task".to_string(), }, ReasoningEffortPreset { effort: ReasoningEffort::High, - description: "Maximizes reasoning depth for complex or ambiguous problems", + description: "Maximizes reasoning depth for complex or ambiguous problems".to_string(), }, ], is_default: false, upgrade: Some(ModelUpgrade { - id: "gpt-5.1-codex-mini", + id: "gpt-5.1-codex-mini".to_string(), reasoning_effort_mapping: None, - migration_config_key: HIDE_GPT5_1_MIGRATION_PROMPT_CONFIG, + migration_config_key: HIDE_GPT5_1_MIGRATION_PROMPT_CONFIG.to_string(), }), show_in_picker: false, }, ModelPreset { - id: "gpt-5", - model: "gpt-5", - display_name: "gpt-5", - description: "Broad world knowledge with strong general reasoning.", + id: "gpt-5".to_string(), + model: "gpt-5".to_string(), + display_name: "gpt-5".to_string(), + description: "Broad world knowledge with strong general reasoning.".to_string(), default_reasoning_effort: ReasoningEffort::Medium, - supported_reasoning_efforts: &[ + supported_reasoning_efforts: vec![ ReasoningEffortPreset { effort: ReasoningEffort::Minimal, - description: "Fastest responses with little reasoning", + description: "Fastest responses with little reasoning".to_string(), }, ReasoningEffortPreset { effort: ReasoningEffort::Low, - description: "Balances speed with some reasoning; useful for straightforward queries and short explanations", + description: "Balances speed with some reasoning; useful for straightforward queries and short explanations".to_string(), }, ReasoningEffortPreset { effort: ReasoningEffort::Medium, - description: "Provides a solid balance of reasoning depth and latency for general-purpose tasks", + description: "Provides a solid balance of reasoning depth and latency for general-purpose tasks".to_string(), }, ReasoningEffortPreset { effort: ReasoningEffort::High, - description: "Maximizes reasoning depth for complex or ambiguous problems", + description: "Maximizes reasoning depth for complex or ambiguous problems".to_string(), }, ], is_default: false, upgrade: Some(ModelUpgrade { - id: "gpt-5.1-codex-max", + id: "gpt-5.1-codex-max".to_string(), reasoning_effort_mapping: None, - migration_config_key: HIDE_GPT_5_1_CODEX_MAX_MIGRATION_PROMPT_CONFIG, + migration_config_key: HIDE_GPT_5_1_CODEX_MAX_MIGRATION_PROMPT_CONFIG.to_string(), }), show_in_picker: false, }, diff --git a/codex-rs/core/src/rollout/policy.rs b/codex-rs/core/src/rollout/policy.rs index 58072f9336..4b7d7d3068 100644 --- a/codex-rs/core/src/rollout/policy.rs +++ b/codex-rs/core/src/rollout/policy.rs @@ -51,6 +51,7 @@ pub(crate) fn should_persist_event_msg(ev: &EventMsg) -> bool { | EventMsg::Warning(_) | EventMsg::TaskStarted(_) | EventMsg::TaskComplete(_) + | EventMsg::ListModelsResponse(_) | EventMsg::AgentMessageDelta(_) | EventMsg::AgentReasoningDelta(_) | EventMsg::AgentReasoningRawContentDelta(_) diff --git a/codex-rs/core/src/sandboxing/assessment.rs b/codex-rs/core/src/sandboxing/assessment.rs index 719e3be1f0..225825c93e 100644 --- a/codex-rs/core/src/sandboxing/assessment.rs +++ b/codex-rs/core/src/sandboxing/assessment.rs @@ -14,9 +14,9 @@ use crate::protocol::SandboxPolicy; use askama::Template; use codex_otel::otel_event_manager::OtelEventManager; use codex_protocol::ConversationId; -use codex_protocol::config_types::ReasoningEffort as ReasoningEffortConfig; use codex_protocol::models::ContentItem; use codex_protocol::models::ResponseItem; +use codex_protocol::openai_models::ReasoningEffort as ReasoningEffortConfig; use codex_protocol::protocol::SandboxCommandAssessment; use codex_protocol::protocol::SessionSource; use futures::StreamExt; diff --git a/codex-rs/core/tests/suite/client.rs b/codex-rs/core/tests/suite/client.rs index e074d29755..e0e06757b5 100644 --- a/codex-rs/core/tests/suite/client.rs +++ b/codex-rs/core/tests/suite/client.rs @@ -22,11 +22,11 @@ use codex_core::protocol::Op; use codex_core::protocol::SessionSource; use codex_otel::otel_event_manager::OtelEventManager; use codex_protocol::ConversationId; -use codex_protocol::config_types::ReasoningEffort; use codex_protocol::config_types::Verbosity; use codex_protocol::models::ReasoningItemContent; use codex_protocol::models::ReasoningItemReasoningSummary; use codex_protocol::models::WebSearchAction; +use codex_protocol::openai_models::ReasoningEffort; use codex_protocol::user_input::UserInput; use core_test_support::load_default_config_for_test; use core_test_support::load_sse_fixture_with_id; diff --git a/codex-rs/core/tests/suite/list_models.rs b/codex-rs/core/tests/suite/list_models.rs new file mode 100644 index 0000000000..ecfe9138e4 --- /dev/null +++ b/codex-rs/core/tests/suite/list_models.rs @@ -0,0 +1,187 @@ +use anyhow::Result; +use codex_core::CodexAuth; +use codex_core::protocol::EventMsg; +use codex_core::protocol::Op; +use codex_protocol::openai_models::ModelPreset; +use codex_protocol::openai_models::ReasoningEffort; +use codex_protocol::openai_models::ReasoningEffortPreset; +use core_test_support::responses::start_mock_server; +use core_test_support::test_codex::test_codex; +use core_test_support::wait_for_event_match; +use pretty_assertions::assert_eq; + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn list_models_returns_api_key_models() -> Result<()> { + let server = start_mock_server().await; + let mut builder = test_codex().with_auth(CodexAuth::from_api_key("sk-test")); + let test = builder.build(&server).await?; + + test.codex.submit(Op::ListModels).await?; + + let event = wait_for_event_match(&test.codex, |event| match event { + EventMsg::ListModelsResponse(models) => Some(models.clone()), + _ => None, + }) + .await; + + let expected_models = expected_models_for_api_key(); + assert_eq!(expected_models, event.models); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn list_models_returns_chatgpt_models() -> Result<()> { + let server = start_mock_server().await; + let mut builder = test_codex().with_auth(CodexAuth::create_dummy_chatgpt_auth_for_testing()); + let test = builder.build(&server).await?; + + test.codex.submit(Op::ListModels).await?; + + let event = wait_for_event_match(&test.codex, |event| match event { + EventMsg::ListModelsResponse(models) => Some(models.clone()), + _ => None, + }) + .await; + + let expected_models = expected_models_for_chatgpt(); + assert_eq!(expected_models, event.models); + + Ok(()) +} + +fn expected_models_for_api_key() -> Vec { + vec![gpt_5_1_codex(), gpt_5_1_codex_mini(), gpt_5_1()] +} + +fn expected_models_for_chatgpt() -> Vec { + vec![ + gpt_5_1_codex_max(), + gpt_5_1_codex(), + gpt_5_1_codex_mini(), + gpt_5_1(), + ] +} + +fn gpt_5_1_codex_max() -> ModelPreset { + ModelPreset { + id: "gpt-5.1-codex-max".to_string(), + model: "gpt-5.1-codex-max".to_string(), + display_name: "gpt-5.1-codex-max".to_string(), + description: "Latest Codex-optimized flagship for deep and fast reasoning.".to_string(), + default_reasoning_effort: ReasoningEffort::Medium, + supported_reasoning_efforts: vec![ + effort( + ReasoningEffort::Low, + "Fast responses with lighter reasoning", + ), + effort( + ReasoningEffort::Medium, + "Balances speed and reasoning depth for everyday tasks", + ), + effort( + ReasoningEffort::High, + "Maximizes reasoning depth for complex problems", + ), + effort( + ReasoningEffort::XHigh, + "Extra high reasoning depth for complex problems", + ), + ], + is_default: true, + upgrade: None, + show_in_picker: true, + } +} + +fn gpt_5_1_codex() -> ModelPreset { + ModelPreset { + id: "gpt-5.1-codex".to_string(), + model: "gpt-5.1-codex".to_string(), + display_name: "gpt-5.1-codex".to_string(), + description: "Optimized for codex.".to_string(), + default_reasoning_effort: ReasoningEffort::Medium, + supported_reasoning_efforts: vec![ + effort( + ReasoningEffort::Low, + "Fastest responses with limited reasoning", + ), + effort( + ReasoningEffort::Medium, + "Dynamically adjusts reasoning based on the task", + ), + effort( + ReasoningEffort::High, + "Maximizes reasoning depth for complex or ambiguous problems", + ), + ], + is_default: false, + upgrade: Some(gpt_5_1_codex_max_upgrade()), + show_in_picker: true, + } +} + +fn gpt_5_1_codex_mini() -> ModelPreset { + ModelPreset { + id: "gpt-5.1-codex-mini".to_string(), + model: "gpt-5.1-codex-mini".to_string(), + display_name: "gpt-5.1-codex-mini".to_string(), + description: "Optimized for codex. Cheaper, faster, but less capable.".to_string(), + default_reasoning_effort: ReasoningEffort::Medium, + supported_reasoning_efforts: vec![ + effort( + ReasoningEffort::Medium, + "Dynamically adjusts reasoning based on the task", + ), + effort( + ReasoningEffort::High, + "Maximizes reasoning depth for complex or ambiguous problems", + ), + ], + is_default: false, + upgrade: Some(gpt_5_1_codex_max_upgrade()), + show_in_picker: true, + } +} + +fn gpt_5_1() -> ModelPreset { + ModelPreset { + id: "gpt-5.1".to_string(), + model: "gpt-5.1".to_string(), + display_name: "gpt-5.1".to_string(), + description: "Broad world knowledge with strong general reasoning.".to_string(), + default_reasoning_effort: ReasoningEffort::Medium, + supported_reasoning_efforts: vec![ + effort( + ReasoningEffort::Low, + "Balances speed with some reasoning; useful for straightforward queries and short explanations", + ), + effort( + ReasoningEffort::Medium, + "Provides a solid balance of reasoning depth and latency for general-purpose tasks", + ), + effort( + ReasoningEffort::High, + "Maximizes reasoning depth for complex or ambiguous problems", + ), + ], + is_default: false, + upgrade: Some(gpt_5_1_codex_max_upgrade()), + show_in_picker: true, + } +} + +fn gpt_5_1_codex_max_upgrade() -> codex_protocol::openai_models::ModelUpgrade { + codex_protocol::openai_models::ModelUpgrade { + id: "gpt-5.1-codex-max".to_string(), + reasoning_effort_mapping: None, + migration_config_key: "hide_gpt-5.1-codex-max_migration_prompt".to_string(), + } +} + +fn effort(reasoning_effort: ReasoningEffort, description: &str) -> ReasoningEffortPreset { + ReasoningEffortPreset { + effort: reasoning_effort, + description: description.to_string(), + } +} diff --git a/codex-rs/core/tests/suite/mod.rs b/codex-rs/core/tests/suite/mod.rs index b877663614..35d4eb52a4 100644 --- a/codex-rs/core/tests/suite/mod.rs +++ b/codex-rs/core/tests/suite/mod.rs @@ -34,6 +34,7 @@ mod grep_files; mod items; mod json_result; mod list_dir; +mod list_models; mod live_cli; mod model_overrides; mod model_tools; diff --git a/codex-rs/core/tests/suite/model_overrides.rs b/codex-rs/core/tests/suite/model_overrides.rs index a186c13ef3..f67196312f 100644 --- a/codex-rs/core/tests/suite/model_overrides.rs +++ b/codex-rs/core/tests/suite/model_overrides.rs @@ -2,7 +2,7 @@ use codex_core::CodexAuth; use codex_core::ConversationManager; use codex_core::protocol::EventMsg; use codex_core::protocol::Op; -use codex_core::protocol_config_types::ReasoningEffort; +use codex_protocol::openai_models::ReasoningEffort; use core_test_support::load_default_config_for_test; use core_test_support::wait_for_event; use pretty_assertions::assert_eq; diff --git a/codex-rs/core/tests/suite/prompt_caching.rs b/codex-rs/core/tests/suite/prompt_caching.rs index f4455fd022..0c908e35be 100644 --- a/codex-rs/core/tests/suite/prompt_caching.rs +++ b/codex-rs/core/tests/suite/prompt_caching.rs @@ -7,10 +7,10 @@ use codex_core::protocol::ENVIRONMENT_CONTEXT_OPEN_TAG; use codex_core::protocol::EventMsg; use codex_core::protocol::Op; use codex_core::protocol::SandboxPolicy; -use codex_core::protocol_config_types::ReasoningEffort; use codex_core::protocol_config_types::ReasoningSummary; use codex_core::shell::Shell; use codex_core::shell::default_user_shell; +use codex_protocol::openai_models::ReasoningEffort; use codex_protocol::user_input::UserInput; use core_test_support::load_sse_fixture_with_id; use core_test_support::responses::mount_sse_once; diff --git a/codex-rs/exec/src/event_processor_with_human_output.rs b/codex-rs/exec/src/event_processor_with_human_output.rs index 64a5358f35..17a167585b 100644 --- a/codex-rs/exec/src/event_processor_with_human_output.rs +++ b/codex-rs/exec/src/event_processor_with_human_output.rs @@ -583,6 +583,7 @@ impl EventProcessor for EventProcessorWithHumanOutput { | EventMsg::ReasoningContentDelta(_) | EventMsg::ReasoningRawContentDelta(_) | EventMsg::UndoCompleted(_) + | EventMsg::ListModelsResponse(_) | EventMsg::UndoStarted(_) => {} } CodexStatus::Running diff --git a/codex-rs/mcp-server/src/codex_tool_runner.rs b/codex-rs/mcp-server/src/codex_tool_runner.rs index 55808f17ca..2b3fd07707 100644 --- a/codex-rs/mcp-server/src/codex_tool_runner.rs +++ b/codex-rs/mcp-server/src/codex_tool_runner.rs @@ -307,6 +307,7 @@ async fn run_codex_tool_session_inner( | EventMsg::UndoCompleted(_) | EventMsg::ExitedReviewMode(_) | EventMsg::ContextCompacted(_) + | EventMsg::ListModelsResponse(_) | EventMsg::DeprecationNotice(_) => { // For now, we do not do anything extra for these // events. Note that diff --git a/codex-rs/mcp-server/src/outgoing_message.rs b/codex-rs/mcp-server/src/outgoing_message.rs index 9e9d079306..83ac25fdfd 100644 --- a/codex-rs/mcp-server/src/outgoing_message.rs +++ b/codex-rs/mcp-server/src/outgoing_message.rs @@ -239,7 +239,7 @@ mod tests { use codex_core::protocol::SandboxPolicy; use codex_core::protocol::SessionConfiguredEvent; use codex_protocol::ConversationId; - use codex_protocol::config_types::ReasoningEffort; + use codex_protocol::openai_models::ReasoningEffort; use pretty_assertions::assert_eq; use serde_json::json; use tempfile::NamedTempFile; diff --git a/codex-rs/otel/src/otel_event_manager.rs b/codex-rs/otel/src/otel_event_manager.rs index b6bc07e79f..c300f3fb82 100644 --- a/codex-rs/otel/src/otel_event_manager.rs +++ b/codex-rs/otel/src/otel_event_manager.rs @@ -2,9 +2,9 @@ use chrono::SecondsFormat; use chrono::Utc; use codex_app_server_protocol::AuthMode; use codex_protocol::ConversationId; -use codex_protocol::config_types::ReasoningEffort; use codex_protocol::config_types::ReasoningSummary; use codex_protocol::models::ResponseItem; +use codex_protocol::openai_models::ReasoningEffort; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::ReviewDecision; use codex_protocol::protocol::SandboxPolicy; diff --git a/codex-rs/protocol/src/config_types.rs b/codex-rs/protocol/src/config_types.rs index 2ee6d39746..a98ec4e2b2 100644 --- a/codex-rs/protocol/src/config_types.rs +++ b/codex-rs/protocol/src/config_types.rs @@ -2,37 +2,8 @@ use schemars::JsonSchema; use serde::Deserialize; use serde::Serialize; use strum_macros::Display; -use strum_macros::EnumIter; use ts_rs::TS; -/// See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#get-started-with-reasoning -#[derive( - Debug, - Serialize, - Deserialize, - Default, - Clone, - Copy, - PartialEq, - Eq, - Display, - JsonSchema, - TS, - EnumIter, - Hash, -)] -#[serde(rename_all = "lowercase")] -#[strum(serialize_all = "lowercase")] -pub enum ReasoningEffort { - None, - Minimal, - Low, - #[default] - Medium, - High, - XHigh, -} - /// A summary of the reasoning performed by the model. This can be useful for /// debugging and understanding the model's reasoning process. /// See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#reasoning-summaries diff --git a/codex-rs/protocol/src/lib.rs b/codex-rs/protocol/src/lib.rs index 08ea753347..0d6a0594fc 100644 --- a/codex-rs/protocol/src/lib.rs +++ b/codex-rs/protocol/src/lib.rs @@ -8,6 +8,7 @@ pub mod items; pub mod message_history; pub mod models; pub mod num_format; +pub mod openai_models; pub mod parse_command; pub mod plan_tool; pub mod protocol; diff --git a/codex-rs/protocol/src/openai_models.rs b/codex-rs/protocol/src/openai_models.rs new file mode 100644 index 0000000000..f9a05f9ff6 --- /dev/null +++ b/codex-rs/protocol/src/openai_models.rs @@ -0,0 +1,80 @@ +use std::collections::HashMap; + +use schemars::JsonSchema; +use serde::Deserialize; +use serde::Serialize; +use strum_macros::Display; +use strum_macros::EnumIter; +use ts_rs::TS; + +/// See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#get-started-with-reasoning +#[derive( + Debug, + Serialize, + Deserialize, + Default, + Clone, + Copy, + PartialEq, + Eq, + Display, + JsonSchema, + TS, + EnumIter, + Hash, +)] +#[serde(rename_all = "lowercase")] +#[strum(serialize_all = "lowercase")] +pub enum ReasoningEffort { + None, + Minimal, + Low, + #[default] + Medium, + High, + XHigh, +} + +#[derive(Debug, Clone, Deserialize, Serialize, TS, JsonSchema)] +pub struct AvailableModelsEvent { + pub models: Vec, +} + +/// A reasoning effort option that can be surfaced for a model. +#[derive(Debug, Clone, Deserialize, Serialize, TS, JsonSchema, PartialEq)] +pub struct ReasoningEffortPreset { + /// Effort level that the model supports. + pub effort: ReasoningEffort, + /// Short human description shown next to the effort in UIs. + pub description: String, +} + +#[derive(Debug, Clone, Deserialize, Serialize, TS, JsonSchema, PartialEq)] +pub struct ModelUpgrade { + pub id: String, + pub reasoning_effort_mapping: Option>, + pub migration_config_key: String, +} + +/// Metadata describing a Codex-supported model. +#[derive(Debug, Clone, Deserialize, Serialize, TS, JsonSchema, PartialEq)] +pub struct ModelPreset { + /// Stable identifier for the preset. + pub id: String, + /// Model slug (e.g., "gpt-5"). + pub model: String, + /// Display name shown in UIs. + pub display_name: String, + /// Short human description shown in UIs. + pub description: String, + /// Reasoning effort applied when none is explicitly chosen. + pub default_reasoning_effort: ReasoningEffort, + /// Supported reasoning effort options. + pub supported_reasoning_efforts: Vec, + /// Whether this is the default model for new users. + pub is_default: bool, + /// recommended upgrade model + pub upgrade: Option, + /// Whether this preset should appear in the picker UI. + pub show_in_picker: bool, +} diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index 347cc119ff..6b787afe17 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -12,7 +12,6 @@ use std::time::Duration; use crate::ConversationId; use crate::approvals::ElicitationRequestEvent; -use crate::config_types::ReasoningEffort as ReasoningEffortConfig; use crate::config_types::ReasoningSummary as ReasoningSummaryConfig; use crate::custom_prompts::CustomPrompt; use crate::items::TurnItem; @@ -20,6 +19,8 @@ use crate::message_history::HistoryEntry; use crate::models::ContentItem; use crate::models::ResponseItem; use crate::num_format::format_with_separators; +use crate::openai_models::AvailableModelsEvent; +use crate::openai_models::ReasoningEffort as ReasoningEffortConfig; use crate::parse_command::ParsedCommand; use crate::plan_tool::UpdatePlanArgs; use crate::user_input::UserInput; @@ -208,6 +209,9 @@ pub enum Op { /// The raw command string after '!' command: String, }, + + /// Request the list of available models. + ListModels, } /// Determines the conditions under which the user is consulted to approve @@ -578,6 +582,7 @@ pub enum EventMsg { AgentMessageContentDelta(AgentMessageContentDeltaEvent), ReasoningContentDelta(ReasoningContentDeltaEvent), ReasoningRawContentDelta(ReasoningRawContentDeltaEvent), + ListModelsResponse(AvailableModelsEvent), } /// Codex errors that we expose to clients. diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 32fc095662..409f047217 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -21,10 +21,6 @@ use crate::tui::TuiEvent; use crate::update_action::UpdateAction; use codex_ansi_escape::ansi_escape_line; use codex_app_server_protocol::AuthMode; -use codex_common::model_presets::HIDE_GPT_5_1_CODEX_MAX_MIGRATION_PROMPT_CONFIG; -use codex_common::model_presets::HIDE_GPT5_1_MIGRATION_PROMPT_CONFIG; -use codex_common::model_presets::ModelUpgrade; -use codex_common::model_presets::all_model_presets; use codex_core::AuthManager; use codex_core::ConversationManager; use codex_core::config::Config; @@ -32,14 +28,18 @@ use codex_core::config::edit::ConfigEditsBuilder; #[cfg(target_os = "windows")] use codex_core::features::Feature; use codex_core::model_family::find_family_for_model; +use codex_core::openai_models::model_presets::HIDE_GPT_5_1_CODEX_MAX_MIGRATION_PROMPT_CONFIG; +use codex_core::openai_models::model_presets::HIDE_GPT5_1_MIGRATION_PROMPT_CONFIG; +use codex_core::openai_models::model_presets::all_model_presets; use codex_core::protocol::EventMsg; use codex_core::protocol::FinalOutput; use codex_core::protocol::Op; use codex_core::protocol::SessionSource; use codex_core::protocol::TokenUsage; -use codex_core::protocol_config_types::ReasoningEffort as ReasoningEffortConfig; use codex_core::skills::load_skills; use codex_protocol::ConversationId; +use codex_protocol::openai_models::ModelUpgrade; +use codex_protocol::openai_models::ReasoningEffort as ReasoningEffortConfig; use color_eyre::eyre::Result; use color_eyre::eyre::WrapErr; use crossterm::event::KeyCode; diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs index 944eeda810..3a199593bb 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -1,18 +1,18 @@ use std::path::PathBuf; use codex_common::approval_presets::ApprovalPreset; -use codex_common::model_presets::ModelPreset; use codex_core::protocol::ConversationPathResponseEvent; use codex_core::protocol::Event; use codex_core::protocol::RateLimitSnapshot; use codex_file_search::FileMatch; +use codex_protocol::openai_models::ModelPreset; use crate::bottom_pane::ApprovalRequest; use crate::history_cell::HistoryCell; use codex_core::protocol::AskForApproval; use codex_core::protocol::SandboxPolicy; -use codex_core::protocol_config_types::ReasoningEffort; +use codex_protocol::openai_models::ReasoningEffort; #[allow(clippy::large_enum_variant)] #[derive(Debug)] diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 0f1d6918d0..f18725d8bd 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -122,15 +122,15 @@ use std::path::Path; use chrono::Local; use codex_common::approval_presets::ApprovalPreset; use codex_common::approval_presets::builtin_approval_presets; -use codex_common::model_presets::ModelPreset; -use codex_common::model_presets::builtin_model_presets; use codex_core::AuthManager; use codex_core::CodexAuth; use codex_core::ConversationManager; +use codex_core::openai_models::model_presets::builtin_model_presets; use codex_core::protocol::AskForApproval; use codex_core::protocol::SandboxPolicy; -use codex_core::protocol_config_types::ReasoningEffort as ReasoningEffortConfig; use codex_file_search::FileMatch; +use codex_protocol::openai_models::ModelPreset; +use codex_protocol::openai_models::ReasoningEffort as ReasoningEffortConfig; use codex_protocol::plan_tool::UpdatePlanArgs; use strum::IntoEnumIterator; @@ -1833,6 +1833,7 @@ impl ChatWidget { | EventMsg::ItemCompleted(_) | EventMsg::AgentMessageContentDelta(_) | EventMsg::ReasoningContentDelta(_) + | EventMsg::ListModelsResponse(_) | EventMsg::ReasoningRawContentDelta(_) => {} } } @@ -2074,7 +2075,7 @@ impl ChatWidget { let description = if preset.description.is_empty() { Some("Uses fewer credits for upcoming turns.".to_string()) } else { - Some(preset.description.to_string()) + Some(preset.description) }; let items = vec![ @@ -2210,9 +2211,9 @@ impl ChatWidget { if choices.len() == 1 { if let Some(effort) = choices.first().and_then(|c| c.stored) { - self.apply_model_and_effort(preset.model.to_string(), Some(effort)); + self.apply_model_and_effort(preset.model, Some(effort)); } else { - self.apply_model_and_effort(preset.model.to_string(), None); + self.apply_model_and_effort(preset.model, None); } return; } diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index 43056464de..a4d21608c8 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -5,8 +5,6 @@ use crate::test_backend::VT100Backend; use crate::tui::FrameRequester; use assert_matches::assert_matches; use codex_common::approval_presets::builtin_approval_presets; -use codex_common::model_presets::ModelPreset; -use codex_common::model_presets::ReasoningEffortPreset; use codex_core::AuthManager; use codex_core::CodexAuth; use codex_core::config::Config; @@ -48,6 +46,8 @@ use codex_core::protocol::UndoStartedEvent; use codex_core::protocol::ViewImageToolCallEvent; use codex_core::protocol::WarningEvent; use codex_protocol::ConversationId; +use codex_protocol::openai_models::ModelPreset; +use codex_protocol::openai_models::ReasoningEffortPreset; use codex_protocol::parse_command::ParsedCommand; use codex_protocol::plan_tool::PlanItemArg; use codex_protocol::plan_tool::StepStatus; @@ -1805,17 +1805,17 @@ fn reasoning_popup_shows_extra_high_with_space() { fn single_reasoning_option_skips_selection() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(); - static SINGLE_EFFORT: [ReasoningEffortPreset; 1] = [ReasoningEffortPreset { + let single_effort = vec![ReasoningEffortPreset { effort: ReasoningEffortConfig::High, - description: "Maximizes reasoning depth for complex or ambiguous problems", + description: "Maximizes reasoning depth for complex or ambiguous problems".to_string(), }]; let preset = ModelPreset { - id: "model-with-single-reasoning", - model: "model-with-single-reasoning", - display_name: "model-with-single-reasoning", - description: "", + id: "model-with-single-reasoning".to_string(), + model: "model-with-single-reasoning".to_string(), + display_name: "model-with-single-reasoning".to_string(), + description: "".to_string(), default_reasoning_effort: ReasoningEffortConfig::High, - supported_reasoning_efforts: &SINGLE_EFFORT, + supported_reasoning_efforts: single_effort, is_default: false, upgrade: None, show_in_picker: true, diff --git a/codex-rs/tui/src/history_cell.rs b/codex-rs/tui/src/history_cell.rs index 475eb1db17..c4fd31f548 100644 --- a/codex-rs/tui/src/history_cell.rs +++ b/codex-rs/tui/src/history_cell.rs @@ -31,7 +31,7 @@ use codex_core::protocol::FileChange; use codex_core::protocol::McpAuthStatus; use codex_core::protocol::McpInvocation; use codex_core::protocol::SessionConfiguredEvent; -use codex_core::protocol_config_types::ReasoningEffort as ReasoningEffortConfig; +use codex_protocol::openai_models::ReasoningEffort as ReasoningEffortConfig; use codex_protocol::plan_tool::PlanItemArg; use codex_protocol::plan_tool::StepStatus; use codex_protocol::plan_tool::UpdatePlanArgs; diff --git a/codex-rs/tui/src/model_migration.rs b/codex-rs/tui/src/model_migration.rs index 283007e028..1f93fd9a4f 100644 --- a/codex-rs/tui/src/model_migration.rs +++ b/codex-rs/tui/src/model_migration.rs @@ -7,8 +7,8 @@ use crate::selection_list::selection_option_row; use crate::tui::FrameRequester; use crate::tui::Tui; use crate::tui::TuiEvent; -use codex_common::model_presets::HIDE_GPT_5_1_CODEX_MAX_MIGRATION_PROMPT_CONFIG; -use codex_common::model_presets::HIDE_GPT5_1_MIGRATION_PROMPT_CONFIG; +use codex_core::openai_models::model_presets::HIDE_GPT_5_1_CODEX_MAX_MIGRATION_PROMPT_CONFIG; +use codex_core::openai_models::model_presets::HIDE_GPT5_1_MIGRATION_PROMPT_CONFIG; use crossterm::event::KeyCode; use crossterm::event::KeyEvent; use crossterm::event::KeyEventKind; @@ -329,7 +329,7 @@ mod tests { use crate::custom_terminal::Terminal; use crate::test_backend::VT100Backend; use crate::tui::FrameRequester; - use codex_common::model_presets::HIDE_GPT5_1_MIGRATION_PROMPT_CONFIG; + use codex_core::openai_models::model_presets::HIDE_GPT5_1_MIGRATION_PROMPT_CONFIG; use crossterm::event::KeyCode; use crossterm::event::KeyEvent; use insta::assert_snapshot; diff --git a/codex-rs/tui/src/status/tests.rs b/codex-rs/tui/src/status/tests.rs index ae379aae67..0709e366d0 100644 --- a/codex-rs/tui/src/status/tests.rs +++ b/codex-rs/tui/src/status/tests.rs @@ -13,8 +13,8 @@ use codex_core::protocol::RateLimitSnapshot; use codex_core::protocol::RateLimitWindow; use codex_core::protocol::SandboxPolicy; use codex_core::protocol::TokenUsage; -use codex_protocol::config_types::ReasoningEffort; use codex_protocol::config_types::ReasoningSummary; +use codex_protocol::openai_models::ReasoningEffort; use insta::assert_snapshot; use ratatui::prelude::*; use std::path::PathBuf; From 3395ebd96ef50d459287e0d2303e89360b18b57a Mon Sep 17 00:00:00 2001 From: muyuanjin Date: Thu, 4 Dec 2025 05:43:17 +0800 Subject: [PATCH 14/30] fix(tui): limit user shell output by screen lines (#7448) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit What - Limit the TUI "user shell" output panel by the number of visible screen lines rather than by the number of logical lines. - Apply middle truncation after wrapping, so a few extremely long lines cannot expand into hundreds of visible lines. - Add a regression test to guard this behavior. Why When the `ExecCommandSource::UserShell` tool returns a small number of very long logical lines, the TUI wraps those lines into many visual lines. The existing truncation logic applied `USER_SHELL_TOOL_CALL_MAX_LINES` to the number of logical lines *before* wrapping. As a result, a command like: - `Ran bash -lc "grep -R --line-number 'maskAssetId' ."` or a synthetic command that prints a single ~50,000‑character line, can produce hundreds of screen lines and effectively flood the viewport. The intended middle truncation for user shell output does not take effect in this scenario. How - In `codex-rs/tui/src/exec_cell/render.rs`, change the `ExecCell` rendering path for `ExecCommandSource::UserShell` so that: - Each logical line from `CommandOutput::aggregated_output` is first wrapped via `word_wrap_line` into multiple screen lines using the appropriate `RtOptions` and width from the `EXEC_DISPLAY_LAYOUT` configuration. - `truncate_lines_middle` is then applied to the wrapped screen lines, with `USER_SHELL_TOOL_CALL_MAX_LINES` as the limit. This means the limit is enforced on visible screen lines, not logical lines. - The existing layout struct (`ExecDisplayLayout`) continues to provide `output_max_lines`, so user shell output is subject to both `USER_SHELL_TOOL_CALL_MAX_LINES` and the layout-specific `output_max_lines` constraint. - Keep using `USER_SHELL_TOOL_CALL_MAX_LINES` as the cap, but interpret it as a per‑tool‑call limit on screen lines. - Add a regression test `user_shell_output_is_limited_by_screen_lines` in `codex-rs/tui/src/exec_cell/render.rs` that: - Constructs two extremely long logical lines containing a short marker (`"Z"`), so each wrapped screen line still contains the marker. - Wraps them at a narrow width to generate many screen lines. - Asserts that the unbounded wrapped output would exceed `USER_SHELL_TOOL_CALL_MAX_LINES` screen lines. - Renders an `ExecCell` for `ExecCommandSource::UserShell` at the same width and counts rendered lines containing the marker. - Asserts `output_screen_lines <= USER_SHELL_TOOL_CALL_MAX_LINES`, guarding against regressions where truncation happens before wrapping. This change keeps user shell output readable while ensuring it cannot flood the TUI, even when the tool emits a few extremely long lines. Tests - `cargo test -p codex-tui` Issue - Fixes #7447 --- codex-rs/tui/src/exec_cell/render.rs | 116 ++++++++++++++++++++++++--- 1 file changed, 106 insertions(+), 10 deletions(-) diff --git a/codex-rs/tui/src/exec_cell/render.rs b/codex-rs/tui/src/exec_cell/render.rs index 3e434138d4..3375f1dca8 100644 --- a/codex-rs/tui/src/exec_cell/render.rs +++ b/codex-rs/tui/src/exec_cell/render.rs @@ -451,26 +451,26 @@ impl ExecCell { )); } } else { - let trimmed_output = Self::truncate_lines_middle( - &raw_output.lines, - display_limit, - raw_output.omitted, - ); - + // Wrap first so that truncation is applied to on-screen lines + // rather than logical lines. This ensures that a small number + // of very long lines cannot flood the viewport. let mut wrapped_output: Vec> = Vec::new(); let output_wrap_width = layout.output_block.wrap_width(width); let output_opts = RtOptions::new(output_wrap_width).word_splitter(WordSplitter::NoHyphenation); - for line in trimmed_output { + for line in &raw_output.lines { push_owned_lines( - &word_wrap_line(&line, output_opts.clone()), + &word_wrap_line(line, output_opts.clone()), &mut wrapped_output, ); } - if !wrapped_output.is_empty() { + let trimmed_output = + Self::truncate_lines_middle(&wrapped_output, display_limit, raw_output.omitted); + + if !trimmed_output.is_empty() { lines.extend(prefix_lines( - wrapped_output, + trimmed_output, Span::from(layout.output_block.initial_prefix).dim(), Span::from(layout.output_block.subsequent_prefix), )); @@ -597,3 +597,99 @@ const EXEC_DISPLAY_LAYOUT: ExecDisplayLayout = ExecDisplayLayout::new( PrefixedBlock::new(" └ ", " "), 5, ); + +#[cfg(test)] +mod tests { + use super::*; + use codex_core::protocol::ExecCommandSource; + + #[test] + fn user_shell_output_is_limited_by_screen_lines() { + // Construct a user shell exec cell whose aggregated output consists of a + // small number of very long logical lines. These will wrap into many + // on-screen lines at narrow widths. + // + // Use a short marker so it survives wrapping intact inside each + // rendered screen line; the previous test used a marker longer than + // the wrap width, so it was split across lines and the assertion + // never actually saw it. + let marker = "Z"; + let long_chunk = marker.repeat(800); + let aggregated_output = format!("{long_chunk}\n{long_chunk}\n"); + + // Baseline: how many screen lines would we get if we simply wrapped + // all logical lines without any truncation? + let output = CommandOutput { + exit_code: 0, + aggregated_output, + formatted_output: String::new(), + }; + let width = 20; + let layout = EXEC_DISPLAY_LAYOUT; + let raw_output = output_lines( + Some(&output), + OutputLinesParams { + // Large enough to include all logical lines without + // triggering the ellipsis in `output_lines`. + line_limit: 100, + only_err: false, + include_angle_pipe: false, + include_prefix: false, + }, + ); + let output_wrap_width = layout.output_block.wrap_width(width); + let output_opts = + RtOptions::new(output_wrap_width).word_splitter(WordSplitter::NoHyphenation); + let mut full_wrapped_output: Vec> = Vec::new(); + for line in &raw_output.lines { + push_owned_lines( + &word_wrap_line(line, output_opts.clone()), + &mut full_wrapped_output, + ); + } + let full_screen_lines = full_wrapped_output + .iter() + .filter(|line| line.spans.iter().any(|span| span.content.contains(marker))) + .count(); + + // Sanity check: this scenario should produce more screen lines than + // the user shell per-call limit when no truncation is applied. If + // this ever fails, the test no longer exercises the regression. + assert!( + full_screen_lines > USER_SHELL_TOOL_CALL_MAX_LINES, + "expected unbounded wrapping to produce more than {USER_SHELL_TOOL_CALL_MAX_LINES} screen lines, got {full_screen_lines}", + ); + + let call = ExecCall { + call_id: "call-id".to_string(), + command: vec!["bash".into(), "-lc".into(), "echo long".into()], + parsed: Vec::new(), + output: Some(output), + source: ExecCommandSource::UserShell, + start_time: None, + duration: None, + interaction_input: None, + }; + + let cell = ExecCell::new(call, false); + + // Use a narrow width so each logical line wraps into many on-screen lines. + let lines = cell.command_display_lines(width); + + // Count how many rendered lines contain our marker text. This approximates + // the number of visible output "screen lines" for this command. + let output_screen_lines = lines + .iter() + .filter(|line| line.spans.iter().any(|span| span.content.contains(marker))) + .count(); + + // Regression guard: previously this scenario could render hundreds of + // wrapped lines because truncation happened before wrapping. Now the + // truncation is applied after wrapping, so the number of visible + // screen lines is bounded by USER_SHELL_TOOL_CALL_MAX_LINES. + assert!( + output_screen_lines <= USER_SHELL_TOOL_CALL_MAX_LINES, + "expected at most {USER_SHELL_TOOL_CALL_MAX_LINES} screen lines of user shell output, got {output_screen_lines}", + ); + } +} From de08c735a6b7d54ac9efb742e1d50b68b852f0d0 Mon Sep 17 00:00:00 2001 From: Aofei Sheng Date: Thu, 4 Dec 2025 06:43:31 +0800 Subject: [PATCH 15/30] feat(tui): map Ctrl-P/N to arrow navigation in textarea (#7530) - Treat Ctrl-P/N (and their C0 fallbacks) the same as Up/Down so cursor movement matches popup/history behavior and control bytes never land in the buffer Fixes #7529 Signed-off-by: Aofei Sheng --- codex-rs/tui/src/bottom_pane/textarea.rs | 22 +++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/codex-rs/tui/src/bottom_pane/textarea.rs b/codex-rs/tui/src/bottom_pane/textarea.rs index 03de336811..2fd415c7f6 100644 --- a/codex-rs/tui/src/bottom_pane/textarea.rs +++ b/codex-rs/tui/src/bottom_pane/textarea.rs @@ -216,7 +216,7 @@ impl TextArea { match event { // Some terminals (or configurations) send Control key chords as // C0 control characters without reporting the CONTROL modifier. - // Handle common fallbacks for Ctrl-B/Ctrl-F here so they don't get + // Handle common fallbacks for Ctrl-B/F/P/N here so they don't get // inserted as literal control bytes. KeyEvent { code: KeyCode::Char('\u{0002}'), modifiers: KeyModifiers::NONE, .. } /* ^B */ => { self.move_cursor_left(); @@ -224,6 +224,12 @@ impl TextArea { KeyEvent { code: KeyCode::Char('\u{0006}'), modifiers: KeyModifiers::NONE, .. } /* ^F */ => { self.move_cursor_right(); } + KeyEvent { code: KeyCode::Char('\u{0010}'), modifiers: KeyModifiers::NONE, .. } /* ^P */ => { + self.move_cursor_up(); + } + KeyEvent { code: KeyCode::Char('\u{000e}'), modifiers: KeyModifiers::NONE, .. } /* ^N */ => { + self.move_cursor_down(); + } KeyEvent { code: KeyCode::Char(c), // Insert plain characters (and Shift-modified). Do NOT insert when ALT is held, @@ -359,6 +365,20 @@ impl TextArea { } => { self.move_cursor_right(); } + KeyEvent { + code: KeyCode::Char('p'), + modifiers: KeyModifiers::CONTROL, + .. + } => { + self.move_cursor_up(); + } + KeyEvent { + code: KeyCode::Char('n'), + modifiers: KeyModifiers::CONTROL, + .. + } => { + self.move_cursor_down(); + } // Some terminals send Alt+Arrow for word-wise movement: // Option/Left -> Alt+Left (previous word start) // Option/Right -> Alt+Right (next word end) From 231ff19ca227e3f25e75742e938da0531093c30a Mon Sep 17 00:00:00 2001 From: Owen Lin Date: Wed, 3 Dec 2025 15:00:07 -0800 Subject: [PATCH 16/30] [app-server] fix: add thread_id to turn/plan/updated (#7553) Realized we're missing this while migrating VSCE. --- codex-rs/app-server-protocol/src/protocol/v2.rs | 1 + codex-rs/app-server/src/bespoke_event_handling.rs | 15 ++++++++++++++- 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index e3c9fc0558..f1d8392135 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -1294,6 +1294,7 @@ pub struct TurnDiffUpdatedNotification { #[serde(rename_all = "camelCase")] #[ts(export_to = "v2/")] pub struct TurnPlanUpdatedNotification { + pub thread_id: String, pub turn_id: String, pub explanation: Option, pub plan: Vec, diff --git a/codex-rs/app-server/src/bespoke_event_handling.rs b/codex-rs/app-server/src/bespoke_event_handling.rs index b4dd16b9a6..df4cdb8980 100644 --- a/codex-rs/app-server/src/bespoke_event_handling.rs +++ b/codex-rs/app-server/src/bespoke_event_handling.rs @@ -661,6 +661,7 @@ pub(crate) async fn apply_bespoke_event_handling( } EventMsg::PlanUpdate(plan_update_event) => { handle_turn_plan_update( + conversation_id, &event_turn_id, plan_update_event, api_version, @@ -693,6 +694,7 @@ async fn handle_turn_diff( } async fn handle_turn_plan_update( + conversation_id: ConversationId, event_turn_id: &str, plan_update_event: UpdatePlanArgs, api_version: ApiVersion, @@ -700,6 +702,7 @@ async fn handle_turn_plan_update( ) { if let ApiVersion::V2 = api_version { let notification = TurnPlanUpdatedNotification { + thread_id: conversation_id.to_string(), turn_id: event_turn_id.to_string(), explanation: plan_update_event.explanation, plan: plan_update_event @@ -1422,7 +1425,16 @@ mod tests { ], }; - handle_turn_plan_update("turn-123", update, ApiVersion::V2, &outgoing).await; + let conversation_id = ConversationId::new(); + + handle_turn_plan_update( + conversation_id, + "turn-123", + update, + ApiVersion::V2, + &outgoing, + ) + .await; let msg = rx .recv() @@ -1430,6 +1442,7 @@ mod tests { .ok_or_else(|| anyhow!("should send one notification"))?; match msg { OutgoingMessage::AppServerNotification(ServerNotification::TurnPlanUpdated(n)) => { + assert_eq!(n.thread_id, conversation_id.to_string()); assert_eq!(n.turn_id, "turn-123"); assert_eq!(n.explanation.as_deref(), Some("need plan")); assert_eq!(n.plan.len(), 2); From 9a50a0440090a3c0dfefbd215a00209a31bee52e Mon Sep 17 00:00:00 2001 From: xl-openai Date: Wed, 3 Dec 2025 15:12:46 -0800 Subject: [PATCH 17/30] feat: Support listing and selecting skills via $ or /skills (#7506) List/Select skills with $-mention or /skills --- codex-rs/core/src/project_doc.rs | 6 +- codex-rs/core/src/skills/render.rs | 21 ++ codex-rs/tui/src/app.rs | 17 +- codex-rs/tui/src/app_backtrack.rs | 1 + codex-rs/tui/src/bottom_pane/chat_composer.rs | 307 ++++++++++++++---- codex-rs/tui/src/bottom_pane/command_popup.rs | 66 ++-- codex-rs/tui/src/bottom_pane/mod.rs | 28 +- codex-rs/tui/src/bottom_pane/skill_popup.rs | 142 ++++++++ codex-rs/tui/src/chatwidget.rs | 9 + codex-rs/tui/src/chatwidget/tests.rs | 2 + codex-rs/tui/src/slash_command.rs | 3 + 11 files changed, 505 insertions(+), 97 deletions(-) create mode 100644 codex-rs/tui/src/bottom_pane/skill_popup.rs diff --git a/codex-rs/core/src/project_doc.rs b/codex-rs/core/src/project_doc.rs index ee3148e7e7..43a0034801 100644 --- a/codex-rs/core/src/project_doc.rs +++ b/codex-rs/core/src/project_doc.rs @@ -516,8 +516,9 @@ mod tests { ) .unwrap_or_else(|_| cfg.codex_home.join("skills/pdf-processing/SKILL.md")); let expected_path_str = expected_path.to_string_lossy().replace('\\', "/"); + let usage_rules = "- Discovery: Available skills are listed in project docs and may also appear in a runtime \"## Skills\" section (name + description + file path). These are the sources of truth; skill bodies live on disk at the listed paths.\n- Trigger rules: If the user names a skill (with `$SkillName` or plain text) OR the task clearly matches a skill's description, you must use that skill for that turn. Multiple mentions mean use them all. Do not carry skills across turns unless re-mentioned.\n- Missing/blocked: If a named skill isn't in the list or the path can't be read, say so briefly and continue with the best fallback.\n- How to use a skill (progressive disclosure):\n 1) After deciding to use a skill, open its `SKILL.md`. Read only enough to follow the workflow.\n 2) If `SKILL.md` points to extra folders such as `references/`, load only the specific files needed for the request; don't bulk-load everything.\n 3) If `scripts/` exist, prefer running or patching them instead of retyping large code blocks.\n 4) If `assets/` or templates exist, reuse them instead of recreating from scratch.\n- Description as trigger: The YAML `description` in `SKILL.md` is the primary trigger signal; rely on it to decide applicability. If unsure, ask a brief clarification before proceeding.\n- Coordination and sequencing:\n - If multiple skills apply, choose the minimal set that covers the request and state the order you'll use them.\n - Announce which skill(s) you're using and why (one short line). If you skip an obvious skill, say why.\n- Context hygiene:\n - Keep context small: summarize long sections instead of pasting them; only load extra files when needed.\n - Avoid deeply nested references; prefer one-hop files explicitly linked from `SKILL.md`.\n - When variants exist (frameworks, providers, domains), pick only the relevant reference file(s) and note that choice.\n- Safety and fallback: If a skill can't be applied cleanly (missing files, unclear instructions), state the issue, pick the next-best approach, and continue."; let expected = format!( - "base doc\n\n## Skills\nThese skills are discovered at startup from ~/.codex/skills; each entry shows name, description, and file path so you can open the source for full instructions. Content is not inlined to keep context lean.\n- pdf-processing: extract from pdfs (file: {expected_path_str})" + "base doc\n\n## Skills\nThese skills are discovered at startup from ~/.codex/skills; each entry shows name, description, and file path so you can open the source for full instructions. Content is not inlined to keep context lean.\n- pdf-processing: extract from pdfs (file: {expected_path_str})\n{usage_rules}" ); assert_eq!(res, expected); } @@ -535,8 +536,9 @@ mod tests { dunce::canonicalize(cfg.codex_home.join("skills/linting/SKILL.md").as_path()) .unwrap_or_else(|_| cfg.codex_home.join("skills/linting/SKILL.md")); let expected_path_str = expected_path.to_string_lossy().replace('\\', "/"); + let usage_rules = "- Discovery: Available skills are listed in project docs and may also appear in a runtime \"## Skills\" section (name + description + file path). These are the sources of truth; skill bodies live on disk at the listed paths.\n- Trigger rules: If the user names a skill (with `$SkillName` or plain text) OR the task clearly matches a skill's description, you must use that skill for that turn. Multiple mentions mean use them all. Do not carry skills across turns unless re-mentioned.\n- Missing/blocked: If a named skill isn't in the list or the path can't be read, say so briefly and continue with the best fallback.\n- How to use a skill (progressive disclosure):\n 1) After deciding to use a skill, open its `SKILL.md`. Read only enough to follow the workflow.\n 2) If `SKILL.md` points to extra folders such as `references/`, load only the specific files needed for the request; don't bulk-load everything.\n 3) If `scripts/` exist, prefer running or patching them instead of retyping large code blocks.\n 4) If `assets/` or templates exist, reuse them instead of recreating from scratch.\n- Description as trigger: The YAML `description` in `SKILL.md` is the primary trigger signal; rely on it to decide applicability. If unsure, ask a brief clarification before proceeding.\n- Coordination and sequencing:\n - If multiple skills apply, choose the minimal set that covers the request and state the order you'll use them.\n - Announce which skill(s) you're using and why (one short line). If you skip an obvious skill, say why.\n- Context hygiene:\n - Keep context small: summarize long sections instead of pasting them; only load extra files when needed.\n - Avoid deeply nested references; prefer one-hop files explicitly linked from `SKILL.md`.\n - When variants exist (frameworks, providers, domains), pick only the relevant reference file(s) and note that choice.\n- Safety and fallback: If a skill can't be applied cleanly (missing files, unclear instructions), state the issue, pick the next-best approach, and continue."; let expected = format!( - "## Skills\nThese skills are discovered at startup from ~/.codex/skills; each entry shows name, description, and file path so you can open the source for full instructions. Content is not inlined to keep context lean.\n- linting: run clippy (file: {expected_path_str})" + "## Skills\nThese skills are discovered at startup from ~/.codex/skills; each entry shows name, description, and file path so you can open the source for full instructions. Content is not inlined to keep context lean.\n- linting: run clippy (file: {expected_path_str})\n{usage_rules}" ); assert_eq!(res, expected); } diff --git a/codex-rs/core/src/skills/render.rs b/codex-rs/core/src/skills/render.rs index d547e21c28..b664565459 100644 --- a/codex-rs/core/src/skills/render.rs +++ b/codex-rs/core/src/skills/render.rs @@ -17,5 +17,26 @@ pub fn render_skills_section(skills: &[SkillMetadata]) -> Option { )); } + lines.push( + r###"- Discovery: Available skills are listed in project docs and may also appear in a runtime "## Skills" section (name + description + file path). These are the sources of truth; skill bodies live on disk at the listed paths. +- Trigger rules: If the user names a skill (with `$SkillName` or plain text) OR the task clearly matches a skill's description, you must use that skill for that turn. Multiple mentions mean use them all. Do not carry skills across turns unless re-mentioned. +- Missing/blocked: If a named skill isn't in the list or the path can't be read, say so briefly and continue with the best fallback. +- How to use a skill (progressive disclosure): + 1) After deciding to use a skill, open its `SKILL.md`. Read only enough to follow the workflow. + 2) If `SKILL.md` points to extra folders such as `references/`, load only the specific files needed for the request; don't bulk-load everything. + 3) If `scripts/` exist, prefer running or patching them instead of retyping large code blocks. + 4) If `assets/` or templates exist, reuse them instead of recreating from scratch. +- Description as trigger: The YAML `description` in `SKILL.md` is the primary trigger signal; rely on it to decide applicability. If unsure, ask a brief clarification before proceeding. +- Coordination and sequencing: + - If multiple skills apply, choose the minimal set that covers the request and state the order you'll use them. + - Announce which skill(s) you're using and why (one short line). If you skip an obvious skill, say why. +- Context hygiene: + - Keep context small: summarize long sections instead of pasting them; only load extra files when needed. + - Avoid deeply nested references; prefer one-hop files explicitly linked from `SKILL.md`. + - When variants exist (frameworks, providers, domains), pick only the relevant reference file(s) and note that choice. +- Safety and fallback: If a skill can't be applied cleanly (missing files, unclear instructions), state the issue, pick the next-best approach, and continue."### + .to_string(), + ); + Some(lines.join("\n")) } diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 409f047217..df9e4b5d4b 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -25,7 +25,6 @@ use codex_core::AuthManager; use codex_core::ConversationManager; use codex_core::config::Config; use codex_core::config::edit::ConfigEditsBuilder; -#[cfg(target_os = "windows")] use codex_core::features::Feature; use codex_core::model_family::find_family_for_model; use codex_core::openai_models::model_presets::HIDE_GPT_5_1_CODEX_MAX_MIGRATION_PROMPT_CONFIG; @@ -37,6 +36,7 @@ use codex_core::protocol::Op; use codex_core::protocol::SessionSource; use codex_core::protocol::TokenUsage; use codex_core::skills::load_skills; +use codex_core::skills::model::SkillMetadata; use codex_protocol::ConversationId; use codex_protocol::openai_models::ModelUpgrade; use codex_protocol::openai_models::ReasoningEffort as ReasoningEffortConfig; @@ -231,6 +231,8 @@ pub(crate) struct App { // One-shot suppression of the next world-writable scan after user confirmation. skip_world_writable_scan_once: bool, + + pub(crate) skills: Option>, } impl App { @@ -285,6 +287,12 @@ impl App { } } + let skills = if config.features.enabled(Feature::Skills) { + Some(skills_outcome.skills.clone()) + } else { + None + }; + let enhanced_keys_supported = tui.enhanced_keys_supported(); let mut chat_widget = match resume_selection { @@ -298,6 +306,7 @@ impl App { enhanced_keys_supported, auth_manager: auth_manager.clone(), feedback: feedback.clone(), + skills: skills.clone(), is_first_run, }; ChatWidget::new(init, conversation_manager.clone()) @@ -322,6 +331,7 @@ impl App { enhanced_keys_supported, auth_manager: auth_manager.clone(), feedback: feedback.clone(), + skills: skills.clone(), is_first_run, }; ChatWidget::new_from_existing( @@ -357,6 +367,7 @@ impl App { pending_update_action: None, suppress_shutdown_complete: false, skip_world_writable_scan_once: false, + skills, }; // On startup, if Agent mode (workspace-write) or ReadOnly is active, warn about world-writable dirs on Windows. @@ -476,6 +487,7 @@ impl App { enhanced_keys_supported: self.enhanced_keys_supported, auth_manager: self.auth_manager.clone(), feedback: self.feedback.clone(), + skills: self.skills.clone(), is_first_run: false, }; self.chat_widget = ChatWidget::new(init, self.server.clone()); @@ -523,6 +535,7 @@ impl App { enhanced_keys_supported: self.enhanced_keys_supported, auth_manager: self.auth_manager.clone(), feedback: self.feedback.clone(), + skills: self.skills.clone(), is_first_run: false, }; self.chat_widget = ChatWidget::new_from_existing( @@ -1147,6 +1160,7 @@ mod tests { pending_update_action: None, suppress_shutdown_complete: false, skip_world_writable_scan_once: false, + skills: None, } } @@ -1184,6 +1198,7 @@ mod tests { pending_update_action: None, suppress_shutdown_complete: false, skip_world_writable_scan_once: false, + skills: None, }, rx, op_rx, diff --git a/codex-rs/tui/src/app_backtrack.rs b/codex-rs/tui/src/app_backtrack.rs index 677f29abdd..a7c8611614 100644 --- a/codex-rs/tui/src/app_backtrack.rs +++ b/codex-rs/tui/src/app_backtrack.rs @@ -347,6 +347,7 @@ impl App { enhanced_keys_supported: self.enhanced_keys_supported, auth_manager: self.auth_manager.clone(), feedback: self.feedback.clone(), + skills: self.skills.clone(), is_first_run: false, }; self.chat_widget = diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index e9343fd8a4..4529b66566 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -29,6 +29,7 @@ use super::footer::reset_mode_after_activity; use super::footer::toggle_shortcut_mode; use super::paste_burst::CharDecision; use super::paste_burst::PasteBurst; +use super::skill_popup::SkillPopup; use crate::bottom_pane::paste_burst::FlushResult; use crate::bottom_pane::prompt_args::expand_custom_prompt; use crate::bottom_pane::prompt_args::expand_if_numeric_with_positional_args; @@ -53,6 +54,7 @@ use crate::clipboard_paste::normalize_pasted_path; use crate::clipboard_paste::pasted_image_format; use crate::history_cell; use crate::ui_consts::LIVE_PREFIX_COLS; +use codex_core::skills::model::SkillMetadata; use codex_file_search::FileMatch; use std::cell::RefCell; use std::collections::HashMap; @@ -115,6 +117,8 @@ pub(crate) struct ChatComposer { footer_hint_override: Option>, context_window_percent: Option, context_window_used_tokens: Option, + skills: Option>, + dismissed_skill_popup_token: Option, } /// Popup state – at most one can be visible at any time. @@ -122,6 +126,7 @@ enum ActivePopup { None, Command(CommandPopup), File(FileSearchPopup), + Skill(SkillPopup), } const FOOTER_SPACING_HEIGHT: u16 = 0; @@ -160,12 +165,18 @@ impl ChatComposer { footer_hint_override: None, context_window_percent: None, context_window_used_tokens: None, + skills: None, + dismissed_skill_popup_token: None, }; // Apply configuration via the setter to keep side-effects centralized. this.set_disable_paste_burst(disable_paste_burst); this } + pub fn set_skill_mentions(&mut self, skills: Option>) { + self.skills = skills; + } + fn layout_areas(&self, area: Rect) -> [Rect; 3] { let footer_props = self.footer_props(); let footer_hint_height = self @@ -178,6 +189,9 @@ impl ChatComposer { Constraint::Max(popup.calculate_required_height(area.width)) } ActivePopup::File(popup) => Constraint::Max(popup.calculate_required_height()), + ActivePopup::Skill(popup) => { + Constraint::Max(popup.calculate_required_height(area.width)) + } ActivePopup::None => Constraint::Max(footer_total_height), }; let [composer_rect, popup_rect] = @@ -234,14 +248,7 @@ impl ChatComposer { } // Explicit paste events should not trigger Enter suppression. self.paste_burst.clear_after_explicit_paste(); - // Keep popup sync consistent with key handling: prefer slash popup; only - // sync file popup when slash popup is NOT active. - self.sync_command_popup(); - if matches!(self.active_popup, ActivePopup::Command(_)) { - self.dismissed_file_popup_token = None; - } else { - self.sync_file_search_popup(); - } + self.sync_popups(); true } @@ -286,8 +293,7 @@ impl ChatComposer { self.attached_images.clear(); self.textarea.set_text(&text); self.textarea.set_cursor(0); - self.sync_command_popup(); - self.sync_file_search_popup(); + self.sync_popups(); } pub(crate) fn clear_for_ctrl_c(&mut self) -> Option { @@ -377,8 +383,7 @@ impl ChatComposer { pub(crate) fn insert_str(&mut self, text: &str) { self.textarea.insert_str(text); - self.sync_command_popup(); - self.sync_file_search_popup(); + self.sync_popups(); } /// Handle a key event coming from the main UI. @@ -386,16 +391,12 @@ impl ChatComposer { let result = match &mut self.active_popup { ActivePopup::Command(_) => self.handle_key_event_with_slash_popup(key_event), ActivePopup::File(_) => self.handle_key_event_with_file_popup(key_event), + ActivePopup::Skill(_) => self.handle_key_event_with_skill_popup(key_event), ActivePopup::None => self.handle_key_event_without_popup(key_event), }; // Update (or hide/show) popup after processing the key. - self.sync_command_popup(); - if matches!(self.active_popup, ActivePopup::Command(_)) { - self.dismissed_file_popup_token = None; - } else { - self.sync_file_search_popup(); - } + self.sync_popups(); result } @@ -465,6 +466,11 @@ impl ChatComposer { let mut cursor_target: Option = None; match sel { CommandItem::Builtin(cmd) => { + if cmd == SlashCommand::Skills { + self.textarea.set_text(""); + return (InputResult::Command(cmd), true); + } + let starts_with_cmd = first_line .trim_start() .starts_with(&format!("/{}", cmd.command())); @@ -714,23 +720,101 @@ impl ChatComposer { } } + fn handle_key_event_with_skill_popup(&mut self, key_event: KeyEvent) -> (InputResult, bool) { + if self.handle_shortcut_overlay_key(&key_event) { + return (InputResult::None, true); + } + if key_event.code == KeyCode::Esc { + let next_mode = esc_hint_mode(self.footer_mode, self.is_task_running); + if next_mode != self.footer_mode { + self.footer_mode = next_mode; + return (InputResult::None, true); + } + } else { + self.footer_mode = reset_mode_after_activity(self.footer_mode); + } + + let ActivePopup::Skill(popup) = &mut self.active_popup else { + unreachable!(); + }; + + match key_event { + KeyEvent { + code: KeyCode::Up, .. + } + | KeyEvent { + code: KeyCode::Char('p'), + modifiers: KeyModifiers::CONTROL, + .. + } => { + popup.move_up(); + (InputResult::None, true) + } + KeyEvent { + code: KeyCode::Down, + .. + } + | KeyEvent { + code: KeyCode::Char('n'), + modifiers: KeyModifiers::CONTROL, + .. + } => { + popup.move_down(); + (InputResult::None, true) + } + KeyEvent { + code: KeyCode::Esc, .. + } => { + if let Some(tok) = self.current_skill_token() { + self.dismissed_skill_popup_token = Some(tok); + } + self.active_popup = ActivePopup::None; + (InputResult::None, true) + } + KeyEvent { + code: KeyCode::Tab, .. + } + | KeyEvent { + code: KeyCode::Enter, + modifiers: KeyModifiers::NONE, + .. + } => { + let selected = popup.selected_skill().map(|skill| skill.name.clone()); + if let Some(name) = selected { + self.insert_selected_skill(&name); + } + self.active_popup = ActivePopup::None; + (InputResult::None, true) + } + input => self.handle_input_basic(input), + } + } + fn is_image_path(path: &str) -> bool { let lower = path.to_ascii_lowercase(); lower.ends_with(".png") || lower.ends_with(".jpg") || lower.ends_with(".jpeg") } - /// Extract the `@token` that the cursor is currently positioned on, if any. + fn skills_enabled(&self) -> bool { + self.skills.as_ref().is_some_and(|s| !s.is_empty()) + } + + /// Extract a token prefixed with `prefix` under the cursor, if any. /// - /// The returned string **does not** include the leading `@`. + /// The returned string **does not** include the prefix. /// /// Behavior: /// - The cursor may be anywhere *inside* the token (including on the - /// leading `@`). It does **not** need to be at the end of the line. + /// leading prefix). It does **not** need to be at the end of the line. /// - A token is delimited by ASCII whitespace (space, tab, newline). - /// - If the token under the cursor starts with `@`, that token is - /// returned without the leading `@`. This includes the case where the - /// token is just "@" (empty query), which is used to trigger a UI hint - fn current_at_token(textarea: &TextArea) -> Option { + /// - If the token under the cursor starts with `prefix`, that token is + /// returned without the leading prefix. When `allow_empty` is true, a + /// lone prefix character yields `Some(String::new())` to surface hints. + fn current_prefixed_token( + textarea: &TextArea, + prefix: char, + allow_empty: bool, + ) -> Option { let cursor_offset = textarea.cursor(); let text = textarea.text(); @@ -799,26 +883,40 @@ impl ChatComposer { None }; - let left_at = token_left - .filter(|t| t.starts_with('@')) - .map(|t| t[1..].to_string()); - let right_at = token_right - .filter(|t| t.starts_with('@')) - .map(|t| t[1..].to_string()); + let prefix_str = prefix.to_string(); + let left_match = token_left.filter(|t| t.starts_with(prefix)); + let right_match = token_right.filter(|t| t.starts_with(prefix)); + + let left_prefixed = left_match.map(|t| t[prefix.len_utf8()..].to_string()); + let right_prefixed = right_match.map(|t| t[prefix.len_utf8()..].to_string()); if at_whitespace { - if right_at.is_some() { - return right_at; + if right_prefixed.is_some() { + return right_prefixed; } - if token_left.is_some_and(|t| t == "@") { - return None; + if token_left.is_some_and(|t| t == prefix_str) { + return allow_empty.then(String::new); } - return left_at; + return left_prefixed; } - if after_cursor.starts_with('@') { - return right_at.or(left_at); + if after_cursor.starts_with(prefix) { + return right_prefixed.or(left_prefixed); } - left_at.or(right_at) + left_prefixed.or(right_prefixed) + } + + /// Extract the `@token` that the cursor is currently positioned on, if any. + /// + /// The returned string **does not** include the leading `@`. + fn current_at_token(textarea: &TextArea) -> Option { + Self::current_prefixed_token(textarea, '@', false) + } + + fn current_skill_token(&self) -> Option { + if !self.skills_enabled() { + return None; + } + Self::current_prefixed_token(&self.textarea, '$', true) } /// Replace the active `@token` (the one under the cursor) with `path`. @@ -872,6 +970,41 @@ impl ChatComposer { self.textarea.set_cursor(new_cursor); } + fn insert_selected_skill(&mut self, skill_name: &str) { + let cursor_offset = self.textarea.cursor(); + let text = self.textarea.text(); + let safe_cursor = Self::clamp_to_char_boundary(text, cursor_offset); + + let before_cursor = &text[..safe_cursor]; + let after_cursor = &text[safe_cursor..]; + + let start_idx = before_cursor + .char_indices() + .rfind(|(_, c)| c.is_whitespace()) + .map(|(idx, c)| idx + c.len_utf8()) + .unwrap_or(0); + + let end_rel_idx = after_cursor + .char_indices() + .find(|(_, c)| c.is_whitespace()) + .map(|(idx, _)| idx) + .unwrap_or(after_cursor.len()); + let end_idx = safe_cursor + end_rel_idx; + + let inserted = format!("${skill_name}"); + + let mut new_text = + String::with_capacity(text.len() - (end_idx - start_idx) + inserted.len() + 1); + new_text.push_str(&text[..start_idx]); + new_text.push_str(&inserted); + new_text.push(' '); + new_text.push_str(&text[end_idx..]); + + self.textarea.set_text(&new_text); + let new_cursor = start_idx.saturating_add(inserted.len()).saturating_add(1); + self.textarea.set_cursor(new_cursor); + } + /// Handle key event when no popup is visible. fn handle_key_event_without_popup(&mut self, key_event: KeyEvent) -> (InputResult, bool) { if self.handle_shortcut_overlay_key(&key_event) { @@ -1075,14 +1208,7 @@ impl ChatComposer { // Mirror insert_str() behavior so popups stay in sync when a // pending fast char flushes as normal typed input. self.textarea.insert_str(ch.to_string().as_str()); - // Keep popup sync consistent with key handling: prefer slash popup; only - // sync file popup when slash popup is NOT active. - self.sync_command_popup(); - if matches!(self.active_popup, ActivePopup::Command(_)) { - self.dismissed_file_popup_token = None; - } else { - self.sync_file_search_popup(); - } + self.sync_popups(); true } FlushResult::None => false, @@ -1423,10 +1549,49 @@ impl ChatComposer { .map(|items| if items.is_empty() { 0 } else { 1 }) } + fn sync_popups(&mut self) { + let file_token = Self::current_at_token(&self.textarea); + let skill_token = self.current_skill_token(); + + let allow_command_popup = file_token.is_none() && skill_token.is_none(); + self.sync_command_popup(allow_command_popup); + + if matches!(self.active_popup, ActivePopup::Command(_)) { + self.dismissed_file_popup_token = None; + self.dismissed_skill_popup_token = None; + return; + } + + if let Some(token) = skill_token { + self.sync_skill_popup(token); + return; + } + self.dismissed_skill_popup_token = None; + + if let Some(token) = file_token { + self.sync_file_search_popup(token); + return; + } + + self.dismissed_file_popup_token = None; + if matches!( + self.active_popup, + ActivePopup::File(_) | ActivePopup::Skill(_) + ) { + self.active_popup = ActivePopup::None; + } + } + /// Synchronize `self.command_popup` with the current text in the /// textarea. This must be called after every modification that can change /// the text so the popup is shown/updated/hidden as appropriate. - fn sync_command_popup(&mut self) { + fn sync_command_popup(&mut self, allow: bool) { + if !allow { + if matches!(self.active_popup, ActivePopup::Command(_)) { + self.active_popup = ActivePopup::None; + } + return; + } // Determine whether the caret is inside the initial '/name' token on the first line. let text = self.textarea.text(); let first_line_end = text.find('\n').unwrap_or(text.len()); @@ -1464,7 +1629,9 @@ impl ChatComposer { } _ => { if is_editing_slash_command_name { - let mut command_popup = CommandPopup::new(self.custom_prompts.clone()); + let skills_enabled = self.skills_enabled(); + let mut command_popup = + CommandPopup::new(self.custom_prompts.clone(), skills_enabled); command_popup.on_composer_text_change(first_line.to_string()); self.active_popup = ActivePopup::Command(command_popup); } @@ -1481,17 +1648,7 @@ impl ChatComposer { /// Synchronize `self.file_search_popup` with the current text in the textarea. /// Note this is only called when self.active_popup is NOT Command. - fn sync_file_search_popup(&mut self) { - // Determine if there is an @token underneath the cursor. - let query = match Self::current_at_token(&self.textarea) { - Some(token) => token, - None => { - self.active_popup = ActivePopup::None; - self.dismissed_file_popup_token = None; - return; - } - }; - + fn sync_file_search_popup(&mut self, query: String) { // If user dismissed popup for this exact query, don't reopen until text changes. if self.dismissed_file_popup_token.as_ref() == Some(&query) { return; @@ -1525,6 +1682,32 @@ impl ChatComposer { self.dismissed_file_popup_token = None; } + fn sync_skill_popup(&mut self, query: String) { + if self.dismissed_skill_popup_token.as_ref() == Some(&query) { + return; + } + + let skills = match self.skills.as_ref() { + Some(skills) if !skills.is_empty() => skills.clone(), + _ => { + self.active_popup = ActivePopup::None; + return; + } + }; + + match &mut self.active_popup { + ActivePopup::Skill(popup) => { + popup.set_query(&query); + popup.set_skills(skills); + } + _ => { + let mut popup = SkillPopup::new(skills); + popup.set_query(&query); + self.active_popup = ActivePopup::Skill(popup); + } + } + } + fn set_has_focus(&mut self, has_focus: bool) { self.has_focus = has_focus; } @@ -1574,6 +1757,7 @@ impl Renderable for ChatComposer { ActivePopup::None => footer_total_height, ActivePopup::Command(c) => c.calculate_required_height(width), ActivePopup::File(c) => c.calculate_required_height(), + ActivePopup::Skill(c) => c.calculate_required_height(width), } } @@ -1586,6 +1770,9 @@ impl Renderable for ChatComposer { ActivePopup::File(popup) => { popup.render_ref(popup_rect, buf); } + ActivePopup::Skill(popup) => { + popup.render_ref(popup_rect, buf); + } ActivePopup::None => { let footer_props = self.footer_props(); let custom_height = self.custom_footer_height(); diff --git a/codex-rs/tui/src/bottom_pane/command_popup.rs b/codex-rs/tui/src/bottom_pane/command_popup.rs index d7501cebbc..39bbfbd182 100644 --- a/codex-rs/tui/src/bottom_pane/command_popup.rs +++ b/codex-rs/tui/src/bottom_pane/command_popup.rs @@ -31,8 +31,11 @@ pub(crate) struct CommandPopup { } impl CommandPopup { - pub(crate) fn new(mut prompts: Vec) -> Self { - let builtins = built_in_slash_commands(); + pub(crate) fn new(mut prompts: Vec, skills_enabled: bool) -> Self { + let builtins: Vec<(&'static str, SlashCommand)> = built_in_slash_commands() + .into_iter() + .filter(|(_, cmd)| skills_enabled || *cmd != SlashCommand::Skills) + .collect(); // Exclude prompts that collide with builtin command names and sort by name. let exclude: HashSet = builtins.iter().map(|(n, _)| (*n).to_string()).collect(); prompts.retain(|p| !exclude.contains(&p.name)); @@ -232,7 +235,7 @@ mod tests { #[test] fn filter_includes_init_when_typing_prefix() { - let mut popup = CommandPopup::new(Vec::new()); + let mut popup = CommandPopup::new(Vec::new(), false); // Simulate the composer line starting with '/in' so the popup filters // matching commands by prefix. popup.on_composer_text_change("/in".to_string()); @@ -252,7 +255,7 @@ mod tests { #[test] fn selecting_init_by_exact_match() { - let mut popup = CommandPopup::new(Vec::new()); + let mut popup = CommandPopup::new(Vec::new(), false); popup.on_composer_text_change("/init".to_string()); // When an exact match exists, the selected command should be that @@ -267,7 +270,7 @@ mod tests { #[test] fn model_is_first_suggestion_for_mo() { - let mut popup = CommandPopup::new(Vec::new()); + let mut popup = CommandPopup::new(Vec::new(), false); popup.on_composer_text_change("/mo".to_string()); let matches = popup.filtered_items(); match matches.first() { @@ -297,7 +300,7 @@ mod tests { argument_hint: None, }, ]; - let popup = CommandPopup::new(prompts); + let popup = CommandPopup::new(prompts, false); let items = popup.filtered_items(); let mut prompt_names: Vec = items .into_iter() @@ -313,13 +316,16 @@ mod tests { #[test] fn prompt_name_collision_with_builtin_is_ignored() { // Create a prompt named like a builtin (e.g. "init"). - let popup = CommandPopup::new(vec![CustomPrompt { - name: "init".to_string(), - path: "/tmp/init.md".to_string().into(), - content: "should be ignored".to_string(), - description: None, - argument_hint: None, - }]); + let popup = CommandPopup::new( + vec![CustomPrompt { + name: "init".to_string(), + path: "/tmp/init.md".to_string().into(), + content: "should be ignored".to_string(), + description: None, + argument_hint: None, + }], + false, + ); let items = popup.filtered_items(); let has_collision_prompt = items.into_iter().any(|it| match it { CommandItem::UserPrompt(i) => popup.prompt(i).is_some_and(|p| p.name == "init"), @@ -333,13 +339,16 @@ mod tests { #[test] fn prompt_description_uses_frontmatter_metadata() { - let popup = CommandPopup::new(vec![CustomPrompt { - name: "draftpr".to_string(), - path: "/tmp/draftpr.md".to_string().into(), - content: "body".to_string(), - description: Some("Create feature branch, commit and open draft PR.".to_string()), - argument_hint: None, - }]); + let popup = CommandPopup::new( + vec![CustomPrompt { + name: "draftpr".to_string(), + path: "/tmp/draftpr.md".to_string().into(), + content: "body".to_string(), + description: Some("Create feature branch, commit and open draft PR.".to_string()), + argument_hint: None, + }], + false, + ); let rows = popup.rows_from_matches(vec![(CommandItem::UserPrompt(0), None, 0)]); let description = rows.first().and_then(|row| row.description.as_deref()); assert_eq!( @@ -350,13 +359,16 @@ mod tests { #[test] fn prompt_description_falls_back_when_missing() { - let popup = CommandPopup::new(vec![CustomPrompt { - name: "foo".to_string(), - path: "/tmp/foo.md".to_string().into(), - content: "body".to_string(), - description: None, - argument_hint: None, - }]); + let popup = CommandPopup::new( + vec![CustomPrompt { + name: "foo".to_string(), + path: "/tmp/foo.md".to_string().into(), + content: "body".to_string(), + description: None, + argument_hint: None, + }], + false, + ); let rows = popup.rows_from_matches(vec![(CommandItem::UserPrompt(0), None, 0)]); let description = rows.first().and_then(|row| row.description.as_deref()); assert_eq!(description, Some("send saved prompt")); diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index b4255fd979..a0425c92d7 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -8,6 +8,7 @@ use crate::render::renderable::Renderable; use crate::render::renderable::RenderableItem; use crate::tui::FrameRequester; use bottom_pane_view::BottomPaneView; +use codex_core::skills::model::SkillMetadata; use codex_file_search::FileMatch; use crossterm::event::KeyCode; use crossterm::event::KeyEvent; @@ -27,6 +28,7 @@ mod file_search_popup; mod footer; mod list_selection_view; mod prompt_args; +mod skill_popup; pub(crate) use list_selection_view::SelectionViewParams; mod feedback_view; pub(crate) use feedback_view::feedback_selection_params; @@ -87,6 +89,7 @@ pub(crate) struct BottomPaneParams { pub(crate) placeholder_text: String, pub(crate) disable_paste_burst: bool, pub(crate) animations_enabled: bool, + pub(crate) skills: Option>, } impl BottomPane { @@ -99,15 +102,19 @@ impl BottomPane { placeholder_text, disable_paste_burst, animations_enabled, + skills, } = params; + let mut composer = ChatComposer::new( + has_input_focus, + app_event_tx.clone(), + enhanced_keys_supported, + placeholder_text, + disable_paste_burst, + ); + composer.set_skill_mentions(skills); + Self { - composer: ChatComposer::new( - has_input_focus, - app_event_tx.clone(), - enhanced_keys_supported, - placeholder_text, - disable_paste_burst, - ), + composer, view_stack: Vec::new(), app_event_tx, frame_requester, @@ -578,6 +585,7 @@ mod tests { placeholder_text: "Ask Codex to do anything".to_string(), disable_paste_burst: false, animations_enabled: true, + skills: Some(Vec::new()), }); pane.push_approval_request(exec_request()); assert_eq!(CancellationEvent::Handled, pane.on_ctrl_c()); @@ -599,6 +607,7 @@ mod tests { placeholder_text: "Ask Codex to do anything".to_string(), disable_paste_burst: false, animations_enabled: true, + skills: Some(Vec::new()), }); // Create an approval modal (active view). @@ -631,6 +640,7 @@ mod tests { placeholder_text: "Ask Codex to do anything".to_string(), disable_paste_burst: false, animations_enabled: true, + skills: Some(Vec::new()), }); // Start a running task so the status indicator is active above the composer. @@ -697,6 +707,7 @@ mod tests { placeholder_text: "Ask Codex to do anything".to_string(), disable_paste_burst: false, animations_enabled: true, + skills: Some(Vec::new()), }); // Begin a task: show initial status. @@ -723,6 +734,7 @@ mod tests { placeholder_text: "Ask Codex to do anything".to_string(), disable_paste_burst: false, animations_enabled: true, + skills: Some(Vec::new()), }); // Activate spinner (status view replaces composer) with no live ring. @@ -753,6 +765,7 @@ mod tests { placeholder_text: "Ask Codex to do anything".to_string(), disable_paste_burst: false, animations_enabled: true, + skills: Some(Vec::new()), }); pane.set_task_running(true); @@ -780,6 +793,7 @@ mod tests { placeholder_text: "Ask Codex to do anything".to_string(), disable_paste_burst: false, animations_enabled: true, + skills: Some(Vec::new()), }); pane.set_task_running(true); diff --git a/codex-rs/tui/src/bottom_pane/skill_popup.rs b/codex-rs/tui/src/bottom_pane/skill_popup.rs new file mode 100644 index 0000000000..74c1b137ca --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/skill_popup.rs @@ -0,0 +1,142 @@ +use ratatui::buffer::Buffer; +use ratatui::layout::Rect; +use ratatui::widgets::WidgetRef; + +use super::popup_consts::MAX_POPUP_ROWS; +use super::scroll_state::ScrollState; +use super::selection_popup_common::GenericDisplayRow; +use super::selection_popup_common::measure_rows_height; +use super::selection_popup_common::render_rows; +use crate::render::Insets; +use crate::render::RectExt; +use codex_common::fuzzy_match::fuzzy_match; +use codex_core::skills::model::SkillMetadata; + +pub(crate) struct SkillPopup { + query: String, + skills: Vec, + state: ScrollState, +} + +impl SkillPopup { + pub(crate) fn new(skills: Vec) -> Self { + Self { + query: String::new(), + skills, + state: ScrollState::new(), + } + } + + pub(crate) fn set_skills(&mut self, skills: Vec) { + self.skills = skills; + self.clamp_selection(); + } + + pub(crate) fn set_query(&mut self, query: &str) { + self.query = query.to_string(); + self.clamp_selection(); + } + + pub(crate) fn calculate_required_height(&self, width: u16) -> u16 { + let rows = self.rows_from_matches(self.filtered()); + measure_rows_height(&rows, &self.state, MAX_POPUP_ROWS, width) + } + + pub(crate) fn move_up(&mut self) { + let len = self.filtered_items().len(); + self.state.move_up_wrap(len); + self.state.ensure_visible(len, MAX_POPUP_ROWS.min(len)); + } + + pub(crate) fn move_down(&mut self) { + let len = self.filtered_items().len(); + self.state.move_down_wrap(len); + self.state.ensure_visible(len, MAX_POPUP_ROWS.min(len)); + } + + pub(crate) fn selected_skill(&self) -> Option<&SkillMetadata> { + let matches = self.filtered_items(); + let idx = self.state.selected_idx?; + let skill_idx = matches.get(idx)?; + self.skills.get(*skill_idx) + } + + fn clamp_selection(&mut self) { + let len = self.filtered_items().len(); + self.state.clamp_selection(len); + self.state.ensure_visible(len, MAX_POPUP_ROWS.min(len)); + } + + fn filtered_items(&self) -> Vec { + self.filtered().into_iter().map(|(idx, _, _)| idx).collect() + } + + fn rows_from_matches( + &self, + matches: Vec<(usize, Option>, i32)>, + ) -> Vec { + matches + .into_iter() + .map(|(idx, indices, _score)| { + let skill = &self.skills[idx]; + let slug = skill + .path + .parent() + .and_then(|p| p.file_name()) + .and_then(|n| n.to_str()) + .unwrap_or(&skill.name); + let name = format!("{} ({slug})", skill.name); + let description = skill.description.clone(); + GenericDisplayRow { + name, + match_indices: indices, + is_current: false, + display_shortcut: None, + description: Some(description), + } + }) + .collect() + } + + fn filtered(&self) -> Vec<(usize, Option>, i32)> { + let filter = self.query.trim(); + let mut out: Vec<(usize, Option>, i32)> = Vec::new(); + + if filter.is_empty() { + for (idx, _skill) in self.skills.iter().enumerate() { + out.push((idx, None, 0)); + } + return out; + } + + for (idx, skill) in self.skills.iter().enumerate() { + if let Some((indices, score)) = fuzzy_match(&skill.name, filter) { + out.push((idx, Some(indices), score)); + } + } + + out.sort_by(|a, b| { + a.2.cmp(&b.2).then_with(|| { + let an = &self.skills[a.0].name; + let bn = &self.skills[b.0].name; + an.cmp(bn) + }) + }); + + out + } +} + +impl WidgetRef for SkillPopup { + fn render_ref(&self, area: Rect, buf: &mut Buffer) { + let rows = self.rows_from_matches(self.filtered()); + render_rows( + area.inset(Insets::tlbr(0, 2, 0, 0)), + buf, + &rows, + &self.state, + MAX_POPUP_ROWS, + "no skills", + ); + } +} diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index f18725d8bd..2ae53bc0c2 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -55,6 +55,7 @@ use codex_core::protocol::ViewImageToolCallEvent; use codex_core::protocol::WarningEvent; use codex_core::protocol::WebSearchBeginEvent; use codex_core::protocol::WebSearchEndEvent; +use codex_core::skills::model::SkillMetadata; use codex_protocol::ConversationId; use codex_protocol::approvals::ElicitationRequestEvent; use codex_protocol::parse_command::ParsedCommand; @@ -256,6 +257,7 @@ pub(crate) struct ChatWidgetInit { pub(crate) enhanced_keys_supported: bool, pub(crate) auth_manager: Arc, pub(crate) feedback: codex_feedback::CodexFeedback, + pub(crate) skills: Option>, pub(crate) is_first_run: bool, } @@ -1231,6 +1233,7 @@ impl ChatWidget { enhanced_keys_supported, auth_manager, feedback, + skills, is_first_run, } = common; let mut rng = rand::rng(); @@ -1249,6 +1252,7 @@ impl ChatWidget { placeholder_text: placeholder, disable_paste_burst: config.disable_paste_burst, animations_enabled: config.animations, + skills, }), active_cell: None, config: config.clone(), @@ -1307,6 +1311,7 @@ impl ChatWidget { enhanced_keys_supported, auth_manager, feedback, + skills, .. } = common; let mut rng = rand::rng(); @@ -1327,6 +1332,7 @@ impl ChatWidget { placeholder_text: placeholder, disable_paste_burst: config.disable_paste_burst, animations_enabled: config.animations, + skills, }), active_cell: None, config: config.clone(), @@ -1545,6 +1551,9 @@ impl ChatWidget { SlashCommand::Mention => { self.insert_str("@"); } + SlashCommand::Skills => { + self.insert_str("$"); + } SlashCommand::Status => { self.add_status_output(); } diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index a4d21608c8..699435a71a 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -355,6 +355,7 @@ async fn helpers_are_available_and_do_not_panic() { enhanced_keys_supported: false, auth_manager, feedback: codex_feedback::CodexFeedback::new(), + skills: None, is_first_run: true, }; let mut w = ChatWidget::new(init, conversation_manager); @@ -380,6 +381,7 @@ fn make_chatwidget_manual() -> ( placeholder_text: "Ask Codex to do anything".to_string(), disable_paste_burst: false, animations_enabled: cfg.animations, + skills: None, }); let auth_manager = AuthManager::from_auth_for_testing(CodexAuth::from_api_key("test")); let widget = ChatWidget { diff --git a/codex-rs/tui/src/slash_command.rs b/codex-rs/tui/src/slash_command.rs index 47b330cba6..e0c676812c 100644 --- a/codex-rs/tui/src/slash_command.rs +++ b/codex-rs/tui/src/slash_command.rs @@ -14,6 +14,7 @@ pub enum SlashCommand { // more frequently used commands should be listed first. Model, Approvals, + Skills, Review, New, Resume, @@ -46,6 +47,7 @@ impl SlashCommand { SlashCommand::Quit | SlashCommand::Exit => "exit Codex", SlashCommand::Diff => "show git diff (including untracked files)", SlashCommand::Mention => "mention a file", + SlashCommand::Skills => "use skills to improve how Codex performs specific tasks", SlashCommand::Status => "show current session configuration and token usage", SlashCommand::Model => "choose what model and reasoning effort to use", SlashCommand::Approvals => "choose what Codex can do without approval", @@ -76,6 +78,7 @@ impl SlashCommand { | SlashCommand::Logout => false, SlashCommand::Diff | SlashCommand::Mention + | SlashCommand::Skills | SlashCommand::Status | SlashCommand::Mcp | SlashCommand::Feedback From 1cfc967eb8cc2aeaf140e67b009ed22e0ab660ca Mon Sep 17 00:00:00 2001 From: Michael Bolin Date: Wed, 3 Dec 2025 16:12:31 -0800 Subject: [PATCH 18/30] fix: Features should be immutable over the lifetime of a session/thread (#7540) I noticed that `features: Features` was defined on `struct SessionConfiguration`, which is commonly owned by `SessionState`, which is in turn owned by `Session`. Though I do not believe that `Features` should be allowed to be modified over the course of a session (if the feature state is not invariant, it makes it harder to reason about), which argues that it should live on `Session` rather than `SessionState` or `SessionConfiguration`. This PR moves `Features` to `Session` and updates all call sites. It appears the only place we were mutating `Features` was: - in tests - the sub-agent config for a review task: https://github.com/openai/codex/blob/3ef76ff29d5eed258fb6b8550e0e2b973d0dca21/codex-rs/core/src/tasks/review.rs#L86-L89 Note this change also means it is no longer an `async` call to check the state of a feature, eliminating the possibility of a [TOCTTOU](https://en.wikipedia.org/wiki/Time-of-check_to_time-of-use) error between checking the state of a feature and acting on it: https://github.com/openai/codex/blob/3ef76ff29d5eed258fb6b8550e0e2b973d0dca21/codex-rs/core/src/codex.rs#L1069-L1076 --- codex-rs/core/src/codex.rs | 60 ++++++++++++------------------ codex-rs/core/src/compact.rs | 4 +- codex-rs/core/src/tasks/compact.rs | 2 +- 3 files changed, 26 insertions(+), 40 deletions(-) diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 29e7a1ce88..13be377e9c 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -12,6 +12,7 @@ use crate::compact::run_inline_auto_compact_task; use crate::compact::should_use_remote_compact_task; use crate::compact_remote::run_inline_remote_auto_compact_task; use crate::features::Feature; +use crate::features::Features; use crate::function_tool::FunctionCallError; use crate::parse_command::parse_command; use crate::parse_turn_item; @@ -190,7 +191,6 @@ impl Codex { sandbox_policy: config.sandbox_policy.clone(), cwd: config.cwd.clone(), original_config_do_not_use: Arc::clone(&config), - features: config.features.clone(), exec_policy, session_source, }; @@ -264,6 +264,9 @@ pub(crate) struct Session { conversation_id: ConversationId, tx_event: Sender, state: Mutex, + /// The set of enabled features should be invariant for the lifetime of the + /// session. + features: Features, pub(crate) active_turn: Mutex>, pub(crate) services: SessionServices, next_internal_sub_id: AtomicU64, @@ -344,8 +347,6 @@ pub(crate) struct SessionConfiguration { /// operate deterministically. cwd: PathBuf, - /// Set of feature flags for this session - features: Features, /// Execpolicy policy, applied only when enabled by feature flag. exec_policy: Arc, @@ -401,6 +402,7 @@ impl Session { sub_id: String, ) -> TurnContext { let config = session_configuration.original_config_do_not_use.clone(); + let features = &config.features; let model_family = find_family_for_model(&session_configuration.model) .unwrap_or_else(|| config.model_family.clone()); let mut per_turn_config = (*config).clone(); @@ -408,6 +410,7 @@ impl Session { per_turn_config.model_family = model_family.clone(); per_turn_config.model_reasoning_effort = session_configuration.model_reasoning_effort; per_turn_config.model_reasoning_summary = session_configuration.model_reasoning_summary; + per_turn_config.features = features.clone(); if let Some(model_info) = get_model_info(&model_family) { per_turn_config.model_context_window = Some(model_info.context_window); } @@ -430,7 +433,7 @@ impl Session { let tools_config = ToolsConfig::new(&ToolsConfigParams { model_family: &model_family, - features: &config.features, + features, }); TurnContext { @@ -516,7 +519,7 @@ impl Session { let mut post_session_configured_events = Vec::::new(); - for (alias, feature) in session_configuration.features.legacy_feature_usages() { + for (alias, feature) in config.features.legacy_feature_usages() { let canonical = feature.key(); let summary = format!("`{alias}` is deprecated. Use `{canonical}` instead."); let details = if alias == canonical { @@ -575,6 +578,7 @@ impl Session { conversation_id, tx_event: tx_event.clone(), state: Mutex::new(state), + features: config.features.clone(), active_turn: Mutex::new(None), services, next_internal_sub_id: AtomicU64::new(0), @@ -1046,7 +1050,7 @@ impl Session { } pub(crate) async fn record_model_warning(&self, message: impl Into, ctx: &TurnContext) { - if !self.enabled(Feature::ModelWarnings).await { + if !self.enabled(Feature::ModelWarnings) { return; } @@ -1075,13 +1079,8 @@ impl Session { self.persist_rollout_items(&rollout_items).await; } - pub async fn enabled(&self, feature: Feature) -> bool { - self.state - .lock() - .await - .session_configuration - .features - .enabled(feature) + pub fn enabled(&self, feature: Feature) -> bool { + self.features.enabled(feature) } async fn send_raw_response_items(&self, turn_context: &TurnContext, items: &[ResponseItem]) { @@ -1264,7 +1263,7 @@ impl Session { turn_context: Arc, cancellation_token: CancellationToken, ) { - if !self.enabled(Feature::GhostCommit).await { + if !self.enabled(Feature::GhostCommit) { return; } let token = match turn_context.tool_call_gate.subscribe().await { @@ -1852,7 +1851,7 @@ async fn spawn_review_thread( let review_model_family = find_family_for_model(&model) .unwrap_or_else(|| parent_turn_context.client.get_model_family()); // For reviews, disable web_search and view_image regardless of global settings. - let mut review_features = config.features.clone(); + let mut review_features = sess.features.clone(); review_features .disable(crate::features::Feature::WebSearchRequest) .disable(crate::features::Feature::ViewImageTool); @@ -1873,6 +1872,7 @@ async fn spawn_review_thread( per_turn_config.model_family = model_family.clone(); per_turn_config.model_reasoning_effort = Some(ReasoningEffortConfig::Low); per_turn_config.model_reasoning_summary = ReasoningSummaryConfig::Detailed; + per_turn_config.features = review_features.clone(); if let Some(model_info) = get_model_info(&model_family) { per_turn_config.model_context_window = Some(model_info.context_window); } @@ -2020,7 +2020,7 @@ pub(crate) async fn run_task( // as long as compaction works well in getting us way below the token limit, we shouldn't worry about being in an infinite loop. if token_limit_reached { - if should_use_remote_compact_task(&sess).await { + if should_use_remote_compact_task(&sess) { run_inline_remote_auto_compact_task(sess.clone(), turn_context.clone()) .await; } else { @@ -2103,14 +2103,7 @@ async fn run_turn( .supports_parallel_tool_calls; // TODO(jif) revert once testing phase is done. - let parallel_tool_calls = model_supports_parallel - && sess - .state - .lock() - .await - .session_configuration - .features - .enabled(Feature::ParallelToolCalls); + let parallel_tool_calls = model_supports_parallel && sess.enabled(Feature::ParallelToolCalls); let mut base_instructions = turn_context.base_instructions.clone(); if parallel_tool_calls { static INSTRUCTIONS: &str = include_str!("../templates/parallel/instructions.md"); @@ -2486,7 +2479,6 @@ pub(super) fn get_last_assistant_message_from_turn(responses: &[ResponseItem]) - }) } -use crate::features::Features; #[cfg(test)] pub(crate) use tests::make_session_and_context; @@ -2599,7 +2591,6 @@ mod tests { sandbox_policy: config.sandbox_policy.clone(), cwd: config.cwd.clone(), original_config_do_not_use: Arc::clone(&config), - features: Features::default(), exec_policy: Arc::new(ExecPolicy::empty()), session_source: SessionSource::Exec, }; @@ -2798,7 +2789,6 @@ mod tests { sandbox_policy: config.sandbox_policy.clone(), cwd: config.cwd.clone(), original_config_do_not_use: Arc::clone(&config), - features: Features::default(), exec_policy: Arc::new(ExecPolicy::empty()), session_source: SessionSource::Exec, }; @@ -2831,6 +2821,7 @@ mod tests { conversation_id, tx_event, state: Mutex::new(state), + features: config.features.clone(), active_turn: Mutex::new(None), services, next_internal_sub_id: AtomicU64::new(0), @@ -2876,7 +2867,6 @@ mod tests { sandbox_policy: config.sandbox_policy.clone(), cwd: config.cwd.clone(), original_config_do_not_use: Arc::clone(&config), - features: Features::default(), exec_policy: Arc::new(ExecPolicy::empty()), session_source: SessionSource::Exec, }; @@ -2909,6 +2899,7 @@ mod tests { conversation_id, tx_event, state: Mutex::new(state), + features: config.features.clone(), active_turn: Mutex::new(None), services, next_internal_sub_id: AtomicU64::new(0), @@ -2919,15 +2910,10 @@ mod tests { #[tokio::test] async fn record_model_warning_appends_user_message() { - let (session, turn_context) = make_session_and_context(); - - session - .state - .lock() - .await - .session_configuration - .features - .enable(Feature::ModelWarnings); + let (mut session, turn_context) = make_session_and_context(); + let mut features = Features::with_defaults(); + features.enable(Feature::ModelWarnings); + session.features = features; session .record_model_warning("too many unified exec sessions", &turn_context) diff --git a/codex-rs/core/src/compact.rs b/codex-rs/core/src/compact.rs index fb5c187b7f..7ce325a75a 100644 --- a/codex-rs/core/src/compact.rs +++ b/codex-rs/core/src/compact.rs @@ -32,13 +32,13 @@ pub const SUMMARIZATION_PROMPT: &str = include_str!("../templates/compact/prompt pub const SUMMARY_PREFIX: &str = include_str!("../templates/compact/summary_prefix.md"); const COMPACT_USER_MESSAGE_MAX_TOKENS: usize = 20_000; -pub(crate) async fn should_use_remote_compact_task(session: &Session) -> bool { +pub(crate) fn should_use_remote_compact_task(session: &Session) -> bool { session .services .auth_manager .auth() .is_some_and(|auth| auth.mode == AuthMode::ChatGPT) - && session.enabled(Feature::RemoteCompaction).await + && session.enabled(Feature::RemoteCompaction) } pub(crate) async fn run_inline_auto_compact_task( diff --git a/codex-rs/core/src/tasks/compact.rs b/codex-rs/core/src/tasks/compact.rs index 893c0c476a..293116c167 100644 --- a/codex-rs/core/src/tasks/compact.rs +++ b/codex-rs/core/src/tasks/compact.rs @@ -25,7 +25,7 @@ impl SessionTask for CompactTask { _cancellation_token: CancellationToken, ) -> Option { let session = session.clone_session(); - if crate::compact::should_use_remote_compact_task(&session).await { + if crate::compact::should_use_remote_compact_task(&session) { crate::compact_remote::run_remote_compact_task(session, ctx).await } else { crate::compact::run_compact_task(session, ctx, input).await From 70b97790bec00bce43eae326200742e76d719339 Mon Sep 17 00:00:00 2001 From: muyuanjin Date: Thu, 4 Dec 2025 08:45:08 +0800 Subject: [PATCH 19/30] fix: wrap long exec lines in transcript overlay (#7481) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit What ----- - Fix the Ctrl+T transcript overlay so that very long exec output lines are soft‑wrapped to the viewport width instead of being rendered as a single truncated row. - Add a regression test to `TranscriptOverlay` to ensure long exec outputs are rendered on multiple lines in the overlay. Why ---- - Previously, the transcript overlay rendered extremely long single exec lines as one on‑screen row and simply cut them off at the right edge, with no horizontal scrolling. - This made it impossible to inspect the full content of long tool/exec outputs in the transcript view, even though the main TUI view already wrapped those lines. - Fixes #7454. How ---- - Update `ExecCell::transcript_lines` to wrap exec output lines using the existing `RtOptions`/`word_wrap_line` helpers so that transcript rendering is width‑aware. - Reuse the existing line utilities to expand the wrapped `Line` values into the transcript overlay, preserving styling while respecting the current viewport width. - Add `transcript_overlay_wraps_long_exec_output_lines` test in `pager_overlay.rs` that constructs a long single‑line exec output, renders the transcript overlay into a small buffer, and asserts that the long marker string spans multiple rendered lines. --- codex-rs/tui/src/exec_cell/render.rs | 7 ++++- codex-rs/tui/src/pager_overlay.rs | 43 ++++++++++++++++++++++++++++ 2 files changed, 49 insertions(+), 1 deletion(-) diff --git a/codex-rs/tui/src/exec_cell/render.rs b/codex-rs/tui/src/exec_cell/render.rs index 3375f1dca8..a38cf5a84c 100644 --- a/codex-rs/tui/src/exec_cell/render.rs +++ b/codex-rs/tui/src/exec_cell/render.rs @@ -219,7 +219,12 @@ impl HistoryCell for ExecCell { if let Some(output) = call.output.as_ref() { if !call.is_unified_exec_interaction() { - lines.extend(output.formatted_output.lines().map(ansi_escape_line)); + let wrap_width = width.max(1) as usize; + let wrap_opts = RtOptions::new(wrap_width); + for unwrapped in output.formatted_output.lines().map(ansi_escape_line) { + let wrapped = word_wrap_line(&unwrapped, wrap_opts.clone()); + push_owned_lines(&wrapped, &mut lines); + } } let duration = call .duration diff --git a/codex-rs/tui/src/pager_overlay.rs b/codex-rs/tui/src/pager_overlay.rs index 3b47e9a70e..b5f7b963cc 100644 --- a/codex-rs/tui/src/pager_overlay.rs +++ b/codex-rs/tui/src/pager_overlay.rs @@ -748,6 +748,49 @@ mod tests { assert_snapshot!("transcript_overlay_apply_patch_scroll_vt100", snapshot); } + #[test] + fn transcript_overlay_wraps_long_exec_output_lines() { + let marker = "Z"; + let long_line = marker.repeat(200); + + let mut exec_cell = crate::exec_cell::new_active_exec_command( + "exec-long".into(), + vec!["bash".into(), "-lc".into(), "echo long".into()], + vec![ParsedCommand::Unknown { + cmd: "echo long".into(), + }], + ExecCommandSource::Agent, + None, + false, + ); + exec_cell.complete_call( + "exec-long", + CommandOutput { + exit_code: 0, + aggregated_output: format!("{long_line}\n"), + formatted_output: long_line, + }, + Duration::from_millis(10), + ); + let exec_cell: Arc = Arc::new(exec_cell); + + let mut overlay = TranscriptOverlay::new(vec![exec_cell]); + let area = Rect::new(0, 0, 20, 10); + let mut buf = Buffer::empty(area); + + overlay.render(area, &mut buf); + let rendered = buffer_to_text(&buf, area); + + let wrapped_lines = rendered + .lines() + .filter(|line| line.contains(marker)) + .count(); + assert!( + wrapped_lines >= 2, + "expected long exec output to wrap into multiple lines in transcript overlay, got:\n{rendered}" + ); + } + #[test] fn transcript_overlay_keeps_scroll_pinned_at_bottom() { let mut overlay = TranscriptOverlay::new( From 00cc00ead8d45722342a43274af3f8cfc41cc67e Mon Sep 17 00:00:00 2001 From: Ahmed Ibrahim Date: Wed, 3 Dec 2025 17:17:56 -0800 Subject: [PATCH 20/30] Introduce `ModelsManager` and migrate `app-server` to use it. (#7552) --- .../app-server/src/codex_message_processor.rs | 3 +- codex-rs/app-server/src/models.rs | 11 ++++-- codex-rs/core/src/auth.rs | 4 ++ codex-rs/core/src/codex.rs | 24 ------------ codex-rs/core/src/conversation_manager.rs | 10 ++++- codex-rs/core/src/openai_models/mod.rs | 1 + .../core/src/openai_models/models_manager.rs | 26 +++++++++++++ codex-rs/core/src/rollout/policy.rs | 1 - codex-rs/core/tests/suite/list_models.rs | 37 ++++--------------- .../src/event_processor_with_human_output.rs | 1 - codex-rs/mcp-server/src/codex_tool_runner.rs | 1 - codex-rs/protocol/src/openai_models.rs | 5 --- codex-rs/protocol/src/protocol.rs | 2 - codex-rs/tui/src/chatwidget.rs | 1 - 14 files changed, 56 insertions(+), 71 deletions(-) create mode 100644 codex-rs/core/src/openai_models/models_manager.rs diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index 6b85049d28..65721a698e 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -1862,8 +1862,7 @@ impl CodexMessageProcessor { async fn list_models(&self, request_id: RequestId, params: ModelListParams) { let ModelListParams { limit, cursor } = params; - let auth_mode = self.auth_manager.auth().map(|auth| auth.mode); - let models = supported_models(auth_mode); + let models = supported_models(self.conversation_manager.clone()).await; let total = models.len(); if total == 0 { diff --git a/codex-rs/app-server/src/models.rs b/codex-rs/app-server/src/models.rs index 78f6fd5851..3ac71e85b9 100644 --- a/codex-rs/app-server/src/models.rs +++ b/codex-rs/app-server/src/models.rs @@ -1,12 +1,15 @@ -use codex_app_server_protocol::AuthMode; +use std::sync::Arc; + use codex_app_server_protocol::Model; use codex_app_server_protocol::ReasoningEffortOption; -use codex_core::openai_models::model_presets::builtin_model_presets; +use codex_core::ConversationManager; use codex_protocol::openai_models::ModelPreset; use codex_protocol::openai_models::ReasoningEffortPreset; -pub fn supported_models(auth_mode: Option) -> Vec { - builtin_model_presets(auth_mode) +pub async fn supported_models(conversation_manager: Arc) -> Vec { + conversation_manager + .list_models() + .await .into_iter() .map(model_from_preset) .collect() diff --git a/codex-rs/core/src/auth.rs b/codex-rs/core/src/auth.rs index d874435e8e..a5c9add53f 100644 --- a/codex-rs/core/src/auth.rs +++ b/codex-rs/core/src/auth.rs @@ -1201,4 +1201,8 @@ impl AuthManager { self.reload(); Ok(removed) } + + pub fn get_auth_mode(&self) -> Option { + self.auth().map(|a| a.mode) + } } diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 13be377e9c..885a4cdf74 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -23,7 +23,6 @@ use crate::user_notification::UserNotifier; use crate::util::error_or_panic; use async_channel::Receiver; use async_channel::Sender; -use codex_app_server_protocol::AuthMode; use codex_protocol::ConversationId; use codex_protocol::items::TurnItem; use codex_protocol::protocol::FileChange; @@ -643,14 +642,6 @@ impl Session { Ok(sess) } - pub(crate) fn get_auth_mode(&self) -> AuthMode { - self.services - .auth_manager - .auth() - .map(|a| a.mode) - .unwrap_or(AuthMode::ApiKey) - } - pub(crate) fn get_tx_event(&self) -> Sender { self.tx_event.clone() } @@ -1486,9 +1477,6 @@ async fn submission_loop(sess: Arc, config: Arc, rx_sub: Receiv Op::Review { review_request } => { handlers::review(&sess, &config, sub.id.clone(), review_request).await; } - Op::ListModels => { - handlers::list_models(&sess, sub.id.clone(), Some(sess.get_auth_mode())).await; - } _ => {} // Ignore unknown ops; enum is non_exhaustive to allow extensions. } } @@ -1505,15 +1493,12 @@ mod handlers { use crate::config::Config; use crate::mcp::auth::compute_auth_statuses; use crate::mcp::collect_mcp_snapshot_from_manager; - use crate::openai_models::model_presets::builtin_model_presets; use crate::review_prompts::resolve_review_request; use crate::tasks::CompactTask; use crate::tasks::RegularTask; use crate::tasks::UndoTask; use crate::tasks::UserShellCommandTask; - use codex_app_server_protocol::AuthMode; use codex_protocol::custom_prompts::CustomPrompt; - use codex_protocol::openai_models::AvailableModelsEvent; use codex_protocol::protocol::CodexErrorInfo; use codex_protocol::protocol::ErrorEvent; use codex_protocol::protocol::Event; @@ -1828,15 +1813,6 @@ mod handlers { } } } - - pub async fn list_models(sess: &Arc, sub_id: String, auth_mode: Option) { - let models = builtin_model_presets(auth_mode); - let event = Event { - id: sub_id, - msg: EventMsg::ListModelsResponse(AvailableModelsEvent { models }), - }; - sess.send_event_raw(event).await; - } } /// Spawn a review thread using the given prompt. diff --git a/codex-rs/core/src/conversation_manager.rs b/codex-rs/core/src/conversation_manager.rs index 0f4577bf1e..3ac09ec78c 100644 --- a/codex-rs/core/src/conversation_manager.rs +++ b/codex-rs/core/src/conversation_manager.rs @@ -7,6 +7,7 @@ use crate::codex_conversation::CodexConversation; use crate::config::Config; use crate::error::CodexErr; use crate::error::Result as CodexResult; +use crate::openai_models::models_manager::ModelsManager; use crate::protocol::Event; use crate::protocol::EventMsg; use crate::protocol::SessionConfiguredEvent; @@ -14,6 +15,7 @@ use crate::rollout::RolloutRecorder; use codex_protocol::ConversationId; use codex_protocol::items::TurnItem; use codex_protocol::models::ResponseItem; +use codex_protocol::openai_models::ModelPreset; use codex_protocol::protocol::InitialHistory; use codex_protocol::protocol::RolloutItem; use codex_protocol::protocol::SessionSource; @@ -35,6 +37,7 @@ pub struct NewConversation { pub struct ConversationManager { conversations: Arc>>>, auth_manager: Arc, + models_manager: Arc, session_source: SessionSource, } @@ -42,8 +45,9 @@ impl ConversationManager { pub fn new(auth_manager: Arc, session_source: SessionSource) -> Self { Self { conversations: Arc::new(RwLock::new(HashMap::new())), - auth_manager, + auth_manager: auth_manager.clone(), session_source, + models_manager: Arc::new(ModelsManager::new(auth_manager.get_auth_mode())), } } @@ -193,6 +197,10 @@ impl ConversationManager { self.finalize_spawn(codex, conversation_id).await } + + pub async fn list_models(&self) -> Vec { + self.models_manager.available_models.read().await.clone() + } } /// Return a prefix of `items` obtained by cutting strictly before the nth user message diff --git a/codex-rs/core/src/openai_models/mod.rs b/codex-rs/core/src/openai_models/mod.rs index 7df68c4ab7..13ee2e0605 100644 --- a/codex-rs/core/src/openai_models/mod.rs +++ b/codex-rs/core/src/openai_models/mod.rs @@ -1 +1,2 @@ pub mod model_presets; +pub mod models_manager; diff --git a/codex-rs/core/src/openai_models/models_manager.rs b/codex-rs/core/src/openai_models/models_manager.rs new file mode 100644 index 0000000000..1d57f1e693 --- /dev/null +++ b/codex-rs/core/src/openai_models/models_manager.rs @@ -0,0 +1,26 @@ +use codex_app_server_protocol::AuthMode; +use codex_protocol::openai_models::ModelPreset; +use tokio::sync::RwLock; + +use crate::openai_models::model_presets::builtin_model_presets; + +pub struct ModelsManager { + pub available_models: RwLock>, + pub etag: String, + pub auth_mode: Option, +} + +impl ModelsManager { + pub fn new(auth_mode: Option) -> Self { + Self { + available_models: RwLock::new(builtin_model_presets(auth_mode)), + etag: String::new(), + auth_mode, + } + } + + pub async fn refresh_available_models(&self) { + let models = builtin_model_presets(self.auth_mode); + *self.available_models.write().await = models; + } +} diff --git a/codex-rs/core/src/rollout/policy.rs b/codex-rs/core/src/rollout/policy.rs index 4b7d7d3068..58072f9336 100644 --- a/codex-rs/core/src/rollout/policy.rs +++ b/codex-rs/core/src/rollout/policy.rs @@ -51,7 +51,6 @@ pub(crate) fn should_persist_event_msg(ev: &EventMsg) -> bool { | EventMsg::Warning(_) | EventMsg::TaskStarted(_) | EventMsg::TaskComplete(_) - | EventMsg::ListModelsResponse(_) | EventMsg::AgentMessageDelta(_) | EventMsg::AgentReasoningDelta(_) | EventMsg::AgentReasoningRawContentDelta(_) diff --git a/codex-rs/core/tests/suite/list_models.rs b/codex-rs/core/tests/suite/list_models.rs index ecfe9138e4..9303820163 100644 --- a/codex-rs/core/tests/suite/list_models.rs +++ b/codex-rs/core/tests/suite/list_models.rs @@ -1,51 +1,30 @@ use anyhow::Result; use codex_core::CodexAuth; -use codex_core::protocol::EventMsg; -use codex_core::protocol::Op; +use codex_core::ConversationManager; use codex_protocol::openai_models::ModelPreset; use codex_protocol::openai_models::ReasoningEffort; use codex_protocol::openai_models::ReasoningEffortPreset; -use core_test_support::responses::start_mock_server; -use core_test_support::test_codex::test_codex; -use core_test_support::wait_for_event_match; use pretty_assertions::assert_eq; #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn list_models_returns_api_key_models() -> Result<()> { - let server = start_mock_server().await; - let mut builder = test_codex().with_auth(CodexAuth::from_api_key("sk-test")); - let test = builder.build(&server).await?; - - test.codex.submit(Op::ListModels).await?; - - let event = wait_for_event_match(&test.codex, |event| match event { - EventMsg::ListModelsResponse(models) => Some(models.clone()), - _ => None, - }) - .await; + let manager = ConversationManager::with_auth(CodexAuth::from_api_key("sk-test")); + let models = manager.list_models().await; let expected_models = expected_models_for_api_key(); - assert_eq!(expected_models, event.models); + assert_eq!(expected_models, models); Ok(()) } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn list_models_returns_chatgpt_models() -> Result<()> { - let server = start_mock_server().await; - let mut builder = test_codex().with_auth(CodexAuth::create_dummy_chatgpt_auth_for_testing()); - let test = builder.build(&server).await?; - - test.codex.submit(Op::ListModels).await?; - - let event = wait_for_event_match(&test.codex, |event| match event { - EventMsg::ListModelsResponse(models) => Some(models.clone()), - _ => None, - }) - .await; + let manager = + ConversationManager::with_auth(CodexAuth::create_dummy_chatgpt_auth_for_testing()); + let models = manager.list_models().await; let expected_models = expected_models_for_chatgpt(); - assert_eq!(expected_models, event.models); + assert_eq!(expected_models, models); Ok(()) } diff --git a/codex-rs/exec/src/event_processor_with_human_output.rs b/codex-rs/exec/src/event_processor_with_human_output.rs index 17a167585b..64a5358f35 100644 --- a/codex-rs/exec/src/event_processor_with_human_output.rs +++ b/codex-rs/exec/src/event_processor_with_human_output.rs @@ -583,7 +583,6 @@ impl EventProcessor for EventProcessorWithHumanOutput { | EventMsg::ReasoningContentDelta(_) | EventMsg::ReasoningRawContentDelta(_) | EventMsg::UndoCompleted(_) - | EventMsg::ListModelsResponse(_) | EventMsg::UndoStarted(_) => {} } CodexStatus::Running diff --git a/codex-rs/mcp-server/src/codex_tool_runner.rs b/codex-rs/mcp-server/src/codex_tool_runner.rs index 2b3fd07707..55808f17ca 100644 --- a/codex-rs/mcp-server/src/codex_tool_runner.rs +++ b/codex-rs/mcp-server/src/codex_tool_runner.rs @@ -307,7 +307,6 @@ async fn run_codex_tool_session_inner( | EventMsg::UndoCompleted(_) | EventMsg::ExitedReviewMode(_) | EventMsg::ContextCompacted(_) - | EventMsg::ListModelsResponse(_) | EventMsg::DeprecationNotice(_) => { // For now, we do not do anything extra for these // events. Note that diff --git a/codex-rs/protocol/src/openai_models.rs b/codex-rs/protocol/src/openai_models.rs index f9a05f9ff6..b99c3bbde1 100644 --- a/codex-rs/protocol/src/openai_models.rs +++ b/codex-rs/protocol/src/openai_models.rs @@ -35,11 +35,6 @@ pub enum ReasoningEffort { XHigh, } -#[derive(Debug, Clone, Deserialize, Serialize, TS, JsonSchema)] -pub struct AvailableModelsEvent { - pub models: Vec, -} - /// A reasoning effort option that can be surfaced for a model. #[derive(Debug, Clone, Deserialize, Serialize, TS, JsonSchema, PartialEq)] pub struct ReasoningEffortPreset { diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index 6b787afe17..99d2ec70d3 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -19,7 +19,6 @@ use crate::message_history::HistoryEntry; use crate::models::ContentItem; use crate::models::ResponseItem; use crate::num_format::format_with_separators; -use crate::openai_models::AvailableModelsEvent; use crate::openai_models::ReasoningEffort as ReasoningEffortConfig; use crate::parse_command::ParsedCommand; use crate::plan_tool::UpdatePlanArgs; @@ -582,7 +581,6 @@ pub enum EventMsg { AgentMessageContentDelta(AgentMessageContentDeltaEvent), ReasoningContentDelta(ReasoningContentDeltaEvent), ReasoningRawContentDelta(ReasoningRawContentDeltaEvent), - ListModelsResponse(AvailableModelsEvent), } /// Codex errors that we expose to clients. diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 2ae53bc0c2..c4059dc852 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -1842,7 +1842,6 @@ impl ChatWidget { | EventMsg::ItemCompleted(_) | EventMsg::AgentMessageContentDelta(_) | EventMsg::ReasoningContentDelta(_) - | EventMsg::ListModelsResponse(_) | EventMsg::ReasoningRawContentDelta(_) => {} } } From 8da91d1c8913a29b795132a1b899c88e62e09e3b Mon Sep 17 00:00:00 2001 From: Ahmed Ibrahim Date: Wed, 3 Dec 2025 18:00:47 -0800 Subject: [PATCH 21/30] Migrate `tui` to use models manager (#7555) - This PR treats the `ModelsManager` like `AuthManager` and propagate it into the tui, replacing the `builtin_model_presets` - We are also decreasing the visibility of `builtin_model_presets` based on https://github.com/openai/codex/pull/7552 --- codex-rs/core/src/conversation_manager.rs | 4 + .../core/src/openai_models/model_presets.rs | 3 +- codex-rs/tui/Cargo.toml | 2 + codex-rs/tui/src/app.rs | 73 ++++++++++++++----- codex-rs/tui/src/app_backtrack.rs | 1 + codex-rs/tui/src/chatwidget.rs | 24 ++++-- codex-rs/tui/src/chatwidget/tests.rs | 45 +++++++----- 7 files changed, 107 insertions(+), 45 deletions(-) diff --git a/codex-rs/core/src/conversation_manager.rs b/codex-rs/core/src/conversation_manager.rs index 3ac09ec78c..f41e5b597f 100644 --- a/codex-rs/core/src/conversation_manager.rs +++ b/codex-rs/core/src/conversation_manager.rs @@ -201,6 +201,10 @@ impl ConversationManager { pub async fn list_models(&self) -> Vec { self.models_manager.available_models.read().await.clone() } + + pub fn get_models_manager(&self) -> Arc { + self.models_manager.clone() + } } /// Return a prefix of `items` obtained by cutting strictly before the nth user message diff --git a/codex-rs/core/src/openai_models/model_presets.rs b/codex-rs/core/src/openai_models/model_presets.rs index f649d88741..3d46c695cc 100644 --- a/codex-rs/core/src/openai_models/model_presets.rs +++ b/codex-rs/core/src/openai_models/model_presets.rs @@ -209,7 +209,7 @@ static PRESETS: Lazy> = Lazy::new(|| { ] }); -pub fn builtin_model_presets(auth_mode: Option) -> Vec { +pub(crate) fn builtin_model_presets(auth_mode: Option) -> Vec { PRESETS .iter() .filter(|preset| match auth_mode { @@ -220,6 +220,7 @@ pub fn builtin_model_presets(auth_mode: Option) -> Vec { .collect() } +// todo(aibrahim): remove this once we migrate tests pub fn all_model_presets() -> &'static Vec { &PRESETS } diff --git a/codex-rs/tui/Cargo.toml b/codex-rs/tui/Cargo.toml index 828255a582..248205c427 100644 --- a/codex-rs/tui/Cargo.toml +++ b/codex-rs/tui/Cargo.toml @@ -78,6 +78,8 @@ tokio = { workspace = true, features = [ "process", "rt-multi-thread", "signal", + "test-util", + "time", ] } tokio-stream = { workspace = true } toml = { workspace = true } diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index df9e4b5d4b..2367bbd582 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -29,7 +29,7 @@ use codex_core::features::Feature; use codex_core::model_family::find_family_for_model; use codex_core::openai_models::model_presets::HIDE_GPT_5_1_CODEX_MAX_MIGRATION_PROMPT_CONFIG; use codex_core::openai_models::model_presets::HIDE_GPT5_1_MIGRATION_PROMPT_CONFIG; -use codex_core::openai_models::model_presets::all_model_presets; +use codex_core::openai_models::models_manager::ModelsManager; use codex_core::protocol::EventMsg; use codex_core::protocol::FinalOutput; use codex_core::protocol::Op; @@ -38,6 +38,7 @@ use codex_core::protocol::TokenUsage; use codex_core::skills::load_skills; use codex_core::skills::model::SkillMetadata; use codex_protocol::ConversationId; +use codex_protocol::openai_models::ModelPreset; use codex_protocol::openai_models::ModelUpgrade; use codex_protocol::openai_models::ReasoningEffort as ReasoningEffortConfig; use color_eyre::eyre::Result; @@ -98,12 +99,13 @@ fn should_show_model_migration_prompt( current_model: &str, target_model: &str, hide_prompt_flag: Option, + available_models: Vec, ) -> bool { if target_model == current_model || hide_prompt_flag.unwrap_or(false) { return false; } - all_model_presets() + available_models .iter() .filter(|preset| preset.upgrade.is_some()) .any(|preset| preset.model == current_model) @@ -124,8 +126,10 @@ async fn handle_model_migration_prompt_if_needed( config: &mut Config, app_event_tx: &AppEventSender, auth_mode: Option, + models_manager: Arc, ) -> Option { - let upgrade = all_model_presets() + let available_models = models_manager.available_models.read().await.clone(); + let upgrade = available_models .iter() .find(|preset| preset.model == config.model) .and_then(|preset| preset.upgrade.as_ref()); @@ -142,7 +146,12 @@ async fn handle_model_migration_prompt_if_needed( let target_model = target_model.to_string(); let hide_prompt_flag = migration_prompt_hidden(config, migration_config_key); - if !should_show_model_migration_prompt(&config.model, &target_model, hide_prompt_flag) { + if !should_show_model_migration_prompt( + &config.model, + &target_model, + hide_prompt_flag, + available_models.clone(), + ) { return None; } @@ -200,7 +209,6 @@ pub(crate) struct App { pub(crate) app_event_tx: AppEventSender, pub(crate) chat_widget: ChatWidget, pub(crate) auth_manager: Arc, - /// Config is stored here so we can recreate ChatWidgets as needed. pub(crate) config: Config, pub(crate) active_profile: Option, @@ -261,17 +269,21 @@ impl App { let app_event_tx = AppEventSender::new(app_event_tx); let auth_mode = auth_manager.auth().map(|auth| auth.mode); - let exit_info = - handle_model_migration_prompt_if_needed(tui, &mut config, &app_event_tx, auth_mode) - .await; - if let Some(exit_info) = exit_info { - return Ok(exit_info); - } - let conversation_manager = Arc::new(ConversationManager::new( auth_manager.clone(), SessionSource::Cli, )); + let exit_info = handle_model_migration_prompt_if_needed( + tui, + &mut config, + &app_event_tx, + auth_mode, + conversation_manager.get_models_manager(), + ) + .await; + if let Some(exit_info) = exit_info { + return Ok(exit_info); + } let skills_outcome = load_skills(&config); if !skills_outcome.errors.is_empty() { @@ -305,6 +317,7 @@ impl App { initial_images: initial_images.clone(), enhanced_keys_supported, auth_manager: auth_manager.clone(), + models_manager: conversation_manager.get_models_manager(), feedback: feedback.clone(), skills: skills.clone(), is_first_run, @@ -330,6 +343,7 @@ impl App { initial_images: initial_images.clone(), enhanced_keys_supported, auth_manager: auth_manager.clone(), + models_manager: conversation_manager.get_models_manager(), feedback: feedback.clone(), skills: skills.clone(), is_first_run, @@ -349,7 +363,7 @@ impl App { let upgrade_version = crate::updates::get_upgrade_version(&config); let mut app = Self { - server: conversation_manager, + server: conversation_manager.clone(), app_event_tx, chat_widget, auth_manager: auth_manager.clone(), @@ -486,6 +500,7 @@ impl App { initial_images: Vec::new(), enhanced_keys_supported: self.enhanced_keys_supported, auth_manager: self.auth_manager.clone(), + models_manager: self.server.get_models_manager(), feedback: self.feedback.clone(), skills: self.skills.clone(), is_first_run: false, @@ -534,6 +549,7 @@ impl App { initial_images: Vec::new(), enhanced_keys_supported: self.enhanced_keys_supported, auth_manager: self.auth_manager.clone(), + models_manager: self.server.get_models_manager(), feedback: self.feedback.clone(), skills: self.skills.clone(), is_first_run: false, @@ -1205,28 +1221,41 @@ mod tests { ) } + fn all_model_presets() -> Vec { + codex_core::openai_models::model_presets::all_model_presets().clone() + } + #[test] fn model_migration_prompt_only_shows_for_deprecated_models() { - assert!(should_show_model_migration_prompt("gpt-5", "gpt-5.1", None)); + assert!(should_show_model_migration_prompt( + "gpt-5", + "gpt-5.1", + None, + all_model_presets() + )); assert!(should_show_model_migration_prompt( "gpt-5-codex", "gpt-5.1-codex", - None + None, + all_model_presets() )); assert!(should_show_model_migration_prompt( "gpt-5-codex-mini", "gpt-5.1-codex-mini", - None + None, + all_model_presets() )); assert!(should_show_model_migration_prompt( "gpt-5.1-codex", "gpt-5.1-codex-max", - None + None, + all_model_presets() )); assert!(!should_show_model_migration_prompt( "gpt-5.1-codex", "gpt-5.1-codex", - None + None, + all_model_presets() )); } @@ -1235,10 +1264,14 @@ mod tests { assert!(!should_show_model_migration_prompt( "gpt-5", "gpt-5.1", - Some(true) + Some(true), + all_model_presets() )); assert!(!should_show_model_migration_prompt( - "gpt-5.1", "gpt-5.1", None + "gpt-5.1", + "gpt-5.1", + None, + all_model_presets() )); } diff --git a/codex-rs/tui/src/app_backtrack.rs b/codex-rs/tui/src/app_backtrack.rs index a7c8611614..2f59872bce 100644 --- a/codex-rs/tui/src/app_backtrack.rs +++ b/codex-rs/tui/src/app_backtrack.rs @@ -346,6 +346,7 @@ impl App { initial_images: Vec::new(), enhanced_keys_supported: self.enhanced_keys_supported, auth_manager: self.auth_manager.clone(), + models_manager: self.server.get_models_manager(), feedback: self.feedback.clone(), skills: self.skills.clone(), is_first_run: false, diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index c4059dc852..f956ef5c8a 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -11,6 +11,7 @@ use codex_core::config::Config; use codex_core::config::types::Notifications; use codex_core::git_info::current_branch_name; use codex_core::git_info::local_git_branches; +use codex_core::openai_models::models_manager::ModelsManager; use codex_core::project_doc::DEFAULT_PROJECT_DOC_FILENAME; use codex_core::protocol::AgentMessageDeltaEvent; use codex_core::protocol::AgentMessageEvent; @@ -126,7 +127,6 @@ use codex_common::approval_presets::builtin_approval_presets; use codex_core::AuthManager; use codex_core::CodexAuth; use codex_core::ConversationManager; -use codex_core::openai_models::model_presets::builtin_model_presets; use codex_core::protocol::AskForApproval; use codex_core::protocol::SandboxPolicy; use codex_file_search::FileMatch; @@ -256,6 +256,7 @@ pub(crate) struct ChatWidgetInit { pub(crate) initial_images: Vec, pub(crate) enhanced_keys_supported: bool, pub(crate) auth_manager: Arc, + pub(crate) models_manager: Arc, pub(crate) feedback: codex_feedback::CodexFeedback, pub(crate) skills: Option>, pub(crate) is_first_run: bool, @@ -276,6 +277,7 @@ pub(crate) struct ChatWidget { active_cell: Option>, config: Config, auth_manager: Arc, + models_manager: Arc, session_header: SessionHeader, initial_user_message: Option, token_info: Option, @@ -1232,6 +1234,7 @@ impl ChatWidget { initial_images, enhanced_keys_supported, auth_manager, + models_manager, feedback, skills, is_first_run, @@ -1257,6 +1260,7 @@ impl ChatWidget { active_cell: None, config: config.clone(), auth_manager, + models_manager, session_header: SessionHeader::new(config.model), initial_user_message: create_initial_user_message( initial_prompt.unwrap_or_default(), @@ -1310,6 +1314,7 @@ impl ChatWidget { initial_images, enhanced_keys_supported, auth_manager, + models_manager, feedback, skills, .. @@ -1337,6 +1342,7 @@ impl ChatWidget { active_cell: None, config: config.clone(), auth_manager, + models_manager, session_header: SessionHeader::new(config.model), initial_user_message: create_initial_user_message( initial_prompt.unwrap_or_default(), @@ -2025,10 +2031,11 @@ impl ChatWidget { } fn lower_cost_preset(&self) -> Option { - let auth_mode = self.auth_manager.auth().map(|auth| auth.mode); - builtin_model_presets(auth_mode) - .into_iter() + let models = self.models_manager.available_models.blocking_read(); + models + .iter() .find(|preset| preset.model == NUDGE_MODEL_SLUG) + .cloned() } fn rate_limit_switch_prompt_hidden(&self) -> bool { @@ -2131,8 +2138,13 @@ impl ChatWidget { /// a second popup is shown to choose the reasoning effort. pub(crate) fn open_model_popup(&mut self) { let current_model = self.config.model.clone(); - let auth_mode = self.auth_manager.auth().map(|auth| auth.mode); - let presets: Vec = builtin_model_presets(auth_mode); + let presets: Vec = self + .models_manager + .available_models + .blocking_read() + .iter() + .cloned() + .collect(); let mut items: Vec = Vec::new(); for preset in presets.into_iter() { diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index 699435a71a..419dab2c87 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -354,6 +354,7 @@ async fn helpers_are_available_and_do_not_panic() { initial_images: Vec::new(), enhanced_keys_supported: false, auth_manager, + models_manager: conversation_manager.get_models_manager(), feedback: codex_feedback::CodexFeedback::new(), skills: None, is_first_run: true, @@ -390,7 +391,8 @@ fn make_chatwidget_manual() -> ( bottom_pane: bottom, active_cell: None, config: cfg.clone(), - auth_manager, + auth_manager: auth_manager.clone(), + models_manager: Arc::new(ModelsManager::new(auth_manager.get_auth_mode())), session_header: SessionHeader::new(cfg.model), initial_user_message: None, token_info: None, @@ -425,6 +427,12 @@ fn make_chatwidget_manual() -> ( (widget, rx, op_rx) } +fn set_chatgpt_auth(chat: &mut ChatWidget) { + chat.auth_manager = + AuthManager::from_auth_for_testing(CodexAuth::create_dummy_chatgpt_auth_for_testing()); + chat.models_manager = Arc::new(ModelsManager::new(chat.auth_manager.get_auth_mode())); +} + pub(crate) fn make_chatwidget_manual_with_sender() -> ( ChatWidget, AppEventSender, @@ -881,6 +889,16 @@ fn active_blob(chat: &ChatWidget) -> String { lines_to_single_string(&lines) } +fn get_available_model(chat: &ChatWidget, model: &str) -> ModelPreset { + chat.models_manager + .available_models + .blocking_read() + .iter() + .find(|&preset| preset.model == model) + .cloned() + .unwrap_or_else(|| panic!("{model} preset not found")) +} + #[test] fn empty_enter_during_task_does_not_queue() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(); @@ -1750,13 +1768,11 @@ fn startup_prompts_for_windows_sandbox_when_agent_requested() { fn model_reasoning_selection_popup_snapshot() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(); + set_chatgpt_auth(&mut chat); chat.config.model = "gpt-5.1-codex-max".to_string(); chat.config.model_reasoning_effort = Some(ReasoningEffortConfig::High); - let preset = builtin_model_presets(None) - .into_iter() - .find(|preset| preset.model == "gpt-5.1-codex-max") - .expect("gpt-5.1-codex-max preset"); + let preset = get_available_model(&chat, "gpt-5.1-codex-max"); chat.open_reasoning_popup(preset); let popup = render_bottom_popup(&chat, 80); @@ -1767,13 +1783,11 @@ fn model_reasoning_selection_popup_snapshot() { fn model_reasoning_selection_popup_extra_high_warning_snapshot() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(); + set_chatgpt_auth(&mut chat); chat.config.model = "gpt-5.1-codex-max".to_string(); chat.config.model_reasoning_effort = Some(ReasoningEffortConfig::XHigh); - let preset = builtin_model_presets(None) - .into_iter() - .find(|preset| preset.model == "gpt-5.1-codex-max") - .expect("gpt-5.1-codex-max preset"); + let preset = get_available_model(&chat, "gpt-5.1-codex-max"); chat.open_reasoning_popup(preset); let popup = render_bottom_popup(&chat, 80); @@ -1784,12 +1798,10 @@ fn model_reasoning_selection_popup_extra_high_warning_snapshot() { fn reasoning_popup_shows_extra_high_with_space() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(); + set_chatgpt_auth(&mut chat); chat.config.model = "gpt-5.1-codex-max".to_string(); - let preset = builtin_model_presets(None) - .into_iter() - .find(|preset| preset.model == "gpt-5.1-codex-max") - .expect("gpt-5.1-codex-max preset"); + let preset = get_available_model(&chat, "gpt-5.1-codex-max"); chat.open_reasoning_popup(preset); let popup = render_bottom_popup(&chat, 120); @@ -1872,11 +1884,8 @@ fn reasoning_popup_escape_returns_to_model_popup() { chat.config.model = "gpt-5.1".to_string(); chat.open_model_popup(); - let presets = builtin_model_presets(None) - .into_iter() - .find(|preset| preset.model == "gpt-5.1-codex") - .expect("gpt-5.1-codex preset"); - chat.open_reasoning_popup(presets); + let preset = get_available_model(&chat, "gpt-5.1-codex"); + chat.open_reasoning_popup(preset); let before_escape = render_bottom_popup(&chat, 80); assert!(before_escape.contains("Select Reasoning Level")); From cee37a32b2e77393712864316357903a322b3909 Mon Sep 17 00:00:00 2001 From: Ahmed Ibrahim Date: Wed, 3 Dec 2025 18:49:47 -0800 Subject: [PATCH 22/30] Migrate model family to models manager (#7565) This PR moves `ModelsFamily` to `openai_models`. It also propagates `ModelsManager` to session services and use it to drive model family. We also make `derive_default_model_family` private because it's a step towards what we want: one place that gives model configuration. This is a second step at having one source of truth for models information and config: `ModelsManager`. Next steps would be to remove `ModelsFamily` from config. That's massive because it's being used in 41 occasions mostly pre launching `codex`. Also, we need to make `find_family_for_model` private. It's also big because it's being used in 21 occasions ~ all tests. --- codex-rs/core/src/client.rs | 2 +- codex-rs/core/src/client_common.rs | 6 +-- codex-rs/core/src/codex.rs | 34 +++++++++++------ codex-rs/core/src/codex_delegate.rs | 6 +++ codex-rs/core/src/config/mod.rs | 17 ++++----- codex-rs/core/src/conversation_manager.rs | 20 ++++++++-- codex-rs/core/src/lib.rs | 1 - codex-rs/core/src/openai_model_info.rs | 2 +- codex-rs/core/src/openai_models/mod.rs | 1 + .../src/{ => openai_models}/model_family.rs | 17 +++++---- .../core/src/openai_models/models_manager.rs | 6 +++ codex-rs/core/src/state/service.rs | 2 + codex-rs/core/src/tasks/mod.rs | 5 +++ codex-rs/core/src/tasks/review.rs | 1 + codex-rs/core/src/tools/spec.rs | 37 +++++++------------ codex-rs/core/tests/common/test_codex.rs | 2 - codex-rs/core/tests/suite/client.rs | 5 +-- codex-rs/core/tests/suite/prompt_caching.rs | 3 +- .../core/tests/suite/shell_serialization.rs | 13 +++---- codex-rs/tui/src/app.rs | 11 ++---- 20 files changed, 109 insertions(+), 82 deletions(-) rename codex-rs/core/src/{ => openai_models}/model_family.rs (95%) diff --git a/codex-rs/core/src/client.rs b/codex-rs/core/src/client.rs index f4248f30ab..a3c990cdba 100644 --- a/codex-rs/core/src/client.rs +++ b/codex-rs/core/src/client.rs @@ -46,10 +46,10 @@ use crate::default_client::build_reqwest_client; use crate::error::CodexErr; use crate::error::Result; use crate::flags::CODEX_RS_SSE_FIXTURE; -use crate::model_family::ModelFamily; use crate::model_provider_info::ModelProviderInfo; use crate::model_provider_info::WireApi; use crate::openai_model_info::get_model_info; +use crate::openai_models::model_family::ModelFamily; use crate::tools::spec::create_tools_json_for_chat_completions_api; use crate::tools::spec::create_tools_json_for_responses_api; diff --git a/codex-rs/core/src/client_common.rs b/codex-rs/core/src/client_common.rs index a249ca6fcc..f6152e55d7 100644 --- a/codex-rs/core/src/client_common.rs +++ b/codex-rs/core/src/client_common.rs @@ -1,6 +1,6 @@ use crate::client_common::tools::ToolSpec; use crate::error::Result; -use crate::model_family::ModelFamily; +use crate::openai_models::model_family::ModelFamily; pub use codex_api::common::ResponseEvent; use codex_apply_patch::APPLY_PATCH_TOOL_INSTRUCTIONS; use codex_protocol::models::ResponseItem; @@ -252,7 +252,7 @@ impl Stream for ResponseStream { #[cfg(test)] mod tests { - use crate::model_family::find_family_for_model; + use crate::openai_models::model_family::find_family_for_model; use codex_api::ResponsesApiRequest; use codex_api::common::OpenAiVerbosity; use codex_api::common::TextControls; @@ -309,7 +309,7 @@ mod tests { }, ]; for test_case in test_cases { - let model_family = find_family_for_model(test_case.slug).expect("known model slug"); + let model_family = find_family_for_model(test_case.slug); let expected = if test_case.expects_apply_patch_instructions { format!( "{}\n{}", diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 885a4cdf74..ba7c69eb95 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -14,6 +14,7 @@ use crate::compact_remote::run_inline_remote_auto_compact_task; use crate::features::Feature; use crate::features::Features; use crate::function_tool::FunctionCallError; +use crate::openai_models::models_manager::ModelsManager; use crate::parse_command::parse_command; use crate::parse_turn_item; use crate::response_processing::process_items; @@ -74,7 +75,6 @@ use crate::error::Result as CodexResult; use crate::exec::StreamOutput; use crate::mcp::auth::compute_auth_statuses; use crate::mcp_connection_manager::McpConnectionManager; -use crate::model_family::find_family_for_model; use crate::openai_model_info::get_model_info; use crate::project_doc::get_user_instructions; use crate::protocol::AgentMessageContentDeltaEvent; @@ -163,6 +163,7 @@ impl Codex { pub async fn spawn( config: Config, auth_manager: Arc, + models_manager: Arc, conversation_history: InitialHistory, session_source: SessionSource, ) -> CodexResult { @@ -200,6 +201,7 @@ impl Codex { session_configuration, config.clone(), auth_manager.clone(), + models_manager.clone(), tx_event.clone(), conversation_history, session_source_clone, @@ -394,6 +396,7 @@ pub(crate) struct SessionSettingsUpdate { impl Session { fn make_turn_context( auth_manager: Option>, + models_manager: &ModelsManager, otel_event_manager: &OtelEventManager, provider: ModelProviderInfo, session_configuration: &SessionConfiguration, @@ -402,8 +405,7 @@ impl Session { ) -> TurnContext { let config = session_configuration.original_config_do_not_use.clone(); let features = &config.features; - let model_family = find_family_for_model(&session_configuration.model) - .unwrap_or_else(|| config.model_family.clone()); + let model_family = models_manager.construct_model_family(&session_configuration.model); let mut per_turn_config = (*config).clone(); per_turn_config.model = session_configuration.model.clone(); per_turn_config.model_family = model_family.clone(); @@ -459,6 +461,7 @@ impl Session { session_configuration: SessionConfiguration, config: Arc, auth_manager: Arc, + models_manager: Arc, tx_event: Sender, initial_history: InitialHistory, session_source: SessionSource, @@ -570,6 +573,7 @@ impl Session { show_raw_agent_reasoning: config.show_raw_agent_reasoning, auth_manager: Arc::clone(&auth_manager), otel_event_manager, + models_manager: Arc::clone(&models_manager), tool_approvals: Mutex::new(ApprovalStore::default()), }; @@ -756,6 +760,7 @@ impl Session { let mut turn_context: TurnContext = Self::make_turn_context( Some(Arc::clone(&self.services.auth_manager)), + &self.services.models_manager, &self.services.otel_event_manager, session_configuration.provider.clone(), &session_configuration, @@ -1824,8 +1829,7 @@ async fn spawn_review_thread( resolved: crate::review_prompts::ResolvedReviewRequest, ) { let model = config.review_model.clone(); - let review_model_family = find_family_for_model(&model) - .unwrap_or_else(|| parent_turn_context.client.get_model_family()); + let review_model_family = sess.services.models_manager.construct_model_family(&model); // For reviews, disable web_search and view_image regardless of global settings. let mut review_features = sess.features.clone(); review_features @@ -2083,13 +2087,13 @@ async fn run_turn( let mut base_instructions = turn_context.base_instructions.clone(); if parallel_tool_calls { static INSTRUCTIONS: &str = include_str!("../templates/parallel/instructions.md"); - if let Some(family) = - find_family_for_model(&sess.state.lock().await.session_configuration.model) - { - let mut new_instructions = base_instructions.unwrap_or(family.base_instructions); - new_instructions.push_str(INSTRUCTIONS); - base_instructions = Some(new_instructions); - } + let family = sess + .services + .models_manager + .construct_model_family(&sess.state.lock().await.session_configuration.model); + let mut new_instructions = base_instructions.unwrap_or(family.base_instructions); + new_instructions.push_str(INSTRUCTIONS); + base_instructions = Some(new_instructions); } let prompt = Prompt { input, @@ -2751,6 +2755,7 @@ mod tests { false, config.cli_auth_credentials_store_mode, ); + let models_manager = Arc::new(ModelsManager::new(auth_manager.get_auth_mode())); let session_configuration = SessionConfiguration { provider: config.model_provider.clone(), @@ -2781,11 +2786,13 @@ mod tests { show_raw_agent_reasoning: config.show_raw_agent_reasoning, auth_manager: Arc::clone(&auth_manager), otel_event_manager: otel_event_manager.clone(), + models_manager: models_manager.clone(), tool_approvals: Mutex::new(ApprovalStore::default()), }; let turn_context = Session::make_turn_context( Some(Arc::clone(&auth_manager)), + &models_manager, &otel_event_manager, session_configuration.provider.clone(), &session_configuration, @@ -2829,6 +2836,7 @@ mod tests { false, config.cli_auth_credentials_store_mode, ); + let models_manager = Arc::new(ModelsManager::new(auth_manager.get_auth_mode())); let session_configuration = SessionConfiguration { provider: config.model_provider.clone(), @@ -2859,11 +2867,13 @@ mod tests { show_raw_agent_reasoning: config.show_raw_agent_reasoning, auth_manager: Arc::clone(&auth_manager), otel_event_manager: otel_event_manager.clone(), + models_manager: models_manager.clone(), tool_approvals: Mutex::new(ApprovalStore::default()), }; let turn_context = Arc::new(Session::make_turn_context( Some(Arc::clone(&auth_manager)), + &models_manager, &otel_event_manager, session_configuration.provider.clone(), &session_configuration, diff --git a/codex-rs/core/src/codex_delegate.rs b/codex-rs/core/src/codex_delegate.rs index 796331d1e8..b6e4c88f30 100644 --- a/codex-rs/core/src/codex_delegate.rs +++ b/codex-rs/core/src/codex_delegate.rs @@ -25,6 +25,7 @@ use crate::codex::Session; use crate::codex::TurnContext; use crate::config::Config; use crate::error::CodexErr; +use crate::openai_models::models_manager::ModelsManager; use codex_protocol::protocol::InitialHistory; /// Start an interactive sub-Codex conversation and return IO channels. @@ -35,6 +36,7 @@ use codex_protocol::protocol::InitialHistory; pub(crate) async fn run_codex_conversation_interactive( config: Config, auth_manager: Arc, + models_manager: Arc, parent_session: Arc, parent_ctx: Arc, cancel_token: CancellationToken, @@ -46,6 +48,7 @@ pub(crate) async fn run_codex_conversation_interactive( let CodexSpawnOk { codex, .. } = Codex::spawn( config, auth_manager, + models_manager, initial_history.unwrap_or(InitialHistory::New), SessionSource::SubAgent(SubAgentSource::Review), ) @@ -88,9 +91,11 @@ pub(crate) async fn run_codex_conversation_interactive( /// Convenience wrapper for one-time use with an initial prompt. /// /// Internally calls the interactive variant, then immediately submits the provided input. +#[allow(clippy::too_many_arguments)] pub(crate) async fn run_codex_conversation_one_shot( config: Config, auth_manager: Arc, + models_manager: Arc, input: Vec, parent_session: Arc, parent_ctx: Arc, @@ -103,6 +108,7 @@ pub(crate) async fn run_codex_conversation_one_shot( let io = run_codex_conversation_interactive( config, auth_manager, + models_manager, parent_session, parent_ctx, child_cancel.clone(), diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index dccf0556f1..185584115b 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -22,14 +22,13 @@ use crate::features::FeatureOverrides; use crate::features::Features; use crate::features::FeaturesToml; use crate::git_info::resolve_root_git_project_for_trust; -use crate::model_family::ModelFamily; -use crate::model_family::derive_default_model_family; -use crate::model_family::find_family_for_model; use crate::model_provider_info::LMSTUDIO_OSS_PROVIDER_ID; use crate::model_provider_info::ModelProviderInfo; use crate::model_provider_info::OLLAMA_OSS_PROVIDER_ID; use crate::model_provider_info::built_in_model_providers; use crate::openai_model_info::get_model_info; +use crate::openai_models::model_family::ModelFamily; +use crate::openai_models::model_family::find_family_for_model; use crate::project_doc::DEFAULT_PROJECT_DOC_FILENAME; use crate::project_doc::LOCAL_PROJECT_DOC_FILENAME; use crate::protocol::AskForApproval; @@ -82,6 +81,7 @@ pub struct Config { /// Model used specifically for review sessions. Defaults to "gpt-5.1-codex-max". pub review_model: String, + // todo(aibrahim): remove this field pub model_family: ModelFamily, /// Size of the context window for the model, in tokens. @@ -1109,8 +1109,7 @@ impl Config { .or(cfg.model) .unwrap_or_else(default_model); - let mut model_family = - find_family_for_model(&model).unwrap_or_else(|| derive_default_model_family(&model)); + let mut model_family = find_family_for_model(&model); if let Some(supports_reasoning_summaries) = cfg.model_supports_reasoning_summaries { model_family.supports_reasoning_summaries = supports_reasoning_summaries; @@ -2955,7 +2954,7 @@ model_verbosity = "high" Config { model: "o3".to_string(), review_model: OPENAI_DEFAULT_REVIEW_MODEL.to_string(), - model_family: find_family_for_model("o3").expect("known model slug"), + model_family: find_family_for_model("o3"), model_context_window: Some(200_000), model_auto_compact_token_limit: Some(180_000), model_provider_id: "openai".to_string(), @@ -3029,7 +3028,7 @@ model_verbosity = "high" let expected_gpt3_profile_config = Config { model: "gpt-3.5-turbo".to_string(), review_model: OPENAI_DEFAULT_REVIEW_MODEL.to_string(), - model_family: find_family_for_model("gpt-3.5-turbo").expect("known model slug"), + model_family: find_family_for_model("gpt-3.5-turbo"), model_context_window: Some(16_385), model_auto_compact_token_limit: Some(14_746), model_provider_id: "openai-chat-completions".to_string(), @@ -3118,7 +3117,7 @@ model_verbosity = "high" let expected_zdr_profile_config = Config { model: "o3".to_string(), review_model: OPENAI_DEFAULT_REVIEW_MODEL.to_string(), - model_family: find_family_for_model("o3").expect("known model slug"), + model_family: find_family_for_model("o3"), model_context_window: Some(200_000), model_auto_compact_token_limit: Some(180_000), model_provider_id: "openai".to_string(), @@ -3193,7 +3192,7 @@ model_verbosity = "high" let expected_gpt5_profile_config = Config { model: "gpt-5.1".to_string(), review_model: OPENAI_DEFAULT_REVIEW_MODEL.to_string(), - model_family: find_family_for_model("gpt-5.1").expect("known model slug"), + model_family: find_family_for_model("gpt-5.1"), model_context_window: Some(272_000), model_auto_compact_token_limit: Some(244_800), model_provider_id: "openai".to_string(), diff --git a/codex-rs/core/src/conversation_manager.rs b/codex-rs/core/src/conversation_manager.rs index f41e5b597f..22f73dfe12 100644 --- a/codex-rs/core/src/conversation_manager.rs +++ b/codex-rs/core/src/conversation_manager.rs @@ -65,14 +65,19 @@ impl ConversationManager { } pub async fn new_conversation(&self, config: Config) -> CodexResult { - self.spawn_conversation(config, self.auth_manager.clone()) - .await + self.spawn_conversation( + config, + self.auth_manager.clone(), + self.models_manager.clone(), + ) + .await } async fn spawn_conversation( &self, config: Config, auth_manager: Arc, + models_manager: Arc, ) -> CodexResult { let CodexSpawnOk { codex, @@ -80,6 +85,7 @@ impl ConversationManager { } = Codex::spawn( config, auth_manager, + models_manager, InitialHistory::New, self.session_source.clone(), ) @@ -156,6 +162,7 @@ impl ConversationManager { } = Codex::spawn( config, auth_manager, + self.models_manager.clone(), initial_history, self.session_source.clone(), ) @@ -193,7 +200,14 @@ impl ConversationManager { let CodexSpawnOk { codex, conversation_id, - } = Codex::spawn(config, auth_manager, history, self.session_source.clone()).await?; + } = Codex::spawn( + config, + auth_manager, + self.models_manager.clone(), + history, + self.session_source.clone(), + ) + .await?; self.finalize_spawn(codex, conversation_id).await } diff --git a/codex-rs/core/src/lib.rs b/codex-rs/core/src/lib.rs index d32366476a..39dc224dd8 100644 --- a/codex-rs/core/src/lib.rs +++ b/codex-rs/core/src/lib.rs @@ -67,7 +67,6 @@ pub use conversation_manager::NewConversation; pub use auth::AuthManager; pub use auth::CodexAuth; pub mod default_client; -pub mod model_family; mod openai_model_info; pub mod project_doc; mod rollout; diff --git a/codex-rs/core/src/openai_model_info.rs b/codex-rs/core/src/openai_model_info.rs index 96f3ed77cb..4ee7d7187a 100644 --- a/codex-rs/core/src/openai_model_info.rs +++ b/codex-rs/core/src/openai_model_info.rs @@ -1,4 +1,4 @@ -use crate::model_family::ModelFamily; +use crate::openai_models::model_family::ModelFamily; // Shared constants for commonly used window/token sizes. pub(crate) const CONTEXT_WINDOW_272K: i64 = 272_000; diff --git a/codex-rs/core/src/openai_models/mod.rs b/codex-rs/core/src/openai_models/mod.rs index 13ee2e0605..e7a8beddb1 100644 --- a/codex-rs/core/src/openai_models/mod.rs +++ b/codex-rs/core/src/openai_models/mod.rs @@ -1,2 +1,3 @@ +pub mod model_family; pub mod model_presets; pub mod models_manager; diff --git a/codex-rs/core/src/model_family.rs b/codex-rs/core/src/openai_models/model_family.rs similarity index 95% rename from codex-rs/core/src/model_family.rs rename to codex-rs/core/src/openai_models/model_family.rs index 0417f13b12..1580ab137c 100644 --- a/codex-rs/core/src/model_family.rs +++ b/codex-rs/core/src/openai_models/model_family.rs @@ -8,11 +8,11 @@ use crate::truncate::TruncationPolicy; /// The `instructions` field in the payload sent to a model should always start /// with this content. -const BASE_INSTRUCTIONS: &str = include_str!("../prompt.md"); +const BASE_INSTRUCTIONS: &str = include_str!("../../prompt.md"); -const GPT_5_CODEX_INSTRUCTIONS: &str = include_str!("../gpt_5_codex_prompt.md"); -const GPT_5_1_INSTRUCTIONS: &str = include_str!("../gpt_5_1_prompt.md"); -const GPT_5_1_CODEX_MAX_INSTRUCTIONS: &str = include_str!("../gpt-5.1-codex-max_prompt.md"); +const GPT_5_CODEX_INSTRUCTIONS: &str = include_str!("../../gpt_5_codex_prompt.md"); +const GPT_5_1_INSTRUCTIONS: &str = include_str!("../../gpt_5_1_prompt.md"); +const GPT_5_1_CODEX_MAX_INSTRUCTIONS: &str = include_str!("../../gpt-5.1-codex-max_prompt.md"); /// A model family is a group of models that share certain characteristics. #[derive(Debug, Clone, PartialEq, Eq, Hash)] @@ -100,13 +100,14 @@ macro_rules! model_family { $( mf.$key = $value; )* - Some(mf) + mf }}; } +// todo(aibrahim): remove this function /// Returns a `ModelFamily` for the given model slug, or `None` if the slug /// does not match any known model family. -pub fn find_family_for_model(slug: &str) -> Option { +pub fn find_family_for_model(slug: &str) -> ModelFamily { if slug.starts_with("o3") { model_family!( slug, "o3", @@ -238,11 +239,11 @@ pub fn find_family_for_model(slug: &str) -> Option { truncation_policy: TruncationPolicy::Bytes(10_000), ) } else { - None + derive_default_model_family(slug) } } -pub fn derive_default_model_family(model: &str) -> ModelFamily { +fn derive_default_model_family(model: &str) -> ModelFamily { ModelFamily { slug: model.to_string(), family: model.to_string(), diff --git a/codex-rs/core/src/openai_models/models_manager.rs b/codex-rs/core/src/openai_models/models_manager.rs index 1d57f1e693..c6f9365429 100644 --- a/codex-rs/core/src/openai_models/models_manager.rs +++ b/codex-rs/core/src/openai_models/models_manager.rs @@ -2,6 +2,8 @@ use codex_app_server_protocol::AuthMode; use codex_protocol::openai_models::ModelPreset; use tokio::sync::RwLock; +use crate::openai_models::model_family::ModelFamily; +use crate::openai_models::model_family::find_family_for_model; use crate::openai_models::model_presets::builtin_model_presets; pub struct ModelsManager { @@ -23,4 +25,8 @@ impl ModelsManager { let models = builtin_model_presets(self.auth_mode); *self.available_models.write().await = models; } + + pub fn construct_model_family(&self, model: &str) -> ModelFamily { + find_family_for_model(model) + } } diff --git a/codex-rs/core/src/state/service.rs b/codex-rs/core/src/state/service.rs index 287fb73d25..a35720a9bf 100644 --- a/codex-rs/core/src/state/service.rs +++ b/codex-rs/core/src/state/service.rs @@ -3,6 +3,7 @@ use std::sync::Arc; use crate::AuthManager; use crate::RolloutRecorder; use crate::mcp_connection_manager::McpConnectionManager; +use crate::openai_models::models_manager::ModelsManager; use crate::tools::sandboxing::ApprovalStore; use crate::unified_exec::UnifiedExecSessionManager; use crate::user_notification::UserNotifier; @@ -20,6 +21,7 @@ pub(crate) struct SessionServices { pub(crate) user_shell: crate::shell::Shell, pub(crate) show_raw_agent_reasoning: bool, pub(crate) auth_manager: Arc, + pub(crate) models_manager: Arc, pub(crate) otel_event_manager: OtelEventManager, pub(crate) tool_approvals: Mutex, } diff --git a/codex-rs/core/src/tasks/mod.rs b/codex-rs/core/src/tasks/mod.rs index 9bda02c34e..fa5433ef5e 100644 --- a/codex-rs/core/src/tasks/mod.rs +++ b/codex-rs/core/src/tasks/mod.rs @@ -19,6 +19,7 @@ use tracing::warn; use crate::AuthManager; use crate::codex::Session; use crate::codex::TurnContext; +use crate::openai_models::models_manager::ModelsManager; use crate::protocol::EventMsg; use crate::protocol::TaskCompleteEvent; use crate::protocol::TurnAbortReason; @@ -55,6 +56,10 @@ impl SessionTaskContext { pub(crate) fn auth_manager(&self) -> Arc { Arc::clone(&self.session.services.auth_manager) } + + pub(crate) fn models_manager(&self) -> Arc { + Arc::clone(&self.session.services.models_manager) + } } /// Async task that drives a [`Session`] turn. diff --git a/codex-rs/core/src/tasks/review.rs b/codex-rs/core/src/tasks/review.rs index a6ec840a84..738d33c529 100644 --- a/codex-rs/core/src/tasks/review.rs +++ b/codex-rs/core/src/tasks/review.rs @@ -93,6 +93,7 @@ async fn start_review_conversation( (run_codex_conversation_one_shot( sub_agent_config, session.auth_manager(), + session.models_manager(), input, session.clone_session(), ctx.clone(), diff --git a/codex-rs/core/src/tools/spec.rs b/codex-rs/core/src/tools/spec.rs index d07c605e50..2c3aa2d442 100644 --- a/codex-rs/core/src/tools/spec.rs +++ b/codex-rs/core/src/tools/spec.rs @@ -2,7 +2,7 @@ use crate::client_common::tools::ResponsesApiTool; use crate::client_common::tools::ToolSpec; use crate::features::Feature; use crate::features::Features; -use crate::model_family::ModelFamily; +use crate::openai_models::model_family::ModelFamily; use crate::tools::handlers::PLAN_TOOL; use crate::tools::handlers::apply_patch::ApplyPatchToolType; use crate::tools::handlers::apply_patch::create_apply_patch_freeform_tool; @@ -1118,7 +1118,7 @@ pub(crate) fn build_specs( #[cfg(test)] mod tests { use crate::client_common::tools::FreeformTool; - use crate::model_family::find_family_for_model; + use crate::openai_models::model_family::find_family_for_model; use crate::tools::registry::ConfiguredToolSpec; use mcp_types::ToolInputSchema; use pretty_assertions::assert_eq; @@ -1213,8 +1213,7 @@ mod tests { #[test] fn test_full_toolset_specs_for_gpt5_codex_unified_exec_web_search() { - let model_family = find_family_for_model("gpt-5-codex") - .expect("gpt-5-codex should be a valid model family"); + let model_family = find_family_for_model("gpt-5-codex"); let mut features = Features::with_defaults(); features.enable(Feature::UnifiedExec); features.enable(Feature::WebSearchRequest); @@ -1273,8 +1272,7 @@ mod tests { } fn assert_model_tools(model_family: &str, features: &Features, expected_tools: &[&str]) { - let model_family = find_family_for_model(model_family) - .unwrap_or_else(|| panic!("{model_family} should be a valid model family")); + let model_family = find_family_for_model(model_family); let config = ToolsConfig::new(&ToolsConfigParams { model_family: &model_family, features, @@ -1466,7 +1464,7 @@ mod tests { #[test] fn test_build_specs_default_shell_present() { - let model_family = find_family_for_model("o3").expect("o3 should be a valid model family"); + let model_family = find_family_for_model("o3"); let mut features = Features::with_defaults(); features.enable(Feature::WebSearchRequest); features.enable(Feature::UnifiedExec); @@ -1487,8 +1485,7 @@ mod tests { #[test] #[ignore] fn test_parallel_support_flags() { - let model_family = find_family_for_model("gpt-5-codex") - .expect("codex-mini-latest should be a valid model family"); + let model_family = find_family_for_model("gpt-5-codex"); let mut features = Features::with_defaults(); features.disable(Feature::ViewImageTool); features.enable(Feature::UnifiedExec); @@ -1507,8 +1504,7 @@ mod tests { #[test] fn test_test_model_family_includes_sync_tool() { - let model_family = find_family_for_model("test-gpt-5-codex") - .expect("test-gpt-5-codex should be a valid model family"); + let model_family = find_family_for_model("test-gpt-5-codex"); let mut features = Features::with_defaults(); features.disable(Feature::ViewImageTool); let config = ToolsConfig::new(&ToolsConfigParams { @@ -1537,7 +1533,7 @@ mod tests { #[test] fn test_build_specs_mcp_tools_converted() { - let model_family = find_family_for_model("o3").expect("o3 should be a valid model family"); + let model_family = find_family_for_model("o3"); let mut features = Features::with_defaults(); features.enable(Feature::UnifiedExec); features.enable(Feature::WebSearchRequest); @@ -1631,7 +1627,7 @@ mod tests { #[test] fn test_build_specs_mcp_tools_sorted_by_name() { - let model_family = find_family_for_model("o3").expect("o3 should be a valid model family"); + let model_family = find_family_for_model("o3"); let mut features = Features::with_defaults(); features.enable(Feature::UnifiedExec); let config = ToolsConfig::new(&ToolsConfigParams { @@ -1706,8 +1702,7 @@ mod tests { #[test] fn test_mcp_tool_property_missing_type_defaults_to_string() { - let model_family = find_family_for_model("gpt-5-codex") - .expect("gpt-5-codex should be a valid model family"); + let model_family = find_family_for_model("gpt-5-codex"); let mut features = Features::with_defaults(); features.enable(Feature::UnifiedExec); features.enable(Feature::WebSearchRequest); @@ -1763,8 +1758,7 @@ mod tests { #[test] fn test_mcp_tool_integer_normalized_to_number() { - let model_family = find_family_for_model("gpt-5-codex") - .expect("gpt-5-codex should be a valid model family"); + let model_family = find_family_for_model("gpt-5-codex"); let mut features = Features::with_defaults(); features.enable(Feature::UnifiedExec); features.enable(Feature::WebSearchRequest); @@ -1816,8 +1810,7 @@ mod tests { #[test] fn test_mcp_tool_array_without_items_gets_default_string_items() { - let model_family = find_family_for_model("gpt-5-codex") - .expect("gpt-5-codex should be a valid model family"); + let model_family = find_family_for_model("gpt-5-codex"); let mut features = Features::with_defaults(); features.enable(Feature::UnifiedExec); features.enable(Feature::WebSearchRequest); @@ -1873,8 +1866,7 @@ mod tests { #[test] fn test_mcp_tool_anyof_defaults_to_string() { - let model_family = find_family_for_model("gpt-5-codex") - .expect("gpt-5-codex should be a valid model family"); + let model_family = find_family_for_model("gpt-5-codex"); let mut features = Features::with_defaults(); features.enable(Feature::UnifiedExec); features.enable(Feature::WebSearchRequest); @@ -1985,8 +1977,7 @@ Examples of valid command strings: #[test] fn test_get_openai_tools_mcp_tools_with_additional_properties_schema() { - let model_family = find_family_for_model("gpt-5-codex") - .expect("gpt-5-codex should be a valid model family"); + let model_family = find_family_for_model("gpt-5-codex"); let mut features = Features::with_defaults(); features.enable(Feature::UnifiedExec); features.enable(Feature::WebSearchRequest); diff --git a/codex-rs/core/tests/common/test_codex.rs b/codex-rs/core/tests/common/test_codex.rs index 1c5919214f..aff2ab60df 100644 --- a/codex-rs/core/tests/common/test_codex.rs +++ b/codex-rs/core/tests/common/test_codex.rs @@ -11,7 +11,6 @@ use codex_core::ModelProviderInfo; use codex_core::built_in_model_providers; use codex_core::config::Config; use codex_core::features::Feature; -use codex_core::model_family::find_family_for_model; use codex_core::protocol::AskForApproval; use codex_core::protocol::EventMsg; use codex_core::protocol::Op; @@ -71,7 +70,6 @@ impl TestCodexBuilder { let new_model = model.to_string(); self.with_config(move |config| { config.model = new_model.clone(); - config.model_family = find_family_for_model(&new_model).expect("model family"); }) } diff --git a/codex-rs/core/tests/suite/client.rs b/codex-rs/core/tests/suite/client.rs index e0e06757b5..e5d3d7e61c 100644 --- a/codex-rs/core/tests/suite/client.rs +++ b/codex-rs/core/tests/suite/client.rs @@ -16,7 +16,7 @@ use codex_core::auth::AuthCredentialsStoreMode; use codex_core::built_in_model_providers; use codex_core::error::CodexErr; use codex_core::features::Feature; -use codex_core::model_family::find_family_for_model; +use codex_core::openai_models::model_family::find_family_for_model; use codex_core::protocol::EventMsg; use codex_core::protocol::Op; use codex_core::protocol::SessionSource; @@ -1378,8 +1378,7 @@ async fn context_window_error_sets_total_tokens_to_model_window() -> anyhow::Res let TestCodex { codex, .. } = test_codex() .with_config(|config| { config.model = "gpt-5.1".to_string(); - config.model_family = - find_family_for_model("gpt-5.1").expect("known gpt-5.1 model family"); + config.model_family = find_family_for_model("gpt-5.1"); config.model_context_window = Some(272_000); }) .build(&server) diff --git a/codex-rs/core/tests/suite/prompt_caching.rs b/codex-rs/core/tests/suite/prompt_caching.rs index 0c908e35be..219f29e2fa 100644 --- a/codex-rs/core/tests/suite/prompt_caching.rs +++ b/codex-rs/core/tests/suite/prompt_caching.rs @@ -1,7 +1,7 @@ #![allow(clippy::unwrap_used)] use codex_core::features::Feature; -use codex_core::model_family::find_family_for_model; +use codex_core::openai_models::model_family::find_family_for_model; use codex_core::protocol::AskForApproval; use codex_core::protocol::ENVIRONMENT_CONTEXT_OPEN_TAG; use codex_core::protocol::EventMsg; @@ -74,7 +74,6 @@ async fn codex_mini_latest_tools() -> anyhow::Result<()> { config.features.disable(Feature::ApplyPatchFreeform); config.model = "codex-mini-latest".to_string(); config.model_family = find_family_for_model("codex-mini-latest") - .expect("model family for codex-mini-latest"); }) .build(&server) .await?; diff --git a/codex-rs/core/tests/suite/shell_serialization.rs b/codex-rs/core/tests/suite/shell_serialization.rs index 77c835b320..5dbdda4fcc 100644 --- a/codex-rs/core/tests/suite/shell_serialization.rs +++ b/codex-rs/core/tests/suite/shell_serialization.rs @@ -4,7 +4,7 @@ use anyhow::Result; use codex_core::config::Config; use codex_core::features::Feature; -use codex_core::model_family::find_family_for_model; +use codex_core::openai_models::model_family::find_family_for_model; use codex_core::protocol::SandboxPolicy; use core_test_support::assert_regex_match; use core_test_support::responses::ev_assistant_message; @@ -46,13 +46,12 @@ fn configure_shell_command_model(output_type: ShellModelOutput, config: &mut Con return; } - if let Some(shell_command_family) = find_family_for_model("test-gpt-5-codex") { - if config.model_family.shell_type == shell_command_family.shell_type { - return; - } - config.model = shell_command_family.slug.clone(); - config.model_family = shell_command_family; + let shell_command_family = find_family_for_model("test-gpt-5-codex"); + if config.model_family.shell_type == shell_command_family.shell_type { + return; } + config.model = shell_command_family.slug.clone(); + config.model_family = shell_command_family; } fn shell_responses( diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 2367bbd582..ff5fe9a540 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -26,7 +26,7 @@ use codex_core::ConversationManager; use codex_core::config::Config; use codex_core::config::edit::ConfigEditsBuilder; use codex_core::features::Feature; -use codex_core::model_family::find_family_for_model; +use codex_core::openai_models::model_family::find_family_for_model; use codex_core::openai_models::model_presets::HIDE_GPT_5_1_CODEX_MAX_MIGRATION_PROMPT_CONFIG; use codex_core::openai_models::model_presets::HIDE_GPT5_1_MIGRATION_PROMPT_CONFIG; use codex_core::openai_models::models_manager::ModelsManager; @@ -162,9 +162,7 @@ async fn handle_model_migration_prompt_if_needed( migration_config: migration_config_key.to_string(), }); config.model = target_model.to_string(); - if let Some(family) = find_family_for_model(&target_model) { - config.model_family = family; - } + config.model_family = find_family_for_model(&target_model); let mapped_effort = if let Some(reasoning_effort_mapping) = reasoning_effort_mapping && let Some(reasoning_effort) = config.model_reasoning_effort @@ -683,9 +681,8 @@ impl App { AppEvent::UpdateModel(model) => { self.chat_widget.set_model(&model); self.config.model = model.clone(); - if let Some(family) = find_family_for_model(&model) { - self.config.model_family = family; - } + let family = find_family_for_model(&model); + self.config.model_family = family; } AppEvent::OpenReasoningPopup { model } => { self.chat_widget.open_reasoning_popup(model); From 3e6cd5660cea363c7d3701cb48fcdfde84f07aca Mon Sep 17 00:00:00 2001 From: Celia Chen Date: Wed, 3 Dec 2025 19:08:18 -0800 Subject: [PATCH 23/30] [app-server] make `file_path` for config optional (#7560) When we are writing to config using `config/value/write` or `config/batchWrite`, it always require a `config/read` before it right now in order to get the correct file path to write to. make this optional so we read from the default user config file if this is not passed in. --- .../app-server-protocol/src/protocol/v2.rs | 8 ++- codex-rs/app-server/src/config_api.rs | 55 +++++++++++++++---- .../app-server/tests/suite/v2/config_rpc.rs | 22 +++++++- 3 files changed, 70 insertions(+), 15 deletions(-) diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index f1d8392135..22d53c3dee 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -209,6 +209,8 @@ pub struct OverriddenMetadata { pub struct ConfigWriteResponse { pub status: WriteStatus, pub version: String, + /// Canonical path to the config file that was written. + pub file_path: String, pub overridden_metadata: Option, } @@ -245,10 +247,11 @@ pub struct ConfigReadResponse { #[serde(rename_all = "camelCase")] #[ts(export_to = "v2/")] pub struct ConfigValueWriteParams { - pub file_path: String, pub key_path: String, pub value: JsonValue, pub merge_strategy: MergeStrategy, + /// Path to the config file to write; defaults to the user's `config.toml` when omitted. + pub file_path: Option, pub expected_version: Option, } @@ -256,8 +259,9 @@ pub struct ConfigValueWriteParams { #[serde(rename_all = "camelCase")] #[ts(export_to = "v2/")] pub struct ConfigBatchWriteParams { - pub file_path: String, pub edits: Vec, + /// Path to the config file to write; defaults to the user's `config.toml` when omitted. + pub file_path: Option, pub expected_version: Option, } diff --git a/codex-rs/app-server/src/config_api.rs b/codex-rs/app-server/src/config_api.rs index 68bbdd8c66..ae02927f7a 100644 --- a/codex-rs/app-server/src/config_api.rs +++ b/codex-rs/app-server/src/config_api.rs @@ -109,12 +109,17 @@ impl ConfigApi { async fn apply_edits( &self, - file_path: String, + file_path: Option, expected_version: Option, edits: Vec<(String, JsonValue, MergeStrategy)>, ) -> Result { let allowed_path = self.codex_home.join(CONFIG_FILE_NAME); - if !paths_match(&allowed_path, &file_path) { + let provided_path = file_path + .as_ref() + .map(PathBuf::from) + .unwrap_or_else(|| allowed_path.clone()); + + if !paths_match(&allowed_path, &provided_path) { return Err(config_write_error( ConfigWriteErrorCode::ConfigLayerReadonly, "Only writes to the user config are allowed", @@ -190,9 +195,16 @@ impl ConfigApi { .map(|_| WriteStatus::OkOverridden) .unwrap_or(WriteStatus::Ok); + let file_path = provided_path + .canonicalize() + .unwrap_or(provided_path.clone()) + .display() + .to_string(); + Ok(ConfigWriteResponse { status, version: updated_layers.user.version.clone(), + file_path, overridden_metadata: overridden, }) } @@ -587,15 +599,14 @@ fn canonical_json(value: &JsonValue) -> JsonValue { } } -fn paths_match(expected: &Path, provided: &str) -> bool { - let provided_path = PathBuf::from(provided); +fn paths_match(expected: &Path, provided: &Path) -> bool { if let (Ok(expanded_expected), Ok(expanded_provided)) = - (expected.canonicalize(), provided_path.canonicalize()) + (expected.canonicalize(), provided.canonicalize()) { return expanded_expected == expanded_provided; } - expected == provided_path + expected == provided } fn value_at_path<'a>(root: &'a TomlValue, segments: &[String]) -> Option<&'a TomlValue> { @@ -795,7 +806,7 @@ mod tests { let result = api .write_value(ConfigValueWriteParams { - file_path: tmp.path().join(CONFIG_FILE_NAME).display().to_string(), + file_path: Some(tmp.path().join(CONFIG_FILE_NAME).display().to_string()), key_path: "approval_policy".to_string(), value: json!("never"), merge_strategy: MergeStrategy::Replace, @@ -832,7 +843,7 @@ mod tests { let api = ConfigApi::new(tmp.path().to_path_buf(), vec![]); let error = api .write_value(ConfigValueWriteParams { - file_path: tmp.path().join(CONFIG_FILE_NAME).display().to_string(), + file_path: Some(tmp.path().join(CONFIG_FILE_NAME).display().to_string()), key_path: "model".to_string(), value: json!("gpt-5"), merge_strategy: MergeStrategy::Replace, @@ -852,6 +863,30 @@ mod tests { ); } + #[tokio::test] + async fn write_value_defaults_to_user_config_path() { + let tmp = tempdir().expect("tempdir"); + std::fs::write(tmp.path().join(CONFIG_FILE_NAME), "").unwrap(); + + let api = ConfigApi::new(tmp.path().to_path_buf(), vec![]); + api.write_value(ConfigValueWriteParams { + file_path: None, + key_path: "model".to_string(), + value: json!("gpt-new"), + merge_strategy: MergeStrategy::Replace, + expected_version: None, + }) + .await + .expect("write succeeds"); + + let contents = + std::fs::read_to_string(tmp.path().join(CONFIG_FILE_NAME)).expect("read config"); + assert!( + contents.contains("model = \"gpt-new\""), + "config.toml should be updated even when file_path is omitted" + ); + } + #[tokio::test] async fn invalid_user_value_rejected_even_if_overridden_by_managed() { let tmp = tempdir().expect("tempdir"); @@ -872,7 +907,7 @@ mod tests { let error = api .write_value(ConfigValueWriteParams { - file_path: tmp.path().join(CONFIG_FILE_NAME).display().to_string(), + file_path: Some(tmp.path().join(CONFIG_FILE_NAME).display().to_string()), key_path: "approval_policy".to_string(), value: json!("bogus"), merge_strategy: MergeStrategy::Replace, @@ -957,7 +992,7 @@ mod tests { let result = api .write_value(ConfigValueWriteParams { - file_path: tmp.path().join(CONFIG_FILE_NAME).display().to_string(), + file_path: Some(tmp.path().join(CONFIG_FILE_NAME).display().to_string()), key_path: "approval_policy".to_string(), value: json!("on-request"), merge_strategy: MergeStrategy::Replace, diff --git a/codex-rs/app-server/tests/suite/v2/config_rpc.rs b/codex-rs/app-server/tests/suite/v2/config_rpc.rs index 343a13c3c4..eb3ece64b2 100644 --- a/codex-rs/app-server/tests/suite/v2/config_rpc.rs +++ b/codex-rs/app-server/tests/suite/v2/config_rpc.rs @@ -206,7 +206,7 @@ model = "gpt-old" let write_id = mcp .send_config_value_write_request(ConfigValueWriteParams { - file_path: codex_home.path().join("config.toml").display().to_string(), + file_path: None, key_path: "model".to_string(), value: json!("gpt-new"), merge_strategy: MergeStrategy::Replace, @@ -219,8 +219,16 @@ model = "gpt-old" ) .await??; let write: ConfigWriteResponse = to_response(write_resp)?; + let expected_file_path = codex_home + .path() + .join("config.toml") + .canonicalize() + .unwrap() + .display() + .to_string(); assert_eq!(write.status, WriteStatus::Ok); + assert_eq!(write.file_path, expected_file_path); assert!(write.overridden_metadata.is_none()); let verify_id = mcp @@ -254,7 +262,7 @@ model = "gpt-old" let write_id = mcp .send_config_value_write_request(ConfigValueWriteParams { - file_path: codex_home.path().join("config.toml").display().to_string(), + file_path: Some(codex_home.path().join("config.toml").display().to_string()), key_path: "model".to_string(), value: json!("gpt-new"), merge_strategy: MergeStrategy::Replace, @@ -288,7 +296,7 @@ async fn config_batch_write_applies_multiple_edits() -> Result<()> { let batch_id = mcp .send_config_batch_write_request(ConfigBatchWriteParams { - file_path: codex_home.path().join("config.toml").display().to_string(), + file_path: Some(codex_home.path().join("config.toml").display().to_string()), edits: vec![ ConfigEdit { key_path: "sandbox_mode".to_string(), @@ -314,6 +322,14 @@ async fn config_batch_write_applies_multiple_edits() -> Result<()> { .await??; let batch_write: ConfigWriteResponse = to_response(batch_resp)?; assert_eq!(batch_write.status, WriteStatus::Ok); + let expected_file_path = codex_home + .path() + .join("config.toml") + .canonicalize() + .unwrap() + .display() + .to_string(); + assert_eq!(batch_write.file_path, expected_file_path); let read_id = mcp .send_config_read_request(ConfigReadParams { From edd98dd3b7d1d96f61801382ab4319e5bc36aefa Mon Sep 17 00:00:00 2001 From: Eric Traut Date: Wed, 3 Dec 2025 21:10:54 -0600 Subject: [PATCH 24/30] Remove test from #7481 that doesn't add much value (#7558) Follow-up from PR #7481 --- codex-rs/tui/src/pager_overlay.rs | 43 ------------------------------- 1 file changed, 43 deletions(-) diff --git a/codex-rs/tui/src/pager_overlay.rs b/codex-rs/tui/src/pager_overlay.rs index b5f7b963cc..3b47e9a70e 100644 --- a/codex-rs/tui/src/pager_overlay.rs +++ b/codex-rs/tui/src/pager_overlay.rs @@ -748,49 +748,6 @@ mod tests { assert_snapshot!("transcript_overlay_apply_patch_scroll_vt100", snapshot); } - #[test] - fn transcript_overlay_wraps_long_exec_output_lines() { - let marker = "Z"; - let long_line = marker.repeat(200); - - let mut exec_cell = crate::exec_cell::new_active_exec_command( - "exec-long".into(), - vec!["bash".into(), "-lc".into(), "echo long".into()], - vec![ParsedCommand::Unknown { - cmd: "echo long".into(), - }], - ExecCommandSource::Agent, - None, - false, - ); - exec_cell.complete_call( - "exec-long", - CommandOutput { - exit_code: 0, - aggregated_output: format!("{long_line}\n"), - formatted_output: long_line, - }, - Duration::from_millis(10), - ); - let exec_cell: Arc = Arc::new(exec_cell); - - let mut overlay = TranscriptOverlay::new(vec![exec_cell]); - let area = Rect::new(0, 0, 20, 10); - let mut buf = Buffer::empty(area); - - overlay.render(area, &mut buf); - let rendered = buffer_to_text(&buf, area); - - let wrapped_lines = rendered - .lines() - .filter(|line| line.contains(marker)) - .count(); - assert!( - wrapped_lines >= 2, - "expected long exec output to wrap into multiple lines in transcript overlay, got:\n{rendered}" - ); - } - #[test] fn transcript_overlay_keeps_scroll_pinned_at_bottom() { let mut overlay = TranscriptOverlay::new( From 67e67e054fa70b6963c4b6f03f751e42bfbfba43 Mon Sep 17 00:00:00 2001 From: Ahmed Ibrahim Date: Wed, 3 Dec 2025 20:54:48 -0800 Subject: [PATCH 25/30] Migrate codex max (#7566) - make codex max the default - fix: we were doing some async work in sync function which caused tui to panic --- codex-rs/core/src/config/mod.rs | 5 ++--- .../core/src/openai_models/model_presets.rs | 18 ++--------------- codex-rs/core/tests/suite/list_models.rs | 7 ++++++- codex-rs/tui/src/app.rs | 4 ++-- codex-rs/tui/src/chatwidget.rs | 20 +++++++++++-------- ...twidget__tests__model_selection_popup.snap | 8 +++++--- 6 files changed, 29 insertions(+), 33 deletions(-) diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index 185584115b..def63df845 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -61,9 +61,8 @@ pub mod edit; pub mod profile; pub mod types; -pub const OPENAI_DEFAULT_MODEL: &str = "gpt-5.1-codex"; -const OPENAI_DEFAULT_REVIEW_MODEL: &str = "gpt-5.1-codex"; -pub const GPT_5_CODEX_MEDIUM_MODEL: &str = "gpt-5.1-codex"; +pub const OPENAI_DEFAULT_MODEL: &str = "gpt-5.1-codex-max"; +const OPENAI_DEFAULT_REVIEW_MODEL: &str = "gpt-5.1-codex-max"; /// Maximum number of bytes of the documentation that will be embedded. Larger /// files are *silently truncated* to this size so we do not take up too much of diff --git a/codex-rs/core/src/openai_models/model_presets.rs b/codex-rs/core/src/openai_models/model_presets.rs index 3d46c695cc..6c00465694 100644 --- a/codex-rs/core/src/openai_models/model_presets.rs +++ b/codex-rs/core/src/openai_models/model_presets.rs @@ -209,13 +209,10 @@ static PRESETS: Lazy> = Lazy::new(|| { ] }); -pub(crate) fn builtin_model_presets(auth_mode: Option) -> Vec { +pub(crate) fn builtin_model_presets(_auth_mode: Option) -> Vec { PRESETS .iter() - .filter(|preset| match auth_mode { - Some(AuthMode::ApiKey) => preset.show_in_picker && preset.id != "gpt-5.1-codex-max", - _ => preset.show_in_picker, - }) + .filter(|preset| preset.show_in_picker) .cloned() .collect() } @@ -228,21 +225,10 @@ pub fn all_model_presets() -> &'static Vec { #[cfg(test)] mod tests { use super::*; - use codex_app_server_protocol::AuthMode; #[test] fn only_one_default_model_is_configured() { let default_models = PRESETS.iter().filter(|preset| preset.is_default).count(); assert!(default_models == 1); } - - #[test] - fn gpt_5_1_codex_max_hidden_for_api_key_auth() { - let presets = builtin_model_presets(Some(AuthMode::ApiKey)); - assert!( - presets - .iter() - .all(|preset| preset.id != "gpt-5.1-codex-max") - ); - } } diff --git a/codex-rs/core/tests/suite/list_models.rs b/codex-rs/core/tests/suite/list_models.rs index 9303820163..6348841c6f 100644 --- a/codex-rs/core/tests/suite/list_models.rs +++ b/codex-rs/core/tests/suite/list_models.rs @@ -30,7 +30,12 @@ async fn list_models_returns_chatgpt_models() -> Result<()> { } fn expected_models_for_api_key() -> Vec { - vec![gpt_5_1_codex(), gpt_5_1_codex_mini(), gpt_5_1()] + vec![ + gpt_5_1_codex_max(), + gpt_5_1_codex(), + gpt_5_1_codex_mini(), + gpt_5_1(), + ] } fn expected_models_for_chatgpt() -> Vec { diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index ff5fe9a540..6120c7978d 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -63,7 +63,7 @@ use tokio::sync::mpsc::unbounded_channel; use crate::history_cell::UpdateAvailableHistoryCell; const GPT_5_1_MIGRATION_AUTH_MODES: [AuthMode; 2] = [AuthMode::ChatGPT, AuthMode::ApiKey]; -const GPT_5_1_CODEX_MIGRATION_AUTH_MODES: [AuthMode; 1] = [AuthMode::ChatGPT]; +const GPT_5_1_CODEX_MIGRATION_AUTH_MODES: [AuthMode; 2] = [AuthMode::ChatGPT, AuthMode::ApiKey]; #[derive(Debug, Clone)] pub struct AppExitInfo { @@ -1438,7 +1438,7 @@ mod tests { Some(AuthMode::ChatGPT), HIDE_GPT_5_1_CODEX_MAX_MIGRATION_PROMPT_CONFIG, )); - assert!(!migration_prompt_allows_auth_mode( + assert!(migration_prompt_allows_auth_mode( Some(AuthMode::ApiKey), HIDE_GPT_5_1_CODEX_MAX_MIGRATION_PROMPT_CONFIG, )); diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index f956ef5c8a..c257f7c1bd 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -2031,7 +2031,7 @@ impl ChatWidget { } fn lower_cost_preset(&self) -> Option { - let models = self.models_manager.available_models.blocking_read(); + let models = self.models_manager.available_models.try_read().ok()?; models .iter() .find(|preset| preset.model == NUDGE_MODEL_SLUG) @@ -2138,13 +2138,17 @@ impl ChatWidget { /// a second popup is shown to choose the reasoning effort. pub(crate) fn open_model_popup(&mut self) { let current_model = self.config.model.clone(); - let presets: Vec = self - .models_manager - .available_models - .blocking_read() - .iter() - .cloned() - .collect(); + let presets: Vec = + // todo(aibrahim): make this async function + if let Ok(models) = self.models_manager.available_models.try_read() { + models.clone() + } else { + self.add_info_message( + "Models are being updated; please try /model again in a moment.".to_string(), + None, + ); + return; + }; let mut items: Vec = Vec::new(); for preset in presets.into_iter() { diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__model_selection_popup.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__model_selection_popup.snap index 6cfce48b89..56a209ef73 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__model_selection_popup.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__model_selection_popup.snap @@ -5,9 +5,11 @@ expression: popup Select Model and Effort Access legacy models by running codex -m or in your config.toml -› 1. gpt-5.1-codex Optimized for codex. - 2. gpt-5.1-codex-mini Optimized for codex. Cheaper, faster, but less +› 1. gpt-5.1-codex-max Latest Codex-optimized flagship for deep and fast + reasoning. + 2. gpt-5.1-codex Optimized for codex. + 3. gpt-5.1-codex-mini Optimized for codex. Cheaper, faster, but less capable. - 3. gpt-5.1 Broad world knowledge with strong general reasoning. + 4. gpt-5.1 Broad world knowledge with strong general reasoning. Press enter to select reasoning effort, or esc to dismiss. From ccdeb9d9c4f96c18be9f3e0702e0e00b58ac0887 Mon Sep 17 00:00:00 2001 From: Jeremy Rose <172423086+nornagon-openai@users.noreply.github.com> Date: Wed, 3 Dec 2025 20:58:35 -0800 Subject: [PATCH 26/30] use markdown for rendering tips (#7557) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - render tooltip content through the markdown renderer and prepend a bold Tip label - wrap tooltips at the available width using the indent’s measured width before adding the indent ## Testing - `/root/.cargo/bin/just fmt` - `RUSTFLAGS="--cfg tokio_unstable" TOKIO_UNSTABLE=1 /root/.cargo/bin/just fix -p codex-tui` *(fails: codex-tui tests reference tokio::time::advance/start_paused gated behind the tokio test-util feature)* - `RUSTFLAGS="--cfg tokio_unstable" TOKIO_UNSTABLE=1 cargo test -p codex-tui` *(fails: codex-tui tests reference tokio::time::advance/start_paused gated behind the tokio test-util feature)* ------ [Codex Task](https://chatgpt.com/codex/tasks/task_i_693081406050832c9772ae9fa5dd77ca) --- codex-rs/tui/src/history_cell.rs | 23 ++++++++++++----------- codex-rs/tui/tooltips.txt | 2 +- 2 files changed, 13 insertions(+), 12 deletions(-) diff --git a/codex-rs/tui/src/history_cell.rs b/codex-rs/tui/src/history_cell.rs index c4fd31f548..de8c06488e 100644 --- a/codex-rs/tui/src/history_cell.rs +++ b/codex-rs/tui/src/history_cell.rs @@ -573,18 +573,19 @@ impl TooltipHistoryCell { impl HistoryCell for TooltipHistoryCell { fn display_lines(&self, width: u16) -> Vec> { - let indent: Line<'static> = " ".into(); - let mut lines = Vec::new(); - let tooltip_line: Line<'static> = vec!["Tip: ".cyan(), self.tip.into()].into(); - let wrap_opts = RtOptions::new(usize::from(width.max(1))) - .initial_indent(indent.clone()) - .subsequent_indent(indent.clone()); - lines.extend( - word_wrap_line(&tooltip_line, wrap_opts.clone()) - .into_iter() - .map(|line| line_to_static(&line)), + let indent = " "; + let indent_width = UnicodeWidthStr::width(indent); + let wrap_width = usize::from(width.max(1)) + .saturating_sub(indent_width) + .max(1); + let mut lines: Vec> = Vec::new(); + append_markdown( + &format!("**Tip:** {}", self.tip), + Some(wrap_width), + &mut lines, ); - lines + + prefix_lines(lines, indent.into(), indent.into()) } } diff --git a/codex-rs/tui/tooltips.txt b/codex-rs/tui/tooltips.txt index 09167eb4fc..70c254d293 100644 --- a/codex-rs/tui/tooltips.txt +++ b/codex-rs/tui/tooltips.txt @@ -8,4 +8,4 @@ Type / to open the command popup; Tab autocompletes slash commands and saved pro Use /prompts: key=value to expand a saved prompt with placeholders before sending. With the composer empty, press Esc to step back and edit your last message; Enter confirms. Paste an image with Ctrl+V to attach it to your next message. -You can resume a previous conversation by doing `codex resume` \ No newline at end of file +You can resume a previous conversation by running `codex resume` From e925a380dc960b4c47e5015ad8bdfd6ac25b63ce Mon Sep 17 00:00:00 2001 From: zhao-oai Date: Thu, 4 Dec 2025 02:17:02 -0500 Subject: [PATCH 27/30] whitelist command prefix integration in core and tui (#7033) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit this PR enables TUI to approve commands and add their prefixes to an allowlist: Screenshot 2025-11-21 at 4 18 07 PM note: we only show the option to whitelist the command when 1) command is not multi-part (e.g `git add -A && git commit -m 'hello world'`) 2) command is not already matched by an existing rule --- .../app-server/src/bespoke_event_handling.rs | 1 + codex-rs/core/src/apply_patch.rs | 4 +- codex-rs/core/src/codex.rs | 69 +++- codex-rs/core/src/codex_delegate.rs | 1 + codex-rs/core/src/exec_policy.rs | 377 ++++++++++++++---- codex-rs/core/src/tools/handlers/shell.rs | 8 +- codex-rs/core/src/tools/orchestrator.rs | 28 +- .../core/src/tools/runtimes/apply_patch.rs | 1 + codex-rs/core/src/tools/runtimes/shell.rs | 22 +- .../core/src/tools/runtimes/unified_exec.rs | 29 +- codex-rs/core/src/tools/sandboxing.rs | 42 +- .../core/src/unified_exec/session_manager.rs | 8 +- codex-rs/core/tests/suite/approvals.rs | 149 ++++++- codex-rs/mcp-server/src/codex_tool_runner.rs | 1 + codex-rs/otel/src/otel_event_manager.rs | 4 +- codex-rs/protocol/src/approvals.rs | 4 + codex-rs/protocol/src/protocol.rs | 8 +- .../tui/src/bottom_pane/approval_overlay.rs | 87 +++- codex-rs/tui/src/bottom_pane/mod.rs | 1 + codex-rs/tui/src/chatwidget.rs | 1 + ...hatwidget__tests__approval_modal_exec.snap | 5 +- ..._tests__approval_modal_exec_no_reason.snap | 5 +- ...dget__tests__exec_approval_modal_exec.snap | 6 +- ...sts__status_widget_and_approval_modal.snap | 6 +- codex-rs/tui/src/chatwidget/tests.rs | 6 + codex-rs/tui/src/history_cell.rs | 13 + docs/config.md | 2 +- 27 files changed, 733 insertions(+), 155 deletions(-) diff --git a/codex-rs/app-server/src/bespoke_event_handling.rs b/codex-rs/app-server/src/bespoke_event_handling.rs index df4cdb8980..6d03a9f5db 100644 --- a/codex-rs/app-server/src/bespoke_event_handling.rs +++ b/codex-rs/app-server/src/bespoke_event_handling.rs @@ -179,6 +179,7 @@ pub(crate) async fn apply_bespoke_event_handling( cwd, reason, risk, + allow_prefix: _allow_prefix, parsed_cmd, }) => match api_version { ApiVersion::V1 => { diff --git a/codex-rs/core/src/apply_patch.rs b/codex-rs/core/src/apply_patch.rs index dffe94be61..5cf0ecee70 100644 --- a/codex-rs/core/src/apply_patch.rs +++ b/codex-rs/core/src/apply_patch.rs @@ -70,7 +70,9 @@ pub(crate) async fn apply_patch( ) .await; match rx_approve.await.unwrap_or_default() { - ReviewDecision::Approved | ReviewDecision::ApprovedForSession => { + ReviewDecision::Approved + | ReviewDecision::ApprovedAllowPrefix { .. } + | ReviewDecision::ApprovedForSession => { InternalApplyPatchInvocation::DelegateToExec(ApplyPatchExec { action, user_explicitly_approved_this_action: true, diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index ba7c69eb95..7e0a1d685d 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -73,6 +73,7 @@ use crate::error::CodexErr; use crate::error::Result as CodexResult; #[cfg(test)] use crate::exec::StreamOutput; +use crate::exec_policy::ExecPolicyUpdateError; use crate::mcp::auth::compute_auth_statuses; use crate::mcp_connection_manager::McpConnectionManager; use crate::openai_model_info::get_model_info; @@ -293,7 +294,7 @@ pub(crate) struct TurnContext { pub(crate) final_output_json_schema: Option, pub(crate) codex_linux_sandbox_exe: Option, pub(crate) tool_call_gate: Arc, - pub(crate) exec_policy: Arc, + pub(crate) exec_policy: Arc>, pub(crate) truncation_policy: TruncationPolicy, } @@ -349,7 +350,7 @@ pub(crate) struct SessionConfiguration { cwd: PathBuf, /// Execpolicy policy, applied only when enabled by feature flag. - exec_policy: Arc, + exec_policy: Arc>, // TODO(pakrym): Remove config from here original_config_do_not_use: Arc, @@ -870,11 +871,48 @@ impl Session { .await } + /// Adds a prefix rule to the exec policy + /// + /// This mutates the in-memory execpolicy so the current conversation can use the new + /// prefix and persists the change in default.execpolicy so new conversations will also allow the new prefix. + pub(crate) async fn persist_command_allow_prefix( + &self, + prefix: &[String], + ) -> Result<(), ExecPolicyUpdateError> { + let features = self.features.clone(); + let (codex_home, current_policy) = { + let state = self.state.lock().await; + ( + state + .session_configuration + .original_config_do_not_use + .codex_home + .clone(), + state.session_configuration.exec_policy.clone(), + ) + }; + + if !features.enabled(Feature::ExecPolicy) { + error!("attempted to append execpolicy rule while execpolicy feature is disabled"); + return Err(ExecPolicyUpdateError::FeatureDisabled); + } + + crate::exec_policy::append_allow_prefix_rule_and_update( + &codex_home, + ¤t_policy, + prefix, + ) + .await?; + + Ok(()) + } + /// Emit an exec approval request event and await the user's decision. /// /// The request is keyed by `sub_id`/`call_id` so matching responses are delivered /// to the correct in-flight turn. If the task is aborted, this returns the /// default `ReviewDecision` (`Denied`). + #[allow(clippy::too_many_arguments)] pub async fn request_command_approval( &self, turn_context: &TurnContext, @@ -883,6 +921,7 @@ impl Session { cwd: PathBuf, reason: Option, risk: Option, + allow_prefix: Option>, ) -> ReviewDecision { let sub_id = turn_context.sub_id.clone(); // Add the tx_approve callback to the map before sending the request. @@ -910,6 +949,7 @@ impl Session { cwd, reason, risk, + allow_prefix, parsed_cmd, }); self.send_event(turn_context, event).await; @@ -1079,6 +1119,10 @@ impl Session { self.features.enabled(feature) } + pub(crate) fn features(&self) -> Features { + self.features.clone() + } + async fn send_raw_response_items(&self, turn_context: &TurnContext, items: &[ResponseItem]) { for item in items { self.send_event( @@ -1513,6 +1557,7 @@ mod handlers { use codex_protocol::protocol::ReviewDecision; use codex_protocol::protocol::ReviewRequest; use codex_protocol::protocol::TurnAbortReason; + use codex_protocol::protocol::WarningEvent; use codex_protocol::user_input::UserInput; use codex_rmcp_client::ElicitationAction; @@ -1627,7 +1672,21 @@ mod handlers { } } + /// Propagate a user's exec approval decision to the session + /// Also optionally whitelists command in execpolicy pub async fn exec_approval(sess: &Arc, id: String, decision: ReviewDecision) { + if let ReviewDecision::ApprovedAllowPrefix { allow_prefix } = &decision + && let Err(err) = sess.persist_command_allow_prefix(allow_prefix).await + { + let message = format!("Failed to update execpolicy allow list: {err}"); + tracing::warn!("{message}"); + let warning = EventMsg::Warning(WarningEvent { message }); + sess.send_event_raw(Event { + id: id.clone(), + msg: warning, + }) + .await; + } match decision { ReviewDecision::Abort => { sess.interrupt_task().await; @@ -2571,7 +2630,7 @@ mod tests { sandbox_policy: config.sandbox_policy.clone(), cwd: config.cwd.clone(), original_config_do_not_use: Arc::clone(&config), - exec_policy: Arc::new(ExecPolicy::empty()), + exec_policy: Arc::new(RwLock::new(ExecPolicy::empty())), session_source: SessionSource::Exec, }; @@ -2770,7 +2829,7 @@ mod tests { sandbox_policy: config.sandbox_policy.clone(), cwd: config.cwd.clone(), original_config_do_not_use: Arc::clone(&config), - exec_policy: Arc::new(ExecPolicy::empty()), + exec_policy: Arc::new(RwLock::new(ExecPolicy::empty())), session_source: SessionSource::Exec, }; @@ -2851,7 +2910,7 @@ mod tests { sandbox_policy: config.sandbox_policy.clone(), cwd: config.cwd.clone(), original_config_do_not_use: Arc::clone(&config), - exec_policy: Arc::new(ExecPolicy::empty()), + exec_policy: Arc::new(RwLock::new(ExecPolicy::empty())), session_source: SessionSource::Exec, }; diff --git a/codex-rs/core/src/codex_delegate.rs b/codex-rs/core/src/codex_delegate.rs index b6e4c88f30..1606855669 100644 --- a/codex-rs/core/src/codex_delegate.rs +++ b/codex-rs/core/src/codex_delegate.rs @@ -281,6 +281,7 @@ async fn handle_exec_approval( event.cwd, event.reason, event.risk, + event.allow_prefix, ); let decision = await_approval_with_cancel( approval_fut, diff --git a/codex-rs/core/src/exec_policy.rs b/codex-rs/core/src/exec_policy.rs index 602e5d679e..7c96deea90 100644 --- a/codex-rs/core/src/exec_policy.rs +++ b/codex-rs/core/src/exec_policy.rs @@ -4,25 +4,31 @@ use std::path::PathBuf; use std::sync::Arc; use crate::command_safety::is_dangerous_command::requires_initial_appoval; +use codex_execpolicy::AmendError; use codex_execpolicy::Decision; +use codex_execpolicy::Error as ExecPolicyRuleError; use codex_execpolicy::Evaluation; use codex_execpolicy::Policy; use codex_execpolicy::PolicyParser; +use codex_execpolicy::blocking_append_allow_prefix_rule; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::SandboxPolicy; use thiserror::Error; use tokio::fs; +use tokio::sync::RwLock; +use tokio::task::spawn_blocking; use crate::bash::parse_shell_lc_plain_commands; use crate::features::Feature; use crate::features::Features; use crate::sandboxing::SandboxPermissions; -use crate::tools::sandboxing::ApprovalRequirement; +use crate::tools::sandboxing::ExecApprovalRequirement; const FORBIDDEN_REASON: &str = "execpolicy forbids this command"; const PROMPT_REASON: &str = "execpolicy requires approval for this command"; const POLICY_DIR_NAME: &str = "policy"; const POLICY_EXTENSION: &str = "codexpolicy"; +const DEFAULT_POLICY_FILE: &str = "default.codexpolicy"; #[derive(Debug, Error)] pub enum ExecPolicyError { @@ -45,12 +51,30 @@ pub enum ExecPolicyError { }, } +#[derive(Debug, Error)] +pub enum ExecPolicyUpdateError { + #[error("failed to update execpolicy file {path}: {source}")] + AppendRule { path: PathBuf, source: AmendError }, + + #[error("failed to join blocking execpolicy update task: {source}")] + JoinBlockingTask { source: tokio::task::JoinError }, + + #[error("failed to update in-memory execpolicy: {source}")] + AddRule { + #[from] + source: ExecPolicyRuleError, + }, + + #[error("cannot append execpolicy rule because execpolicy feature is disabled")] + FeatureDisabled, +} + pub(crate) async fn exec_policy_for( features: &Features, codex_home: &Path, -) -> Result, ExecPolicyError> { +) -> Result>, ExecPolicyError> { if !features.enabled(Feature::ExecPolicy) { - return Ok(Arc::new(Policy::empty())); + return Ok(Arc::new(RwLock::new(Policy::empty()))); } let policy_dir = codex_home.join(POLICY_DIR_NAME); @@ -74,7 +98,7 @@ pub(crate) async fn exec_policy_for( })?; } - let policy = Arc::new(parser.build()); + let policy = Arc::new(RwLock::new(parser.build())); tracing::debug!( "loaded execpolicy from {} files in {}", policy_paths.len(), @@ -84,58 +108,107 @@ pub(crate) async fn exec_policy_for( Ok(policy) } -fn evaluate_with_policy( - policy: &Policy, - command: &[String], - approval_policy: AskForApproval, -) -> Option { - let commands = parse_shell_lc_plain_commands(command).unwrap_or_else(|| vec![command.to_vec()]); - let evaluation = policy.check_multiple(commands.iter()); +pub(crate) fn default_policy_path(codex_home: &Path) -> PathBuf { + codex_home.join(POLICY_DIR_NAME).join(DEFAULT_POLICY_FILE) +} - match evaluation { - Evaluation::Match { decision, .. } => match decision { - Decision::Forbidden => Some(ApprovalRequirement::Forbidden { - reason: FORBIDDEN_REASON.to_string(), - }), - Decision::Prompt => { - let reason = PROMPT_REASON.to_string(); - if matches!(approval_policy, AskForApproval::Never) { - Some(ApprovalRequirement::Forbidden { reason }) - } else { - Some(ApprovalRequirement::NeedsApproval { - reason: Some(reason), - }) +pub(crate) async fn append_allow_prefix_rule_and_update( + codex_home: &Path, + current_policy: &Arc>, + prefix: &[String], +) -> Result<(), ExecPolicyUpdateError> { + let policy_path = default_policy_path(codex_home); + let prefix = prefix.to_vec(); + spawn_blocking({ + let policy_path = policy_path.clone(); + let prefix = prefix.clone(); + move || blocking_append_allow_prefix_rule(&policy_path, &prefix) + }) + .await + .map_err(|source| ExecPolicyUpdateError::JoinBlockingTask { source })? + .map_err(|source| ExecPolicyUpdateError::AppendRule { + path: policy_path, + source, + })?; + + current_policy + .write() + .await + .add_prefix_rule(&prefix, Decision::Allow)?; + + Ok(()) +} + +fn requirement_from_decision( + decision: Decision, + approval_policy: AskForApproval, +) -> ExecApprovalRequirement { + match decision { + Decision::Forbidden => ExecApprovalRequirement::Forbidden { + reason: FORBIDDEN_REASON.to_string(), + }, + Decision::Prompt => { + let reason = PROMPT_REASON.to_string(); + if matches!(approval_policy, AskForApproval::Never) { + ExecApprovalRequirement::Forbidden { reason } + } else { + ExecApprovalRequirement::NeedsApproval { + reason: Some(reason), + allow_prefix: None, } } - Decision::Allow => Some(ApprovalRequirement::Skip { - bypass_sandbox: true, - }), + } + Decision::Allow => ExecApprovalRequirement::Skip { + bypass_sandbox: true, }, - Evaluation::NoMatch { .. } => None, } } -pub(crate) async fn create_approval_requirement_for_command( - policy: &Policy, +/// Return an allow-prefix option when a single plain command needs approval without +/// any matching policy rule. We only surface the prefix opt-in when execpolicy did +/// not already drive the decision (NoMatch) and when the command is a single +/// unrolled command (multi-part scripts shouldn’t be whitelisted via prefix) and +/// when execpolicy feature is enabled. +fn allow_prefix_if_applicable( + commands: &[Vec], + features: &Features, +) -> Option> { + if features.enabled(Feature::ExecPolicy) && commands.len() == 1 { + Some(commands[0].clone()) + } else { + None + } +} + +pub(crate) async fn create_exec_approval_requirement_for_command( + exec_policy: &Arc>, + features: &Features, command: &[String], approval_policy: AskForApproval, sandbox_policy: &SandboxPolicy, sandbox_permissions: SandboxPermissions, -) -> ApprovalRequirement { - if let Some(requirement) = evaluate_with_policy(policy, command, approval_policy) { - return requirement; - } +) -> ExecApprovalRequirement { + let commands = parse_shell_lc_plain_commands(command).unwrap_or_else(|| vec![command.to_vec()]); + let evaluation = exec_policy.read().await.check_multiple(commands.iter()); - if requires_initial_appoval( - approval_policy, - sandbox_policy, - command, - sandbox_permissions, - ) { - ApprovalRequirement::NeedsApproval { reason: None } - } else { - ApprovalRequirement::Skip { - bypass_sandbox: false, + match evaluation { + Evaluation::Match { decision, .. } => requirement_from_decision(decision, approval_policy), + Evaluation::NoMatch { .. } => { + if requires_initial_appoval( + approval_policy, + sandbox_policy, + command, + sandbox_permissions, + ) { + ExecApprovalRequirement::NeedsApproval { + reason: None, + allow_prefix: allow_prefix_if_applicable(&commands, features), + } + } else { + ExecApprovalRequirement::Skip { + bypass_sandbox: false, + } + } } } } @@ -195,6 +268,7 @@ mod tests { use codex_protocol::protocol::SandboxPolicy; use pretty_assertions::assert_eq; use std::fs; + use std::sync::Arc; use tempfile::tempdir; #[tokio::test] @@ -209,7 +283,7 @@ mod tests { let commands = [vec!["rm".to_string()]]; assert!(matches!( - policy.check_multiple(commands.iter()), + policy.read().await.check_multiple(commands.iter()), Evaluation::NoMatch { .. } )); assert!(!temp_dir.path().join(POLICY_DIR_NAME).exists()); @@ -243,7 +317,7 @@ mod tests { .expect("policy result"); let command = [vec!["rm".to_string()]]; assert!(matches!( - policy.check_multiple(command.iter()), + policy.read().await.check_multiple(command.iter()), Evaluation::Match { .. } )); } @@ -262,13 +336,13 @@ mod tests { .expect("policy result"); let command = [vec!["ls".to_string()]]; assert!(matches!( - policy.check_multiple(command.iter()), + policy.read().await.check_multiple(command.iter()), Evaluation::NoMatch { .. } )); } - #[test] - fn evaluates_bash_lc_inner_commands() { + #[tokio::test] + async fn evaluates_bash_lc_inner_commands() { let policy_src = r#" prefix_rule(pattern=["rm"], decision="forbidden") "#; @@ -276,7 +350,7 @@ prefix_rule(pattern=["rm"], decision="forbidden") parser .parse("test.codexpolicy", policy_src) .expect("parse policy"); - let policy = parser.build(); + let policy = Arc::new(RwLock::new(parser.build())); let forbidden_script = vec![ "bash".to_string(), @@ -284,30 +358,37 @@ prefix_rule(pattern=["rm"], decision="forbidden") "rm -rf /tmp".to_string(), ]; - let requirement = - evaluate_with_policy(&policy, &forbidden_script, AskForApproval::OnRequest) - .expect("expected match for forbidden command"); + let requirement = create_exec_approval_requirement_for_command( + &policy, + &Features::with_defaults(), + &forbidden_script, + AskForApproval::OnRequest, + &SandboxPolicy::DangerFullAccess, + SandboxPermissions::UseDefault, + ) + .await; assert_eq!( requirement, - ApprovalRequirement::Forbidden { + ExecApprovalRequirement::Forbidden { reason: FORBIDDEN_REASON.to_string() } ); } #[tokio::test] - async fn approval_requirement_prefers_execpolicy_match() { + async fn exec_approval_requirement_prefers_execpolicy_match() { let policy_src = r#"prefix_rule(pattern=["rm"], decision="prompt")"#; let mut parser = PolicyParser::new(); parser .parse("test.codexpolicy", policy_src) .expect("parse policy"); - let policy = parser.build(); + let policy = Arc::new(RwLock::new(parser.build())); let command = vec!["rm".to_string()]; - let requirement = create_approval_requirement_for_command( + let requirement = create_exec_approval_requirement_for_command( &policy, + &Features::with_defaults(), &command, AskForApproval::OnRequest, &SandboxPolicy::DangerFullAccess, @@ -317,24 +398,26 @@ prefix_rule(pattern=["rm"], decision="forbidden") assert_eq!( requirement, - ApprovalRequirement::NeedsApproval { - reason: Some(PROMPT_REASON.to_string()) + ExecApprovalRequirement::NeedsApproval { + reason: Some(PROMPT_REASON.to_string()), + allow_prefix: None, } ); } #[tokio::test] - async fn approval_requirement_respects_approval_policy() { + async fn exec_approval_requirement_respects_approval_policy() { let policy_src = r#"prefix_rule(pattern=["rm"], decision="prompt")"#; let mut parser = PolicyParser::new(); parser .parse("test.codexpolicy", policy_src) .expect("parse policy"); - let policy = parser.build(); + let policy = Arc::new(RwLock::new(parser.build())); let command = vec!["rm".to_string()]; - let requirement = create_approval_requirement_for_command( + let requirement = create_exec_approval_requirement_for_command( &policy, + &Features::with_defaults(), &command, AskForApproval::Never, &SandboxPolicy::DangerFullAccess, @@ -344,19 +427,20 @@ prefix_rule(pattern=["rm"], decision="forbidden") assert_eq!( requirement, - ApprovalRequirement::Forbidden { + ExecApprovalRequirement::Forbidden { reason: PROMPT_REASON.to_string() } ); } #[tokio::test] - async fn approval_requirement_falls_back_to_heuristics() { - let command = vec!["python".to_string()]; + async fn exec_approval_requirement_falls_back_to_heuristics() { + let command = vec!["cargo".to_string(), "build".to_string()]; - let empty_policy = Policy::empty(); - let requirement = create_approval_requirement_for_command( + let empty_policy = Arc::new(RwLock::new(Policy::empty())); + let requirement = create_exec_approval_requirement_for_command( &empty_policy, + &Features::with_defaults(), &command, AskForApproval::UnlessTrusted, &SandboxPolicy::ReadOnly, @@ -366,7 +450,164 @@ prefix_rule(pattern=["rm"], decision="forbidden") assert_eq!( requirement, - ApprovalRequirement::NeedsApproval { reason: None } + ExecApprovalRequirement::NeedsApproval { + reason: None, + allow_prefix: Some(command) + } + ); + } + + #[tokio::test] + async fn append_allow_prefix_rule_updates_policy_and_file() { + let codex_home = tempdir().expect("create temp dir"); + let current_policy = Arc::new(RwLock::new(Policy::empty())); + let prefix = vec!["echo".to_string(), "hello".to_string()]; + + append_allow_prefix_rule_and_update(codex_home.path(), ¤t_policy, &prefix) + .await + .expect("update policy"); + + let evaluation = current_policy.read().await.check(&[ + "echo".to_string(), + "hello".to_string(), + "world".to_string(), + ]); + assert!(matches!( + evaluation, + Evaluation::Match { + decision: Decision::Allow, + .. + } + )); + + let contents = fs::read_to_string(default_policy_path(codex_home.path())) + .expect("policy file should have been created"); + assert_eq!( + contents, + r#"prefix_rule(pattern=["echo", "hello"], decision="allow") +"# + ); + } + + #[tokio::test] + async fn append_allow_prefix_rule_rejects_empty_prefix() { + let codex_home = tempdir().expect("create temp dir"); + let current_policy = Arc::new(RwLock::new(Policy::empty())); + + let result = + append_allow_prefix_rule_and_update(codex_home.path(), ¤t_policy, &[]).await; + + assert!(matches!( + result, + Err(ExecPolicyUpdateError::AppendRule { + source: AmendError::EmptyPrefix, + .. + }) + )); + } + + #[tokio::test] + async fn allow_prefix_is_present_for_single_command_without_policy_match() { + let command = vec!["cargo".to_string(), "build".to_string()]; + + let empty_policy = Arc::new(RwLock::new(Policy::empty())); + let requirement = create_exec_approval_requirement_for_command( + &empty_policy, + &Features::with_defaults(), + &command, + AskForApproval::UnlessTrusted, + &SandboxPolicy::ReadOnly, + SandboxPermissions::UseDefault, + ) + .await; + + assert_eq!( + requirement, + ExecApprovalRequirement::NeedsApproval { + reason: None, + allow_prefix: Some(command) + } + ); + } + + #[tokio::test] + async fn allow_prefix_is_disabled_when_execpolicy_feature_disabled() { + let command = vec!["cargo".to_string(), "build".to_string()]; + + let mut features = Features::with_defaults(); + features.disable(Feature::ExecPolicy); + + let requirement = create_exec_approval_requirement_for_command( + &Arc::new(RwLock::new(Policy::empty())), + &features, + &command, + AskForApproval::UnlessTrusted, + &SandboxPolicy::ReadOnly, + SandboxPermissions::UseDefault, + ) + .await; + + assert_eq!( + requirement, + ExecApprovalRequirement::NeedsApproval { + reason: None, + allow_prefix: None, + } + ); + } + + #[tokio::test] + async fn allow_prefix_is_omitted_when_policy_prompts() { + let policy_src = r#"prefix_rule(pattern=["rm"], decision="prompt")"#; + let mut parser = PolicyParser::new(); + parser + .parse("test.codexpolicy", policy_src) + .expect("parse policy"); + let policy = Arc::new(RwLock::new(parser.build())); + let command = vec!["rm".to_string()]; + + let requirement = create_exec_approval_requirement_for_command( + &policy, + &Features::with_defaults(), + &command, + AskForApproval::OnRequest, + &SandboxPolicy::DangerFullAccess, + SandboxPermissions::UseDefault, + ) + .await; + + assert_eq!( + requirement, + ExecApprovalRequirement::NeedsApproval { + reason: Some(PROMPT_REASON.to_string()), + allow_prefix: None, + } + ); + } + + #[tokio::test] + async fn allow_prefix_is_omitted_for_multi_command_scripts() { + let command = vec![ + "bash".to_string(), + "-lc".to_string(), + "cargo build && echo ok".to_string(), + ]; + let requirement = create_exec_approval_requirement_for_command( + &Arc::new(RwLock::new(Policy::empty())), + &Features::with_defaults(), + &command, + AskForApproval::UnlessTrusted, + &SandboxPolicy::ReadOnly, + SandboxPermissions::UseDefault, + ) + .await; + + assert_eq!( + requirement, + ExecApprovalRequirement::NeedsApproval { + reason: None, + allow_prefix: None, + } ); } } diff --git a/codex-rs/core/src/tools/handlers/shell.rs b/codex-rs/core/src/tools/handlers/shell.rs index d1b7d3144c..cd05d126bf 100644 --- a/codex-rs/core/src/tools/handlers/shell.rs +++ b/codex-rs/core/src/tools/handlers/shell.rs @@ -6,7 +6,7 @@ use std::sync::Arc; use crate::codex::TurnContext; use crate::exec::ExecParams; use crate::exec_env::create_env; -use crate::exec_policy::create_approval_requirement_for_command; +use crate::exec_policy::create_exec_approval_requirement_for_command; use crate::function_tool::FunctionCallError; use crate::is_safe_command::is_known_safe_command; use crate::protocol::ExecCommandSource; @@ -231,8 +231,10 @@ impl ShellHandler { let event_ctx = ToolEventCtx::new(session.as_ref(), turn.as_ref(), &call_id, None); emitter.begin(event_ctx).await; - let approval_requirement = create_approval_requirement_for_command( + let features = session.features(); + let exec_approval_requirement = create_exec_approval_requirement_for_command( &turn.exec_policy, + &features, &exec_params.command, turn.approval_policy, &turn.sandbox_policy, @@ -247,7 +249,7 @@ impl ShellHandler { env: exec_params.env.clone(), with_escalated_permissions: exec_params.with_escalated_permissions, justification: exec_params.justification.clone(), - approval_requirement, + exec_approval_requirement, }; let mut orchestrator = ToolOrchestrator::new(); let mut runtime = ShellRuntime::new(); diff --git a/codex-rs/core/src/tools/orchestrator.rs b/codex-rs/core/src/tools/orchestrator.rs index de23d510bf..5ac3c63509 100644 --- a/codex-rs/core/src/tools/orchestrator.rs +++ b/codex-rs/core/src/tools/orchestrator.rs @@ -11,14 +11,14 @@ use crate::error::get_error_message_ui; use crate::exec::ExecToolCallOutput; use crate::sandboxing::SandboxManager; use crate::tools::sandboxing::ApprovalCtx; -use crate::tools::sandboxing::ApprovalRequirement; +use crate::tools::sandboxing::ExecApprovalRequirement; use crate::tools::sandboxing::ProvidesSandboxRetryData; use crate::tools::sandboxing::SandboxAttempt; use crate::tools::sandboxing::SandboxOverride; use crate::tools::sandboxing::ToolCtx; use crate::tools::sandboxing::ToolError; use crate::tools::sandboxing::ToolRuntime; -use crate::tools::sandboxing::default_approval_requirement; +use crate::tools::sandboxing::default_exec_approval_requirement; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::ReviewDecision; @@ -54,17 +54,17 @@ impl ToolOrchestrator { // 1) Approval let mut already_approved = false; - let requirement = tool.approval_requirement(req).unwrap_or_else(|| { - default_approval_requirement(approval_policy, &turn_ctx.sandbox_policy) + let requirement = tool.exec_approval_requirement(req).unwrap_or_else(|| { + default_exec_approval_requirement(approval_policy, &turn_ctx.sandbox_policy) }); match requirement { - ApprovalRequirement::Skip { .. } => { - otel.tool_decision(otel_tn, otel_ci, ReviewDecision::Approved, otel_cfg); + ExecApprovalRequirement::Skip { .. } => { + otel.tool_decision(otel_tn, otel_ci, &ReviewDecision::Approved, otel_cfg); } - ApprovalRequirement::Forbidden { reason } => { + ExecApprovalRequirement::Forbidden { reason } => { return Err(ToolError::Rejected(reason)); } - ApprovalRequirement::NeedsApproval { reason } => { + ExecApprovalRequirement::NeedsApproval { reason, .. } => { let mut risk = None; if let Some(metadata) = req.sandbox_retry_data() { @@ -88,13 +88,15 @@ impl ToolOrchestrator { }; let decision = tool.start_approval_async(req, approval_ctx).await; - otel.tool_decision(otel_tn, otel_ci, decision, otel_user.clone()); + otel.tool_decision(otel_tn, otel_ci, &decision, otel_user.clone()); match decision { ReviewDecision::Denied | ReviewDecision::Abort => { return Err(ToolError::Rejected("rejected by user".to_string())); } - ReviewDecision::Approved | ReviewDecision::ApprovedForSession => {} + ReviewDecision::Approved + | ReviewDecision::ApprovedAllowPrefix { .. } + | ReviewDecision::ApprovedForSession => {} } already_approved = true; } @@ -169,13 +171,15 @@ impl ToolOrchestrator { }; let decision = tool.start_approval_async(req, approval_ctx).await; - otel.tool_decision(otel_tn, otel_ci, decision, otel_user); + otel.tool_decision(otel_tn, otel_ci, &decision, otel_user); match decision { ReviewDecision::Denied | ReviewDecision::Abort => { return Err(ToolError::Rejected("rejected by user".to_string())); } - ReviewDecision::Approved | ReviewDecision::ApprovedForSession => {} + ReviewDecision::Approved + | ReviewDecision::ApprovedAllowPrefix { .. } + | ReviewDecision::ApprovedForSession => {} } } diff --git a/codex-rs/core/src/tools/runtimes/apply_patch.rs b/codex-rs/core/src/tools/runtimes/apply_patch.rs index 2334f1e712..7ef8d33767 100644 --- a/codex-rs/core/src/tools/runtimes/apply_patch.rs +++ b/codex-rs/core/src/tools/runtimes/apply_patch.rs @@ -127,6 +127,7 @@ impl Approvable for ApplyPatchRuntime { cwd, Some(reason), risk, + None, ) .await } else if user_explicitly_approved { diff --git a/codex-rs/core/src/tools/runtimes/shell.rs b/codex-rs/core/src/tools/runtimes/shell.rs index 56c72a8278..48dc5b9990 100644 --- a/codex-rs/core/src/tools/runtimes/shell.rs +++ b/codex-rs/core/src/tools/runtimes/shell.rs @@ -9,7 +9,7 @@ use crate::sandboxing::execute_env; use crate::tools::runtimes::build_command_spec; use crate::tools::sandboxing::Approvable; use crate::tools::sandboxing::ApprovalCtx; -use crate::tools::sandboxing::ApprovalRequirement; +use crate::tools::sandboxing::ExecApprovalRequirement; use crate::tools::sandboxing::ProvidesSandboxRetryData; use crate::tools::sandboxing::SandboxAttempt; use crate::tools::sandboxing::SandboxOverride; @@ -32,7 +32,7 @@ pub struct ShellRequest { pub env: std::collections::HashMap, pub with_escalated_permissions: Option, pub justification: Option, - pub approval_requirement: ApprovalRequirement, + pub exec_approval_requirement: ExecApprovalRequirement, } impl ProvidesSandboxRetryData for ShellRequest { @@ -107,22 +107,30 @@ impl Approvable for ShellRuntime { Box::pin(async move { with_cached_approval(&session.services, key, move || async move { session - .request_command_approval(turn, call_id, command, cwd, reason, risk) + .request_command_approval( + turn, + call_id, + command, + cwd, + reason, + risk, + req.exec_approval_requirement.allow_prefix().cloned(), + ) .await }) .await }) } - fn approval_requirement(&self, req: &ShellRequest) -> Option { - Some(req.approval_requirement.clone()) + fn exec_approval_requirement(&self, req: &ShellRequest) -> Option { + Some(req.exec_approval_requirement.clone()) } fn sandbox_mode_for_first_attempt(&self, req: &ShellRequest) -> SandboxOverride { if req.with_escalated_permissions.unwrap_or(false) || matches!( - req.approval_requirement, - ApprovalRequirement::Skip { + req.exec_approval_requirement, + ExecApprovalRequirement::Skip { bypass_sandbox: true } ) diff --git a/codex-rs/core/src/tools/runtimes/unified_exec.rs b/codex-rs/core/src/tools/runtimes/unified_exec.rs index 0f306e6ff2..45d804d688 100644 --- a/codex-rs/core/src/tools/runtimes/unified_exec.rs +++ b/codex-rs/core/src/tools/runtimes/unified_exec.rs @@ -10,7 +10,7 @@ use crate::exec::ExecExpiration; use crate::tools::runtimes::build_command_spec; use crate::tools::sandboxing::Approvable; use crate::tools::sandboxing::ApprovalCtx; -use crate::tools::sandboxing::ApprovalRequirement; +use crate::tools::sandboxing::ExecApprovalRequirement; use crate::tools::sandboxing::ProvidesSandboxRetryData; use crate::tools::sandboxing::SandboxAttempt; use crate::tools::sandboxing::SandboxOverride; @@ -36,7 +36,7 @@ pub struct UnifiedExecRequest { pub env: HashMap, pub with_escalated_permissions: Option, pub justification: Option, - pub approval_requirement: ApprovalRequirement, + pub exec_approval_requirement: ExecApprovalRequirement, } impl ProvidesSandboxRetryData for UnifiedExecRequest { @@ -66,7 +66,7 @@ impl UnifiedExecRequest { env: HashMap, with_escalated_permissions: Option, justification: Option, - approval_requirement: ApprovalRequirement, + exec_approval_requirement: ExecApprovalRequirement, ) -> Self { Self { command, @@ -74,7 +74,7 @@ impl UnifiedExecRequest { env, with_escalated_permissions, justification, - approval_requirement, + exec_approval_requirement, } } } @@ -125,22 +125,33 @@ impl Approvable for UnifiedExecRuntime<'_> { Box::pin(async move { with_cached_approval(&session.services, key, || async move { session - .request_command_approval(turn, call_id, command, cwd, reason, risk) + .request_command_approval( + turn, + call_id, + command, + cwd, + reason, + risk, + req.exec_approval_requirement.allow_prefix().cloned(), + ) .await }) .await }) } - fn approval_requirement(&self, req: &UnifiedExecRequest) -> Option { - Some(req.approval_requirement.clone()) + fn exec_approval_requirement( + &self, + req: &UnifiedExecRequest, + ) -> Option { + Some(req.exec_approval_requirement.clone()) } fn sandbox_mode_for_first_attempt(&self, req: &UnifiedExecRequest) -> SandboxOverride { if req.with_escalated_permissions.unwrap_or(false) || matches!( - req.approval_requirement, - ApprovalRequirement::Skip { + req.exec_approval_requirement, + ExecApprovalRequirement::Skip { bypass_sandbox: true } ) diff --git a/codex-rs/core/src/tools/sandboxing.rs b/codex-rs/core/src/tools/sandboxing.rs index df10db952e..793c162936 100644 --- a/codex-rs/core/src/tools/sandboxing.rs +++ b/codex-rs/core/src/tools/sandboxing.rs @@ -88,26 +88,42 @@ pub(crate) struct ApprovalCtx<'a> { // Specifies what tool orchestrator should do with a given tool call. #[derive(Clone, Debug, PartialEq, Eq)] -pub(crate) enum ApprovalRequirement { +pub(crate) enum ExecApprovalRequirement { /// No approval required for this tool call. Skip { /// The first attempt should skip sandboxing (e.g., when explicitly /// greenlit by policy). bypass_sandbox: bool, }, - /// Approval required for this tool call - NeedsApproval { reason: Option }, - /// Execution forbidden for this tool call + /// Approval required for this tool call. + NeedsApproval { + reason: Option, + /// Prefix that can be whitelisted via execpolicy to skip future approvals for similar commands + allow_prefix: Option>, + }, + /// Execution forbidden for this tool call. Forbidden { reason: String }, } +impl ExecApprovalRequirement { + pub fn allow_prefix(&self) -> Option<&Vec> { + match self { + Self::NeedsApproval { + allow_prefix: Some(prefix), + .. + } => Some(prefix), + _ => None, + } + } +} + /// - Never, OnFailure: do not ask /// - OnRequest: ask unless sandbox policy is DangerFullAccess /// - UnlessTrusted: always ask -pub(crate) fn default_approval_requirement( +pub(crate) fn default_exec_approval_requirement( policy: AskForApproval, sandbox_policy: &SandboxPolicy, -) -> ApprovalRequirement { +) -> ExecApprovalRequirement { let needs_approval = match policy { AskForApproval::Never | AskForApproval::OnFailure => false, AskForApproval::OnRequest => !matches!(sandbox_policy, SandboxPolicy::DangerFullAccess), @@ -115,9 +131,12 @@ pub(crate) fn default_approval_requirement( }; if needs_approval { - ApprovalRequirement::NeedsApproval { reason: None } + ExecApprovalRequirement::NeedsApproval { + reason: None, + allow_prefix: None, + } } else { - ApprovalRequirement::Skip { + ExecApprovalRequirement::Skip { bypass_sandbox: false, } } @@ -149,10 +168,9 @@ pub(crate) trait Approvable { matches!(policy, AskForApproval::Never) } - /// Override the default approval requirement. Return `Some(_)` to specify - /// a custom requirement, or `None` to fall back to - /// policy-based default. - fn approval_requirement(&self, _req: &Req) -> Option { + /// Return `Some(_)` to specify a custom exec approval requirement, or `None` + /// to fall back to policy-based default. + fn exec_approval_requirement(&self, _req: &Req) -> Option { None } diff --git a/codex-rs/core/src/unified_exec/session_manager.rs b/codex-rs/core/src/unified_exec/session_manager.rs index d37ad4d3fc..51e5626073 100644 --- a/codex-rs/core/src/unified_exec/session_manager.rs +++ b/codex-rs/core/src/unified_exec/session_manager.rs @@ -15,7 +15,7 @@ use crate::codex::TurnContext; use crate::exec::ExecToolCallOutput; use crate::exec::StreamOutput; use crate::exec_env::create_env; -use crate::exec_policy::create_approval_requirement_for_command; +use crate::exec_policy::create_exec_approval_requirement_for_command; use crate::protocol::BackgroundEventEvent; use crate::protocol::EventMsg; use crate::protocol::ExecCommandSource; @@ -556,10 +556,12 @@ impl UnifiedExecSessionManager { context: &UnifiedExecContext, ) -> Result { let env = apply_unified_exec_env(create_env(&context.turn.shell_environment_policy)); + let features = context.session.features(); let mut orchestrator = ToolOrchestrator::new(); let mut runtime = UnifiedExecRuntime::new(self); - let approval_requirement = create_approval_requirement_for_command( + let exec_approval_requirement = create_exec_approval_requirement_for_command( &context.turn.exec_policy, + &features, command, context.turn.approval_policy, &context.turn.sandbox_policy, @@ -572,7 +574,7 @@ impl UnifiedExecSessionManager { env, with_escalated_permissions, justification, - approval_requirement, + exec_approval_requirement, ); let tool_ctx = ToolCtx { session: context.session.as_ref(), diff --git a/codex-rs/core/tests/suite/approvals.rs b/codex-rs/core/tests/suite/approvals.rs index caa488a88f..8ac31fb378 100644 --- a/codex-rs/core/tests/suite/approvals.rs +++ b/codex-rs/core/tests/suite/approvals.rs @@ -1523,7 +1523,7 @@ async fn run_scenario(scenario: &ScenarioSpec) -> Result<()> { test.codex .submit(Op::ExecApproval { id: "0".into(), - decision: *decision, + decision: decision.clone(), }) .await?; wait_for_completion(&test).await; @@ -1544,7 +1544,7 @@ async fn run_scenario(scenario: &ScenarioSpec) -> Result<()> { test.codex .submit(Op::PatchApproval { id: "0".into(), - decision: *decision, + decision: decision.clone(), }) .await?; wait_for_completion(&test).await; @@ -1557,3 +1557,148 @@ async fn run_scenario(scenario: &ScenarioSpec) -> Result<()> { Ok(()) } + +#[tokio::test(flavor = "current_thread")] +#[cfg(unix)] +async fn approving_allow_prefix_persists_policy_and_skips_future_prompts() -> Result<()> { + let server = start_mock_server().await; + let approval_policy = AskForApproval::UnlessTrusted; + let sandbox_policy = SandboxPolicy::ReadOnly; + let sandbox_policy_for_config = sandbox_policy.clone(); + let mut builder = test_codex().with_config(move |config| { + config.approval_policy = approval_policy; + config.sandbox_policy = sandbox_policy_for_config; + }); + let test = builder.build(&server).await?; + let allow_prefix_path = test.cwd.path().join("allow-prefix.txt"); + let _ = fs::remove_file(&allow_prefix_path); + + let call_id_first = "allow-prefix-first"; + let (first_event, expected_command) = ActionKind::RunCommand { + command: "touch allow-prefix.txt", + } + .prepare(&test, &server, call_id_first, false) + .await?; + let expected_command = + expected_command.expect("allow prefix scenario should produce a shell command"); + let expected_allow_prefix = vec!["touch".to_string(), "allow-prefix.txt".to_string()]; + + let _ = mount_sse_once( + &server, + sse(vec![ + ev_response_created("resp-allow-prefix-1"), + first_event, + ev_completed("resp-allow-prefix-1"), + ]), + ) + .await; + let first_results = mount_sse_once( + &server, + sse(vec![ + ev_assistant_message("msg-allow-prefix-1", "done"), + ev_completed("resp-allow-prefix-2"), + ]), + ) + .await; + + submit_turn( + &test, + "allow-prefix-first", + approval_policy, + sandbox_policy.clone(), + ) + .await?; + + let approval = expect_exec_approval(&test, expected_command.as_str()).await; + assert_eq!(approval.allow_prefix, Some(expected_allow_prefix.clone())); + + test.codex + .submit(Op::ExecApproval { + id: "0".into(), + decision: ReviewDecision::ApprovedAllowPrefix { + allow_prefix: expected_allow_prefix.clone(), + }, + }) + .await?; + wait_for_completion(&test).await; + + let policy_path = test.home.path().join("policy").join("default.codexpolicy"); + let policy_contents = fs::read_to_string(&policy_path)?; + assert!( + policy_contents + .contains(r#"prefix_rule(pattern=["touch", "allow-prefix.txt"], decision="allow")"#), + "unexpected policy contents: {policy_contents}" + ); + + let first_output = parse_result( + &first_results + .single_request() + .function_call_output(call_id_first), + ); + assert_eq!(first_output.exit_code.unwrap_or(0), 0); + assert!( + first_output.stdout.is_empty(), + "unexpected stdout: {}", + first_output.stdout + ); + assert_eq!( + fs::read_to_string(&allow_prefix_path)?, + "", + "unexpected file contents after first run" + ); + + let call_id_second = "allow-prefix-second"; + let (second_event, second_command) = ActionKind::RunCommand { + command: "touch allow-prefix.txt", + } + .prepare(&test, &server, call_id_second, false) + .await?; + assert_eq!(second_command.as_deref(), Some(expected_command.as_str())); + + let _ = mount_sse_once( + &server, + sse(vec![ + ev_response_created("resp-allow-prefix-3"), + second_event, + ev_completed("resp-allow-prefix-3"), + ]), + ) + .await; + let second_results = mount_sse_once( + &server, + sse(vec![ + ev_assistant_message("msg-allow-prefix-2", "done"), + ev_completed("resp-allow-prefix-4"), + ]), + ) + .await; + + submit_turn( + &test, + "allow-prefix-second", + approval_policy, + sandbox_policy.clone(), + ) + .await?; + + wait_for_completion_without_approval(&test).await; + + let second_output = parse_result( + &second_results + .single_request() + .function_call_output(call_id_second), + ); + assert_eq!(second_output.exit_code.unwrap_or(0), 0); + assert!( + second_output.stdout.is_empty(), + "unexpected stdout: {}", + second_output.stdout + ); + assert_eq!( + fs::read_to_string(&allow_prefix_path)?, + "", + "unexpected file contents after second run" + ); + + Ok(()) +} diff --git a/codex-rs/mcp-server/src/codex_tool_runner.rs b/codex-rs/mcp-server/src/codex_tool_runner.rs index 55808f17ca..cbee0fe0e7 100644 --- a/codex-rs/mcp-server/src/codex_tool_runner.rs +++ b/codex-rs/mcp-server/src/codex_tool_runner.rs @@ -180,6 +180,7 @@ async fn run_codex_tool_session_inner( call_id, reason: _, risk, + allow_prefix: _, parsed_cmd, }) => { handle_exec_approval_request( diff --git a/codex-rs/otel/src/otel_event_manager.rs b/codex-rs/otel/src/otel_event_manager.rs index c300f3fb82..d3536cd8db 100644 --- a/codex-rs/otel/src/otel_event_manager.rs +++ b/codex-rs/otel/src/otel_event_manager.rs @@ -352,7 +352,7 @@ impl OtelEventManager { &self, tool_name: &str, call_id: &str, - decision: ReviewDecision, + decision: &ReviewDecision, source: ToolDecisionSource, ) { tracing::event!( @@ -369,7 +369,7 @@ impl OtelEventManager { slug = %self.metadata.slug, tool_name = %tool_name, call_id = %call_id, - decision = %decision.to_string().to_lowercase(), + decision = %decision.clone().to_string().to_lowercase(), source = %source.to_string(), ); } diff --git a/codex-rs/protocol/src/approvals.rs b/codex-rs/protocol/src/approvals.rs index 9ef3a9e368..54a9efca9f 100644 --- a/codex-rs/protocol/src/approvals.rs +++ b/codex-rs/protocol/src/approvals.rs @@ -51,6 +51,10 @@ pub struct ExecApprovalRequestEvent { /// Optional model-provided risk assessment describing the blocked command. #[serde(skip_serializing_if = "Option::is_none")] pub risk: Option, + /// Prefix rule that can be added to the user's execpolicy to allow future runs. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional, type = "Array")] + pub allow_prefix: Option>, pub parsed_cmd: Vec, } diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index 99d2ec70d3..ef06c2e1dc 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -1649,14 +1649,16 @@ pub struct SessionConfiguredEvent { } /// User's decision in response to an ExecApprovalRequest. -#[derive( - Debug, Default, Clone, Copy, Deserialize, Serialize, PartialEq, Eq, Display, JsonSchema, TS, -)] +#[derive(Debug, Default, Clone, Deserialize, Serialize, PartialEq, Eq, Display, JsonSchema, TS)] #[serde(rename_all = "snake_case")] pub enum ReviewDecision { /// User has approved this command and the agent should execute it. Approved, + /// User has approved this command and wants to add the command prefix to + /// the execpolicy allow list so future matching commands are permitted. + ApprovedAllowPrefix { allow_prefix: Vec }, + /// User has approved this command and wants to automatically approve any /// future identical instances (`command` and `cwd` match exactly) for the /// remainder of the session. diff --git a/codex-rs/tui/src/bottom_pane/approval_overlay.rs b/codex-rs/tui/src/bottom_pane/approval_overlay.rs index c6006a9ae7..3defdbf9e3 100644 --- a/codex-rs/tui/src/bottom_pane/approval_overlay.rs +++ b/codex-rs/tui/src/bottom_pane/approval_overlay.rs @@ -43,6 +43,7 @@ pub(crate) enum ApprovalRequest { command: Vec, reason: Option, risk: Option, + allow_prefix: Option>, }, ApplyPatch { id: String, @@ -104,8 +105,8 @@ impl ApprovalOverlay { header: Box, ) -> (Vec, SelectionViewParams) { let (options, title) = match &variant { - ApprovalVariant::Exec { .. } => ( - exec_options(), + ApprovalVariant::Exec { allow_prefix, .. } => ( + exec_options(allow_prefix.clone()), "Would you like to run the following command?".to_string(), ), ApprovalVariant::ApplyPatch { .. } => ( @@ -160,12 +161,12 @@ impl ApprovalOverlay { return; }; if let Some(variant) = self.current_variant.as_ref() { - match (&variant, &option.decision) { - (ApprovalVariant::Exec { id, command }, ApprovalDecision::Review(decision)) => { - self.handle_exec_decision(id, command, *decision); + match (variant, &option.decision) { + (ApprovalVariant::Exec { id, command, .. }, ApprovalDecision::Review(decision)) => { + self.handle_exec_decision(id, command, decision.clone()); } (ApprovalVariant::ApplyPatch { id, .. }, ApprovalDecision::Review(decision)) => { - self.handle_patch_decision(id, *decision); + self.handle_patch_decision(id, decision.clone()); } ( ApprovalVariant::McpElicitation { @@ -185,7 +186,7 @@ impl ApprovalOverlay { } fn handle_exec_decision(&self, id: &str, command: &[String], decision: ReviewDecision) { - let cell = history_cell::new_approval_decision_cell(command.to_vec(), decision); + let cell = history_cell::new_approval_decision_cell(command.to_vec(), decision.clone()); self.app_event_tx.send(AppEvent::InsertHistoryCell(cell)); self.app_event_tx.send(AppEvent::CodexOp(Op::ExecApproval { id: id.to_string(), @@ -273,7 +274,7 @@ impl BottomPaneView for ApprovalOverlay { && let Some(variant) = self.current_variant.as_ref() { match &variant { - ApprovalVariant::Exec { id, command } => { + ApprovalVariant::Exec { id, command, .. } => { self.handle_exec_decision(id, command, ReviewDecision::Abort); } ApprovalVariant::ApplyPatch { id, .. } => { @@ -336,6 +337,7 @@ impl From for ApprovalRequestState { command, reason, risk, + allow_prefix, } => { let reason = reason.filter(|item| !item.is_empty()); let has_reason = reason.is_some(); @@ -355,7 +357,11 @@ impl From for ApprovalRequestState { } header.extend(full_cmd_lines); Self { - variant: ApprovalVariant::Exec { id, command }, + variant: ApprovalVariant::Exec { + id, + command, + allow_prefix, + }, header: Box::new(Paragraph::new(header).wrap(Wrap { trim: false })), } } @@ -431,6 +437,7 @@ enum ApprovalVariant { Exec { id: String, command: Vec, + allow_prefix: Option>, }, ApplyPatch { id: String, @@ -463,7 +470,7 @@ impl ApprovalOption { } } -fn exec_options() -> Vec { +fn exec_options(allow_prefix: Option>) -> Vec { vec![ ApprovalOption { label: "Yes, proceed".to_string(), @@ -472,18 +479,28 @@ fn exec_options() -> Vec { additional_shortcuts: vec![key_hint::plain(KeyCode::Char('y'))], }, ApprovalOption { - label: "Yes, and don't ask again for this command".to_string(), + label: "Yes, and don't ask again this session".to_string(), decision: ApprovalDecision::Review(ReviewDecision::ApprovedForSession), display_shortcut: None, additional_shortcuts: vec![key_hint::plain(KeyCode::Char('a'))], }, - ApprovalOption { - label: "No, and tell Codex what to do differently".to_string(), - decision: ApprovalDecision::Review(ReviewDecision::Abort), - display_shortcut: Some(key_hint::plain(KeyCode::Esc)), - additional_shortcuts: vec![key_hint::plain(KeyCode::Char('n'))], - }, ] + .into_iter() + .chain(allow_prefix.map(|prefix| ApprovalOption { + label: "Yes, and don't ask again for commands with this prefix".to_string(), + decision: ApprovalDecision::Review(ReviewDecision::ApprovedAllowPrefix { + allow_prefix: prefix, + }), + display_shortcut: None, + additional_shortcuts: vec![key_hint::plain(KeyCode::Char('p'))], + })) + .chain([ApprovalOption { + label: "No, and tell Codex what to do differently".to_string(), + decision: ApprovalDecision::Review(ReviewDecision::Abort), + display_shortcut: Some(key_hint::plain(KeyCode::Esc)), + additional_shortcuts: vec![key_hint::plain(KeyCode::Char('n'))], + }]) + .collect() } fn patch_options() -> Vec { @@ -539,6 +556,7 @@ mod tests { command: vec!["echo".to_string(), "hi".to_string()], reason: Some("reason".to_string()), risk: None, + allow_prefix: None, } } @@ -571,6 +589,40 @@ mod tests { assert!(saw_op, "expected approval decision to emit an op"); } + #[test] + fn exec_prefix_option_emits_allow_prefix() { + let (tx, mut rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx); + let mut view = ApprovalOverlay::new( + ApprovalRequest::Exec { + id: "test".to_string(), + command: vec!["echo".to_string()], + reason: None, + risk: None, + allow_prefix: Some(vec!["echo".to_string()]), + }, + tx, + ); + view.handle_key_event(KeyEvent::new(KeyCode::Char('p'), KeyModifiers::NONE)); + let mut saw_op = false; + while let Ok(ev) = rx.try_recv() { + if let AppEvent::CodexOp(Op::ExecApproval { decision, .. }) = ev { + assert_eq!( + decision, + ReviewDecision::ApprovedAllowPrefix { + allow_prefix: vec!["echo".to_string()] + } + ); + saw_op = true; + break; + } + } + assert!( + saw_op, + "expected approval decision to emit an op with allow prefix" + ); + } + #[test] fn header_includes_command_snippet() { let (tx, _rx) = unbounded_channel::(); @@ -581,6 +633,7 @@ mod tests { command, reason: None, risk: None, + allow_prefix: None, }; let view = ApprovalOverlay::new(exec_request, tx); diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index a0425c92d7..844442c24c 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -570,6 +570,7 @@ mod tests { command: vec!["echo".into(), "ok".into()], reason: None, risk: None, + allow_prefix: None, } } diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index c257f7c1bd..4aa397ad90 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -1074,6 +1074,7 @@ impl ChatWidget { command: ev.command, reason: ev.reason, risk: ev.risk, + allow_prefix: ev.allow_prefix, }; self.bottom_pane.push_approval_request(request); self.request_redraw(); diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__approval_modal_exec.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__approval_modal_exec.snap index b84588e337..eaf0fed3a1 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__approval_modal_exec.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__approval_modal_exec.snap @@ -10,7 +10,8 @@ expression: terminal.backend().vt100().screen().contents() $ echo hello world › 1. Yes, proceed (y) - 2. Yes, and don't ask again for this command (a) - 3. No, and tell Codex what to do differently (esc) + 2. Yes, and don't ask again this session (a) + 3. Yes, and don't ask again for commands with this prefix (p) + 4. No, and tell Codex what to do differently (esc) Press enter to confirm or esc to cancel diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__approval_modal_exec_no_reason.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__approval_modal_exec_no_reason.snap index 543d367d23..ce2277ce62 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__approval_modal_exec_no_reason.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__approval_modal_exec_no_reason.snap @@ -7,7 +7,8 @@ expression: terminal.backend().vt100().screen().contents() $ echo hello world › 1. Yes, proceed (y) - 2. Yes, and don't ask again for this command (a) - 3. No, and tell Codex what to do differently (esc) + 2. Yes, and don't ask again this session (a) + 3. Yes, and don't ask again for commands with this prefix (p) + 4. No, and tell Codex what to do differently (esc) Press enter to confirm or esc to cancel diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__exec_approval_modal_exec.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__exec_approval_modal_exec.snap index f986a927e5..ff70d7d492 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__exec_approval_modal_exec.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__exec_approval_modal_exec.snap @@ -15,7 +15,7 @@ Buffer { " $ echo hello world ", " ", "› 1. Yes, proceed (y) ", - " 2. Yes, and don't ask again for this command (a) ", + " 2. Yes, and don't ask again this session (a) ", " 3. No, and tell Codex what to do differently (esc) ", " ", " Press enter to confirm or esc to cancel ", @@ -30,8 +30,8 @@ Buffer { x: 7, y: 5, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, x: 0, y: 9, fg: Cyan, bg: Reset, underline: Reset, modifier: BOLD, x: 21, y: 9, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, - x: 48, y: 10, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, - x: 49, y: 10, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 44, y: 10, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 45, y: 10, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, x: 48, y: 11, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, x: 51, y: 11, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, x: 2, y: 13, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__status_widget_and_approval_modal.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__status_widget_and_approval_modal.snap index f98c807878..5ce388c87b 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__status_widget_and_approval_modal.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__status_widget_and_approval_modal.snap @@ -1,6 +1,5 @@ --- source: tui/src/chatwidget/tests.rs -assertion_line: 1548 expression: terminal.backend() --- " " @@ -13,7 +12,8 @@ expression: terminal.backend() " $ echo 'hello world' " " " "› 1. Yes, proceed (y) " -" 2. Yes, and don't ask again for this command (a) " -" 3. No, and tell Codex what to do differently (esc) " +" 2. Yes, and don't ask again this session (a) " +" 3. Yes, and don't ask again for commands with this prefix (p) " +" 4. No, and tell Codex what to do differently (esc) " " " " Press enter to confirm or esc to cancel " diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index 419dab2c87..2b75f960bd 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -688,6 +688,7 @@ fn exec_approval_emits_proposed_command_and_decision_history() { "this is a test reason such as one that would be produced by the model".into(), ), risk: None, + allow_prefix: None, parsed_cmd: vec![], }; chat.handle_codex_event(Event { @@ -732,6 +733,7 @@ fn exec_approval_decision_truncates_multiline_and_long_commands() { "this is a test reason such as one that would be produced by the model".into(), ), risk: None, + allow_prefix: None, parsed_cmd: vec![], }; chat.handle_codex_event(Event { @@ -782,6 +784,7 @@ fn exec_approval_decision_truncates_multiline_and_long_commands() { cwd: std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")), reason: None, risk: None, + allow_prefix: None, parsed_cmd: vec![], }; chat.handle_codex_event(Event { @@ -1990,6 +1993,7 @@ fn approval_modal_exec_snapshot() { "this is a test reason such as one that would be produced by the model".into(), ), risk: None, + allow_prefix: Some(vec!["echo".into(), "hello".into(), "world".into()]), parsed_cmd: vec![], }; chat.handle_codex_event(Event { @@ -2036,6 +2040,7 @@ fn approval_modal_exec_without_reason_snapshot() { cwd: std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")), reason: None, risk: None, + allow_prefix: Some(vec!["echo".into(), "hello".into(), "world".into()]), parsed_cmd: vec![], }; chat.handle_codex_event(Event { @@ -2249,6 +2254,7 @@ fn status_widget_and_approval_modal_snapshot() { "this is a test reason such as one that would be produced by the model".into(), ), risk: None, + allow_prefix: Some(vec!["echo".into(), "hello world".into()]), parsed_cmd: vec![], }; chat.handle_codex_event(Event { diff --git a/codex-rs/tui/src/history_cell.rs b/codex-rs/tui/src/history_cell.rs index de8c06488e..90a54268cb 100644 --- a/codex-rs/tui/src/history_cell.rs +++ b/codex-rs/tui/src/history_cell.rs @@ -409,6 +409,19 @@ pub fn new_approval_decision_cell( ], ) } + ApprovedAllowPrefix { .. } => { + let snippet = Span::from(exec_snippet(&command)).dim(); + ( + "✔ ".green(), + vec![ + "You ".into(), + "approved".bold(), + " codex to run ".into(), + snippet, + " and added its prefix to your allow list".bold(), + ], + ) + } ApprovedForSession => { let snippet = Span::from(exec_snippet(&command)).dim(); ( diff --git a/docs/config.md b/docs/config.md index 0f0761a301..6aa1bde8c5 100644 --- a/docs/config.md +++ b/docs/config.md @@ -603,7 +603,7 @@ metadata above): - `codex.tool_decision` - `tool_name` - `call_id` - - `decision` (`approved`, `approved_for_session`, `denied`, or `abort`) + - `decision` (`approved`, `approved_allow_prefix`, `approved_for_session`, `denied`, or `abort`) - `source` (`config` or `user`) - `codex.tool_result` - `tool_name` From 3d35cb4619a5aca7d7d4fe84ae5422ffb0e92651 Mon Sep 17 00:00:00 2001 From: zhao-oai Date: Thu, 4 Dec 2025 02:39:48 -0500 Subject: [PATCH 28/30] Refactor execpolicy fallback evaluation (#7544) ## Refactor of the `execpolicy` crate To illustrate why we need this refactor, consider an agent attempting to run `apple | rm -rf ./`. Suppose `apple` is allowed by `execpolicy`. Before this PR, `execpolicy` would consider `apple` and `pear` and only render one rule match: `Allow`. We would skip any heuristics checks on `rm -rf ./` and immediately approve `apple | rm -rf ./` to run. To fix this, we now thread a `fallback` evaluation function into `execpolicy` that runs when no `execpolicy` rules match a given command. In our example, we would run `fallback` on `rm -rf ./` and prevent `apple | rm -rf ./` from being run without approval. --- .../app-server/src/bespoke_event_handling.rs | 2 +- codex-rs/cli/tests/execpolicy.rs | 18 +- codex-rs/core/src/apply_patch.rs | 2 +- codex-rs/core/src/codex.rs | 33 +- codex-rs/core/src/codex_delegate.rs | 2 +- codex-rs/core/src/exec_policy.rs | 289 +++++++++++++----- codex-rs/core/src/tools/orchestrator.rs | 4 +- codex-rs/core/src/tools/runtimes/shell.rs | 4 +- .../core/src/tools/runtimes/unified_exec.rs | 4 +- codex-rs/core/src/tools/sandboxing.rs | 12 +- codex-rs/core/tests/suite/approvals.rs | 17 +- codex-rs/execpolicy/README.md | 32 +- codex-rs/execpolicy/src/execpolicycheck.rs | 28 +- codex-rs/execpolicy/src/main.rs | 8 +- codex-rs/execpolicy/src/policy.rs | 96 +++--- codex-rs/execpolicy/src/rule.rs | 5 + codex-rs/execpolicy/tests/basic.rs | 94 ++++-- codex-rs/mcp-server/src/codex_tool_runner.rs | 2 +- codex-rs/protocol/src/approvals.rs | 34 ++- codex-rs/protocol/src/protocol.rs | 9 +- .../tui/src/bottom_pane/approval_overlay.rs | 42 +-- codex-rs/tui/src/bottom_pane/mod.rs | 2 +- codex-rs/tui/src/chatwidget.rs | 2 +- codex-rs/tui/src/chatwidget/tests.rs | 24 +- codex-rs/tui/src/history_cell.rs | 4 +- docs/config.md | 2 +- docs/execpolicy.md | 24 ++ 27 files changed, 538 insertions(+), 257 deletions(-) diff --git a/codex-rs/app-server/src/bespoke_event_handling.rs b/codex-rs/app-server/src/bespoke_event_handling.rs index 6d03a9f5db..8e39580fa8 100644 --- a/codex-rs/app-server/src/bespoke_event_handling.rs +++ b/codex-rs/app-server/src/bespoke_event_handling.rs @@ -179,7 +179,7 @@ pub(crate) async fn apply_bespoke_event_handling( cwd, reason, risk, - allow_prefix: _allow_prefix, + proposed_execpolicy_amendment: _, parsed_cmd, }) => match api_version { ApiVersion::V1 => { diff --git a/codex-rs/cli/tests/execpolicy.rs b/codex-rs/cli/tests/execpolicy.rs index c6bca85bc6..4610b95874 100644 --- a/codex-rs/cli/tests/execpolicy.rs +++ b/codex-rs/cli/tests/execpolicy.rs @@ -40,17 +40,15 @@ prefix_rule( assert_eq!( result, json!({ - "match": { - "decision": "forbidden", - "matchedRules": [ - { - "prefixRuleMatch": { - "matchedPrefix": ["git", "push"], - "decision": "forbidden" - } + "decision": "forbidden", + "matchedRules": [ + { + "prefixRuleMatch": { + "matchedPrefix": ["git", "push"], + "decision": "forbidden" } - ] - } + } + ] }) ); diff --git a/codex-rs/core/src/apply_patch.rs b/codex-rs/core/src/apply_patch.rs index 5cf0ecee70..67433303e5 100644 --- a/codex-rs/core/src/apply_patch.rs +++ b/codex-rs/core/src/apply_patch.rs @@ -71,7 +71,7 @@ pub(crate) async fn apply_patch( .await; match rx_approve.await.unwrap_or_default() { ReviewDecision::Approved - | ReviewDecision::ApprovedAllowPrefix { .. } + | ReviewDecision::ApprovedExecpolicyAmendment { .. } | ReviewDecision::ApprovedForSession => { InternalApplyPatchInvocation::DelegateToExec(ApplyPatchExec { action, diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 7e0a1d685d..34cde906ec 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -25,6 +25,7 @@ use crate::util::error_or_panic; use async_channel::Receiver; use async_channel::Sender; use codex_protocol::ConversationId; +use codex_protocol::approvals::ExecPolicyAmendment; use codex_protocol::items::TurnItem; use codex_protocol::protocol::FileChange; use codex_protocol::protocol::HasLegacyEvent; @@ -871,13 +872,11 @@ impl Session { .await } - /// Adds a prefix rule to the exec policy - /// - /// This mutates the in-memory execpolicy so the current conversation can use the new - /// prefix and persists the change in default.execpolicy so new conversations will also allow the new prefix. - pub(crate) async fn persist_command_allow_prefix( + /// Adds an execpolicy amendment to both the in-memory and on-disk policies so future + /// commands can use the newly approved prefix. + pub(crate) async fn persist_execpolicy_amendment( &self, - prefix: &[String], + amendment: &ExecPolicyAmendment, ) -> Result<(), ExecPolicyUpdateError> { let features = self.features.clone(); let (codex_home, current_policy) = { @@ -897,10 +896,10 @@ impl Session { return Err(ExecPolicyUpdateError::FeatureDisabled); } - crate::exec_policy::append_allow_prefix_rule_and_update( + crate::exec_policy::append_execpolicy_amendment_and_update( &codex_home, ¤t_policy, - prefix, + &amendment.command, ) .await?; @@ -921,7 +920,7 @@ impl Session { cwd: PathBuf, reason: Option, risk: Option, - allow_prefix: Option>, + proposed_execpolicy_amendment: Option, ) -> ReviewDecision { let sub_id = turn_context.sub_id.clone(); // Add the tx_approve callback to the map before sending the request. @@ -949,7 +948,7 @@ impl Session { cwd, reason, risk, - allow_prefix, + proposed_execpolicy_amendment, parsed_cmd, }); self.send_event(turn_context, event).await; @@ -1672,13 +1671,17 @@ mod handlers { } } - /// Propagate a user's exec approval decision to the session - /// Also optionally whitelists command in execpolicy + /// Propagate a user's exec approval decision to the session. + /// Also optionally applies an execpolicy amendment. pub async fn exec_approval(sess: &Arc, id: String, decision: ReviewDecision) { - if let ReviewDecision::ApprovedAllowPrefix { allow_prefix } = &decision - && let Err(err) = sess.persist_command_allow_prefix(allow_prefix).await + if let ReviewDecision::ApprovedExecpolicyAmendment { + proposed_execpolicy_amendment, + } = &decision + && let Err(err) = sess + .persist_execpolicy_amendment(proposed_execpolicy_amendment) + .await { - let message = format!("Failed to update execpolicy allow list: {err}"); + let message = format!("Failed to apply execpolicy amendment: {err}"); tracing::warn!("{message}"); let warning = EventMsg::Warning(WarningEvent { message }); sess.send_event_raw(Event { diff --git a/codex-rs/core/src/codex_delegate.rs b/codex-rs/core/src/codex_delegate.rs index 1606855669..670225ead0 100644 --- a/codex-rs/core/src/codex_delegate.rs +++ b/codex-rs/core/src/codex_delegate.rs @@ -281,7 +281,7 @@ async fn handle_exec_approval( event.cwd, event.reason, event.risk, - event.allow_prefix, + event.proposed_execpolicy_amendment, ); let decision = await_approval_with_cancel( approval_fut, diff --git a/codex-rs/core/src/exec_policy.rs b/codex-rs/core/src/exec_policy.rs index 7c96deea90..1bbe60ff11 100644 --- a/codex-rs/core/src/exec_policy.rs +++ b/codex-rs/core/src/exec_policy.rs @@ -10,7 +10,9 @@ use codex_execpolicy::Error as ExecPolicyRuleError; use codex_execpolicy::Evaluation; use codex_execpolicy::Policy; use codex_execpolicy::PolicyParser; +use codex_execpolicy::RuleMatch; use codex_execpolicy::blocking_append_allow_prefix_rule; +use codex_protocol::approvals::ExecPolicyAmendment; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::SandboxPolicy; use thiserror::Error; @@ -25,6 +27,8 @@ use crate::sandboxing::SandboxPermissions; use crate::tools::sandboxing::ExecApprovalRequirement; const FORBIDDEN_REASON: &str = "execpolicy forbids this command"; +const PROMPT_CONFLICT_REASON: &str = + "execpolicy requires approval for this command, but AskForApproval is set to Never"; const PROMPT_REASON: &str = "execpolicy requires approval for this command"; const POLICY_DIR_NAME: &str = "policy"; const POLICY_EXTENSION: &str = "codexpolicy"; @@ -112,7 +116,7 @@ pub(crate) fn default_policy_path(codex_home: &Path) -> PathBuf { codex_home.join(POLICY_DIR_NAME).join(DEFAULT_POLICY_FILE) } -pub(crate) async fn append_allow_prefix_rule_and_update( +pub(crate) async fn append_execpolicy_amendment_and_update( codex_home: &Path, current_policy: &Arc>, prefix: &[String], @@ -139,45 +143,54 @@ pub(crate) async fn append_allow_prefix_rule_and_update( Ok(()) } -fn requirement_from_decision( - decision: Decision, - approval_policy: AskForApproval, -) -> ExecApprovalRequirement { - match decision { - Decision::Forbidden => ExecApprovalRequirement::Forbidden { - reason: FORBIDDEN_REASON.to_string(), - }, - Decision::Prompt => { - let reason = PROMPT_REASON.to_string(); - if matches!(approval_policy, AskForApproval::Never) { - ExecApprovalRequirement::Forbidden { reason } - } else { - ExecApprovalRequirement::NeedsApproval { - reason: Some(reason), - allow_prefix: None, +/// Returns a proposed execpolicy amendment only when heuristics caused +/// the prompt decision, so we can offer to apply that amendment for future runs. +/// +/// The amendment uses the first command heuristics marked as `Prompt`. If any explicit +/// execpolicy rule also prompts, we return `None` because applying the amendment would not +/// skip that policy requirement. +/// +/// Examples: +/// - execpolicy: empty. Command: `["python"]`. Heuristics prompt -> `Some(vec!["python"])`. +/// - execpolicy: empty. Command: `["bash", "-c", "cd /some/folder && prog1 --option1 arg1 && prog2 --option2 arg2"]`. +/// Parsed commands include `cd /some/folder`, `prog1 --option1 arg1`, and `prog2 --option2 arg2`. If heuristics allow `cd` but prompt +/// on `prog1`, we return `Some(vec!["prog1", "--option1", "arg1"])`. +/// - execpolicy: contains a `prompt for prefix ["prog2"]` rule. For the same command as above, +/// we return `None` because an execpolicy prompt still applies even if we amend execpolicy to allow ["prog1", "--option1", "arg1"]. +fn proposed_execpolicy_amendment(evaluation: &Evaluation) -> Option { + if evaluation.decision != Decision::Prompt { + return None; + } + + let mut first_prompt_from_heuristics: Option> = None; + for rule_match in &evaluation.matched_rules { + match rule_match { + RuleMatch::HeuristicsRuleMatch { command, decision } => { + if *decision == Decision::Prompt && first_prompt_from_heuristics.is_none() { + first_prompt_from_heuristics = Some(command.clone()); } } + _ if rule_match.decision() == Decision::Prompt => { + return None; + } + _ => {} } - Decision::Allow => ExecApprovalRequirement::Skip { - bypass_sandbox: true, - }, } + + first_prompt_from_heuristics.map(ExecPolicyAmendment::from) } -/// Return an allow-prefix option when a single plain command needs approval without -/// any matching policy rule. We only surface the prefix opt-in when execpolicy did -/// not already drive the decision (NoMatch) and when the command is a single -/// unrolled command (multi-part scripts shouldn’t be whitelisted via prefix) and -/// when execpolicy feature is enabled. -fn allow_prefix_if_applicable( - commands: &[Vec], - features: &Features, -) -> Option> { - if features.enabled(Feature::ExecPolicy) && commands.len() == 1 { - Some(commands[0].clone()) - } else { - None - } +/// Only return PROMPT_REASON when an execpolicy rule drove the prompt decision. +fn derive_prompt_reason(evaluation: &Evaluation) -> Option { + evaluation.matched_rules.iter().find_map(|rule_match| { + if !matches!(rule_match, RuleMatch::HeuristicsRuleMatch { .. }) + && rule_match.decision() == Decision::Prompt + { + Some(PROMPT_REASON.to_string()) + } else { + None + } + }) } pub(crate) async fn create_exec_approval_requirement_for_command( @@ -189,27 +202,43 @@ pub(crate) async fn create_exec_approval_requirement_for_command( sandbox_permissions: SandboxPermissions, ) -> ExecApprovalRequirement { let commands = parse_shell_lc_plain_commands(command).unwrap_or_else(|| vec![command.to_vec()]); - let evaluation = exec_policy.read().await.check_multiple(commands.iter()); + let heuristics_fallback = |cmd: &[String]| { + if requires_initial_appoval(approval_policy, sandbox_policy, cmd, sandbox_permissions) { + Decision::Prompt + } else { + Decision::Allow + } + }; + let policy = exec_policy.read().await; + let evaluation = policy.check_multiple(commands.iter(), &heuristics_fallback); + let has_policy_allow = evaluation.matched_rules.iter().any(|rule_match| { + !matches!(rule_match, RuleMatch::HeuristicsRuleMatch { .. }) + && rule_match.decision() == Decision::Allow + }); - match evaluation { - Evaluation::Match { decision, .. } => requirement_from_decision(decision, approval_policy), - Evaluation::NoMatch { .. } => { - if requires_initial_appoval( - approval_policy, - sandbox_policy, - command, - sandbox_permissions, - ) { - ExecApprovalRequirement::NeedsApproval { - reason: None, - allow_prefix: allow_prefix_if_applicable(&commands, features), + match evaluation.decision { + Decision::Forbidden => ExecApprovalRequirement::Forbidden { + reason: FORBIDDEN_REASON.to_string(), + }, + Decision::Prompt => { + if matches!(approval_policy, AskForApproval::Never) { + ExecApprovalRequirement::Forbidden { + reason: PROMPT_CONFLICT_REASON.to_string(), } } else { - ExecApprovalRequirement::Skip { - bypass_sandbox: false, + ExecApprovalRequirement::NeedsApproval { + reason: derive_prompt_reason(&evaluation), + proposed_execpolicy_amendment: if features.enabled(Feature::ExecPolicy) { + proposed_execpolicy_amendment(&evaluation) + } else { + None + }, } } } + Decision::Allow => ExecApprovalRequirement::Skip { + bypass_sandbox: has_policy_allow, + }, } } @@ -282,10 +311,19 @@ mod tests { .expect("policy result"); let commands = [vec!["rm".to_string()]]; - assert!(matches!( - policy.read().await.check_multiple(commands.iter()), - Evaluation::NoMatch { .. } - )); + assert_eq!( + Evaluation { + decision: Decision::Allow, + matched_rules: vec![RuleMatch::HeuristicsRuleMatch { + command: vec!["rm".to_string()], + decision: Decision::Allow + }], + }, + policy + .read() + .await + .check_multiple(commands.iter(), &|_| Decision::Allow) + ); assert!(!temp_dir.path().join(POLICY_DIR_NAME).exists()); } @@ -316,10 +354,19 @@ mod tests { .await .expect("policy result"); let command = [vec!["rm".to_string()]]; - assert!(matches!( - policy.read().await.check_multiple(command.iter()), - Evaluation::Match { .. } - )); + assert_eq!( + Evaluation { + decision: Decision::Forbidden, + matched_rules: vec![RuleMatch::PrefixRuleMatch { + matched_prefix: vec!["rm".to_string()], + decision: Decision::Forbidden + }], + }, + policy + .read() + .await + .check_multiple(command.iter(), &|_| Decision::Allow) + ); } #[tokio::test] @@ -335,10 +382,19 @@ mod tests { .await .expect("policy result"); let command = [vec!["ls".to_string()]]; - assert!(matches!( - policy.read().await.check_multiple(command.iter()), - Evaluation::NoMatch { .. } - )); + assert_eq!( + Evaluation { + decision: Decision::Allow, + matched_rules: vec![RuleMatch::HeuristicsRuleMatch { + command: vec!["ls".to_string()], + decision: Decision::Allow + }], + }, + policy + .read() + .await + .check_multiple(command.iter(), &|_| Decision::Allow) + ); } #[tokio::test] @@ -400,7 +456,7 @@ prefix_rule(pattern=["rm"], decision="forbidden") requirement, ExecApprovalRequirement::NeedsApproval { reason: Some(PROMPT_REASON.to_string()), - allow_prefix: None, + proposed_execpolicy_amendment: None, } ); } @@ -428,7 +484,7 @@ prefix_rule(pattern=["rm"], decision="forbidden") assert_eq!( requirement, ExecApprovalRequirement::Forbidden { - reason: PROMPT_REASON.to_string() + reason: PROMPT_CONFLICT_REASON.to_string() } ); } @@ -452,29 +508,61 @@ prefix_rule(pattern=["rm"], decision="forbidden") requirement, ExecApprovalRequirement::NeedsApproval { reason: None, - allow_prefix: Some(command) + proposed_execpolicy_amendment: Some(ExecPolicyAmendment::new(command)) } ); } #[tokio::test] - async fn append_allow_prefix_rule_updates_policy_and_file() { + async fn heuristics_apply_when_other_commands_match_policy() { + let policy_src = r#"prefix_rule(pattern=["apple"], decision="allow")"#; + let mut parser = PolicyParser::new(); + parser + .parse("test.codexpolicy", policy_src) + .expect("parse policy"); + let policy = Arc::new(RwLock::new(parser.build())); + let command = vec![ + "bash".to_string(), + "-lc".to_string(), + "apple | orange".to_string(), + ]; + + assert_eq!( + create_exec_approval_requirement_for_command( + &policy, + &Features::with_defaults(), + &command, + AskForApproval::UnlessTrusted, + &SandboxPolicy::DangerFullAccess, + SandboxPermissions::UseDefault, + ) + .await, + ExecApprovalRequirement::NeedsApproval { + reason: None, + proposed_execpolicy_amendment: Some(ExecPolicyAmendment::new(vec![ + "orange".to_string() + ])) + } + ); + } + + #[tokio::test] + async fn append_execpolicy_amendment_updates_policy_and_file() { let codex_home = tempdir().expect("create temp dir"); let current_policy = Arc::new(RwLock::new(Policy::empty())); let prefix = vec!["echo".to_string(), "hello".to_string()]; - append_allow_prefix_rule_and_update(codex_home.path(), ¤t_policy, &prefix) + append_execpolicy_amendment_and_update(codex_home.path(), ¤t_policy, &prefix) .await .expect("update policy"); - let evaluation = current_policy.read().await.check(&[ - "echo".to_string(), - "hello".to_string(), - "world".to_string(), - ]); + let evaluation = current_policy.read().await.check( + &["echo".to_string(), "hello".to_string(), "world".to_string()], + &|_| Decision::Allow, + ); assert!(matches!( evaluation, - Evaluation::Match { + Evaluation { decision: Decision::Allow, .. } @@ -490,12 +578,12 @@ prefix_rule(pattern=["rm"], decision="forbidden") } #[tokio::test] - async fn append_allow_prefix_rule_rejects_empty_prefix() { + async fn append_execpolicy_amendment_rejects_empty_prefix() { let codex_home = tempdir().expect("create temp dir"); let current_policy = Arc::new(RwLock::new(Policy::empty())); let result = - append_allow_prefix_rule_and_update(codex_home.path(), ¤t_policy, &[]).await; + append_execpolicy_amendment_and_update(codex_home.path(), ¤t_policy, &[]).await; assert!(matches!( result, @@ -507,7 +595,7 @@ prefix_rule(pattern=["rm"], decision="forbidden") } #[tokio::test] - async fn allow_prefix_is_present_for_single_command_without_policy_match() { + async fn proposed_execpolicy_amendment_is_present_for_single_command_without_policy_match() { let command = vec!["cargo".to_string(), "build".to_string()]; let empty_policy = Arc::new(RwLock::new(Policy::empty())); @@ -525,13 +613,13 @@ prefix_rule(pattern=["rm"], decision="forbidden") requirement, ExecApprovalRequirement::NeedsApproval { reason: None, - allow_prefix: Some(command) + proposed_execpolicy_amendment: Some(ExecPolicyAmendment::new(command)) } ); } #[tokio::test] - async fn allow_prefix_is_disabled_when_execpolicy_feature_disabled() { + async fn proposed_execpolicy_amendment_is_disabled_when_execpolicy_feature_disabled() { let command = vec!["cargo".to_string(), "build".to_string()]; let mut features = Features::with_defaults(); @@ -551,13 +639,13 @@ prefix_rule(pattern=["rm"], decision="forbidden") requirement, ExecApprovalRequirement::NeedsApproval { reason: None, - allow_prefix: None, + proposed_execpolicy_amendment: None, } ); } #[tokio::test] - async fn allow_prefix_is_omitted_when_policy_prompts() { + async fn proposed_execpolicy_amendment_is_omitted_when_policy_prompts() { let policy_src = r#"prefix_rule(pattern=["rm"], decision="prompt")"#; let mut parser = PolicyParser::new(); parser @@ -580,13 +668,13 @@ prefix_rule(pattern=["rm"], decision="forbidden") requirement, ExecApprovalRequirement::NeedsApproval { reason: Some(PROMPT_REASON.to_string()), - allow_prefix: None, + proposed_execpolicy_amendment: None, } ); } #[tokio::test] - async fn allow_prefix_is_omitted_for_multi_command_scripts() { + async fn proposed_execpolicy_amendment_is_present_for_multi_command_scripts() { let command = vec![ "bash".to_string(), "-lc".to_string(), @@ -606,7 +694,44 @@ prefix_rule(pattern=["rm"], decision="forbidden") requirement, ExecApprovalRequirement::NeedsApproval { reason: None, - allow_prefix: None, + proposed_execpolicy_amendment: Some(ExecPolicyAmendment::new(vec![ + "cargo".to_string(), + "build".to_string() + ])), + } + ); + } + + #[tokio::test] + async fn proposed_execpolicy_amendment_uses_first_no_match_in_multi_command_scripts() { + let policy_src = r#"prefix_rule(pattern=["cat"], decision="allow")"#; + let mut parser = PolicyParser::new(); + parser + .parse("test.codexpolicy", policy_src) + .expect("parse policy"); + let policy = Arc::new(RwLock::new(parser.build())); + + let command = vec![ + "bash".to_string(), + "-lc".to_string(), + "cat && apple".to_string(), + ]; + + assert_eq!( + create_exec_approval_requirement_for_command( + &policy, + &Features::with_defaults(), + &command, + AskForApproval::UnlessTrusted, + &SandboxPolicy::ReadOnly, + SandboxPermissions::UseDefault, + ) + .await, + ExecApprovalRequirement::NeedsApproval { + reason: None, + proposed_execpolicy_amendment: Some(ExecPolicyAmendment::new(vec![ + "apple".to_string() + ])), } ); } diff --git a/codex-rs/core/src/tools/orchestrator.rs b/codex-rs/core/src/tools/orchestrator.rs index 5ac3c63509..4c34658fcd 100644 --- a/codex-rs/core/src/tools/orchestrator.rs +++ b/codex-rs/core/src/tools/orchestrator.rs @@ -95,7 +95,7 @@ impl ToolOrchestrator { return Err(ToolError::Rejected("rejected by user".to_string())); } ReviewDecision::Approved - | ReviewDecision::ApprovedAllowPrefix { .. } + | ReviewDecision::ApprovedExecpolicyAmendment { .. } | ReviewDecision::ApprovedForSession => {} } already_approved = true; @@ -178,7 +178,7 @@ impl ToolOrchestrator { return Err(ToolError::Rejected("rejected by user".to_string())); } ReviewDecision::Approved - | ReviewDecision::ApprovedAllowPrefix { .. } + | ReviewDecision::ApprovedExecpolicyAmendment { .. } | ReviewDecision::ApprovedForSession => {} } } diff --git a/codex-rs/core/src/tools/runtimes/shell.rs b/codex-rs/core/src/tools/runtimes/shell.rs index 48dc5b9990..2af095ee92 100644 --- a/codex-rs/core/src/tools/runtimes/shell.rs +++ b/codex-rs/core/src/tools/runtimes/shell.rs @@ -114,7 +114,9 @@ impl Approvable for ShellRuntime { cwd, reason, risk, - req.exec_approval_requirement.allow_prefix().cloned(), + req.exec_approval_requirement + .proposed_execpolicy_amendment() + .cloned(), ) .await }) diff --git a/codex-rs/core/src/tools/runtimes/unified_exec.rs b/codex-rs/core/src/tools/runtimes/unified_exec.rs index 45d804d688..4c1cbb83ec 100644 --- a/codex-rs/core/src/tools/runtimes/unified_exec.rs +++ b/codex-rs/core/src/tools/runtimes/unified_exec.rs @@ -132,7 +132,9 @@ impl Approvable for UnifiedExecRuntime<'_> { cwd, reason, risk, - req.exec_approval_requirement.allow_prefix().cloned(), + req.exec_approval_requirement + .proposed_execpolicy_amendment() + .cloned(), ) .await }) diff --git a/codex-rs/core/src/tools/sandboxing.rs b/codex-rs/core/src/tools/sandboxing.rs index 793c162936..94c81043cc 100644 --- a/codex-rs/core/src/tools/sandboxing.rs +++ b/codex-rs/core/src/tools/sandboxing.rs @@ -13,6 +13,7 @@ use crate::sandboxing::CommandSpec; use crate::sandboxing::SandboxManager; use crate::sandboxing::SandboxTransformError; use crate::state::SessionServices; +use codex_protocol::approvals::ExecPolicyAmendment; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::ReviewDecision; use std::collections::HashMap; @@ -98,18 +99,19 @@ pub(crate) enum ExecApprovalRequirement { /// Approval required for this tool call. NeedsApproval { reason: Option, - /// Prefix that can be whitelisted via execpolicy to skip future approvals for similar commands - allow_prefix: Option>, + /// Proposed execpolicy amendment to skip future approvals for similar commands + /// See core/src/exec_policy.rs for more details on how proposed_execpolicy_amendment is determined. + proposed_execpolicy_amendment: Option, }, /// Execution forbidden for this tool call. Forbidden { reason: String }, } impl ExecApprovalRequirement { - pub fn allow_prefix(&self) -> Option<&Vec> { + pub fn proposed_execpolicy_amendment(&self) -> Option<&ExecPolicyAmendment> { match self { Self::NeedsApproval { - allow_prefix: Some(prefix), + proposed_execpolicy_amendment: Some(prefix), .. } => Some(prefix), _ => None, @@ -133,7 +135,7 @@ pub(crate) fn default_exec_approval_requirement( if needs_approval { ExecApprovalRequirement::NeedsApproval { reason: None, - allow_prefix: None, + proposed_execpolicy_amendment: None, } } else { ExecApprovalRequirement::Skip { diff --git a/codex-rs/core/tests/suite/approvals.rs b/codex-rs/core/tests/suite/approvals.rs index 8ac31fb378..4570e6a5b9 100644 --- a/codex-rs/core/tests/suite/approvals.rs +++ b/codex-rs/core/tests/suite/approvals.rs @@ -6,6 +6,7 @@ use codex_core::protocol::ApplyPatchApprovalRequestEvent; use codex_core::protocol::AskForApproval; use codex_core::protocol::EventMsg; use codex_core::protocol::ExecApprovalRequestEvent; +use codex_core::protocol::ExecPolicyAmendment; use codex_core::protocol::Op; use codex_core::protocol::SandboxPolicy; use codex_protocol::config_types::ReasoningSummary; @@ -1560,7 +1561,7 @@ async fn run_scenario(scenario: &ScenarioSpec) -> Result<()> { #[tokio::test(flavor = "current_thread")] #[cfg(unix)] -async fn approving_allow_prefix_persists_policy_and_skips_future_prompts() -> Result<()> { +async fn approving_execpolicy_amendment_persists_policy_and_skips_future_prompts() -> Result<()> { let server = start_mock_server().await; let approval_policy = AskForApproval::UnlessTrusted; let sandbox_policy = SandboxPolicy::ReadOnly; @@ -1580,8 +1581,9 @@ async fn approving_allow_prefix_persists_policy_and_skips_future_prompts() -> Re .prepare(&test, &server, call_id_first, false) .await?; let expected_command = - expected_command.expect("allow prefix scenario should produce a shell command"); - let expected_allow_prefix = vec!["touch".to_string(), "allow-prefix.txt".to_string()]; + expected_command.expect("execpolicy amendment scenario should produce a shell command"); + let expected_execpolicy_amendment = + ExecPolicyAmendment::new(vec!["touch".to_string(), "allow-prefix.txt".to_string()]); let _ = mount_sse_once( &server, @@ -1610,13 +1612,16 @@ async fn approving_allow_prefix_persists_policy_and_skips_future_prompts() -> Re .await?; let approval = expect_exec_approval(&test, expected_command.as_str()).await; - assert_eq!(approval.allow_prefix, Some(expected_allow_prefix.clone())); + assert_eq!( + approval.proposed_execpolicy_amendment, + Some(expected_execpolicy_amendment.clone()) + ); test.codex .submit(Op::ExecApproval { id: "0".into(), - decision: ReviewDecision::ApprovedAllowPrefix { - allow_prefix: expected_allow_prefix.clone(), + decision: ReviewDecision::ApprovedExecpolicyAmendment { + proposed_execpolicy_amendment: expected_execpolicy_amendment.clone(), }, }) .await?; diff --git a/codex-rs/execpolicy/README.md b/codex-rs/execpolicy/README.md index 9fd9c63306..1ddb67252f 100644 --- a/codex-rs/execpolicy/README.md +++ b/codex-rs/execpolicy/README.md @@ -30,32 +30,24 @@ codex execpolicy check --policy path/to/policy.codexpolicy git status cargo run -p codex-execpolicy -- check --policy path/to/policy.codexpolicy git status ``` - Example outcomes: - - Match: `{"match": { ... "decision": "allow" ... }}` - - No match: `{"noMatch": {}}` + - Match: `{"matchedRules":[{...}],"decision":"allow"}` + - No match: `{"matchedRules":[]}` -## Response shapes -- Match: +## Response shape ```json { - "match": { - "decision": "allow|prompt|forbidden", - "matchedRules": [ - { - "prefixRuleMatch": { - "matchedPrefix": ["", "..."], - "decision": "allow|prompt|forbidden" - } + "matchedRules": [ + { + "prefixRuleMatch": { + "matchedPrefix": ["", "..."], + "decision": "allow|prompt|forbidden" } - ] - } + } + ], + "decision": "allow|prompt|forbidden" } ``` - -- No match: -```json -{"noMatch": {}} -``` - +- When no rules match, `matchedRules` is an empty array and `decision` is omitted. - `matchedRules` lists every rule whose prefix matched the command; `matchedPrefix` is the exact prefix that matched. - The effective `decision` is the strictest severity across all matches (`forbidden` > `prompt` > `allow`). diff --git a/codex-rs/execpolicy/src/execpolicycheck.rs b/codex-rs/execpolicy/src/execpolicycheck.rs index 0b5e0dcafc..939ed8590b 100644 --- a/codex-rs/execpolicy/src/execpolicycheck.rs +++ b/codex-rs/execpolicy/src/execpolicycheck.rs @@ -4,10 +4,12 @@ use std::path::PathBuf; use anyhow::Context; use anyhow::Result; use clap::Parser; +use serde::Serialize; -use crate::Evaluation; +use crate::Decision; use crate::Policy; use crate::PolicyParser; +use crate::RuleMatch; /// Arguments for evaluating a command against one or more execpolicy files. #[derive(Debug, Parser, Clone)] @@ -34,20 +36,25 @@ impl ExecPolicyCheckCommand { /// Load the policies for this command, evaluate the command, and render JSON output. pub fn run(&self) -> Result<()> { let policy = load_policies(&self.policies)?; - let evaluation = policy.check(&self.command); + let matched_rules = policy.matches_for_command(&self.command, None); - let json = format_evaluation_json(&evaluation, self.pretty)?; + let json = format_matches_json(&matched_rules, self.pretty)?; println!("{json}"); Ok(()) } } -pub fn format_evaluation_json(evaluation: &Evaluation, pretty: bool) -> Result { +pub fn format_matches_json(matched_rules: &[RuleMatch], pretty: bool) -> Result { + let output = ExecPolicyCheckOutput { + matched_rules, + decision: matched_rules.iter().map(RuleMatch::decision).max(), + }; + if pretty { - serde_json::to_string_pretty(evaluation).map_err(Into::into) + serde_json::to_string_pretty(&output).map_err(Into::into) } else { - serde_json::to_string(evaluation).map_err(Into::into) + serde_json::to_string(&output).map_err(Into::into) } } @@ -65,3 +72,12 @@ pub fn load_policies(policy_paths: &[PathBuf]) -> Result { Ok(parser.build()) } + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct ExecPolicyCheckOutput<'a> { + #[serde(rename = "matchedRules")] + matched_rules: &'a [RuleMatch], + #[serde(skip_serializing_if = "Option::is_none")] + decision: Option, +} diff --git a/codex-rs/execpolicy/src/main.rs b/codex-rs/execpolicy/src/main.rs index e1373b6d16..d3b34a3307 100644 --- a/codex-rs/execpolicy/src/main.rs +++ b/codex-rs/execpolicy/src/main.rs @@ -1,6 +1,6 @@ use anyhow::Result; use clap::Parser; -use codex_execpolicy::ExecPolicyCheckCommand; +use codex_execpolicy::execpolicycheck::ExecPolicyCheckCommand; /// CLI for evaluating exec policies #[derive(Parser)] @@ -13,10 +13,6 @@ enum Cli { fn main() -> Result<()> { let cli = Cli::parse(); match cli { - Cli::Check(cmd) => cmd_check(cmd), + Cli::Check(cmd) => cmd.run(), } } - -fn cmd_check(cmd: ExecPolicyCheckCommand) -> Result<()> { - cmd.run() -} diff --git a/codex-rs/execpolicy/src/policy.rs b/codex-rs/execpolicy/src/policy.rs index 10858c9fad..991e904ae9 100644 --- a/codex-rs/execpolicy/src/policy.rs +++ b/codex-rs/execpolicy/src/policy.rs @@ -11,6 +11,8 @@ use serde::Deserialize; use serde::Serialize; use std::sync::Arc; +type HeuristicsFallback<'a> = Option<&'a dyn Fn(&[String]) -> Decision>; + #[derive(Clone, Debug)] pub struct Policy { rules_by_program: MultiMap, @@ -50,62 +52,84 @@ impl Policy { Ok(()) } - pub fn check(&self, cmd: &[String]) -> Evaluation { - let rules = match cmd.first() { - Some(first) => match self.rules_by_program.get_vec(first) { - Some(rules) => rules, - None => return Evaluation::NoMatch {}, - }, - None => return Evaluation::NoMatch {}, - }; - - let matched_rules: Vec = - rules.iter().filter_map(|rule| rule.matches(cmd)).collect(); - match matched_rules.iter().map(RuleMatch::decision).max() { - Some(decision) => Evaluation::Match { - decision, - matched_rules, - }, - None => Evaluation::NoMatch {}, - } + pub fn check(&self, cmd: &[String], heuristics_fallback: &F) -> Evaluation + where + F: Fn(&[String]) -> Decision, + { + let matched_rules = self.matches_for_command(cmd, Some(heuristics_fallback)); + Evaluation::from_matches(matched_rules) } - pub fn check_multiple(&self, commands: Commands) -> Evaluation + pub fn check_multiple( + &self, + commands: Commands, + heuristics_fallback: &F, + ) -> Evaluation where Commands: IntoIterator, Commands::Item: AsRef<[String]>, + F: Fn(&[String]) -> Decision, { let matched_rules: Vec = commands .into_iter() - .flat_map(|command| match self.check(command.as_ref()) { - Evaluation::Match { matched_rules, .. } => matched_rules, - Evaluation::NoMatch { .. } => Vec::new(), + .flat_map(|command| { + self.matches_for_command(command.as_ref(), Some(heuristics_fallback)) }) .collect(); - match matched_rules.iter().map(RuleMatch::decision).max() { - Some(decision) => Evaluation::Match { - decision, - matched_rules, - }, - None => Evaluation::NoMatch {}, + Evaluation::from_matches(matched_rules) + } + + pub fn matches_for_command( + &self, + cmd: &[String], + heuristics_fallback: HeuristicsFallback<'_>, + ) -> Vec { + let mut matched_rules: Vec = match cmd.first() { + Some(first) => self + .rules_by_program + .get_vec(first) + .map(|rules| rules.iter().filter_map(|rule| rule.matches(cmd)).collect()) + .unwrap_or_default(), + None => Vec::new(), + }; + + if let (true, Some(heuristics_fallback)) = (matched_rules.is_empty(), heuristics_fallback) { + matched_rules.push(RuleMatch::HeuristicsRuleMatch { + command: cmd.to_vec(), + decision: heuristics_fallback(cmd), + }); } + + matched_rules } } #[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] -pub enum Evaluation { - NoMatch {}, - Match { - decision: Decision, - #[serde(rename = "matchedRules")] - matched_rules: Vec, - }, +pub struct Evaluation { + pub decision: Decision, + #[serde(rename = "matchedRules")] + pub matched_rules: Vec, } impl Evaluation { pub fn is_match(&self) -> bool { - matches!(self, Self::Match { .. }) + self.matched_rules + .iter() + .any(|rule_match| !matches!(rule_match, RuleMatch::HeuristicsRuleMatch { .. })) + } + + fn from_matches(matched_rules: Vec) -> Self { + let decision = matched_rules + .iter() + .map(RuleMatch::decision) + .max() + .unwrap_or(Decision::Allow); + + Self { + decision, + matched_rules, + } } } diff --git a/codex-rs/execpolicy/src/rule.rs b/codex-rs/execpolicy/src/rule.rs index 20e23fe6a2..cd0756bbb3 100644 --- a/codex-rs/execpolicy/src/rule.rs +++ b/codex-rs/execpolicy/src/rule.rs @@ -64,12 +64,17 @@ pub enum RuleMatch { matched_prefix: Vec, decision: Decision, }, + HeuristicsRuleMatch { + command: Vec, + decision: Decision, + }, } impl RuleMatch { pub fn decision(&self) -> Decision { match self { Self::PrefixRuleMatch { decision, .. } => *decision, + Self::HeuristicsRuleMatch { decision, .. } => *decision, } } } diff --git a/codex-rs/execpolicy/tests/basic.rs b/codex-rs/execpolicy/tests/basic.rs index 9a7ec58b1e..3ea33604ae 100644 --- a/codex-rs/execpolicy/tests/basic.rs +++ b/codex-rs/execpolicy/tests/basic.rs @@ -19,6 +19,14 @@ fn tokens(cmd: &[&str]) -> Vec { cmd.iter().map(std::string::ToString::to_string).collect() } +fn allow_all(_: &[String]) -> Decision { + Decision::Allow +} + +fn prompt_all(_: &[String]) -> Decision { + Decision::Prompt +} + #[derive(Clone, Debug, Eq, PartialEq)] enum RuleSnapshot { Prefix(PrefixRule), @@ -49,9 +57,9 @@ prefix_rule( parser.parse("test.codexpolicy", policy_src)?; let policy = parser.build(); let cmd = tokens(&["git", "status"]); - let evaluation = policy.check(&cmd); + let evaluation = policy.check(&cmd, &allow_all); assert_eq!( - Evaluation::Match { + Evaluation { decision: Decision::Allow, matched_rules: vec![RuleMatch::PrefixRuleMatch { matched_prefix: tokens(&["git", "status"]), @@ -80,9 +88,9 @@ fn add_prefix_rule_extends_policy() -> Result<()> { rules ); - let evaluation = policy.check(&tokens(&["ls", "-l", "/tmp"])); + let evaluation = policy.check(&tokens(&["ls", "-l", "/tmp"]), &allow_all); assert_eq!( - Evaluation::Match { + Evaluation { decision: Decision::Prompt, matched_rules: vec![RuleMatch::PrefixRuleMatch { matched_prefix: tokens(&["ls", "-l"]), @@ -146,9 +154,9 @@ prefix_rule( git_rules ); - let status_eval = policy.check(&tokens(&["git", "status"])); + let status_eval = policy.check(&tokens(&["git", "status"]), &allow_all); assert_eq!( - Evaluation::Match { + Evaluation { decision: Decision::Prompt, matched_rules: vec![RuleMatch::PrefixRuleMatch { matched_prefix: tokens(&["git"]), @@ -158,9 +166,9 @@ prefix_rule( status_eval ); - let commit_eval = policy.check(&tokens(&["git", "commit", "-m", "hi"])); + let commit_eval = policy.check(&tokens(&["git", "commit", "-m", "hi"]), &allow_all); assert_eq!( - Evaluation::Match { + Evaluation { decision: Decision::Forbidden, matched_rules: vec![ RuleMatch::PrefixRuleMatch { @@ -217,9 +225,9 @@ prefix_rule( sh_rules ); - let bash_eval = policy.check(&tokens(&["bash", "-c", "echo", "hi"])); + let bash_eval = policy.check(&tokens(&["bash", "-c", "echo", "hi"]), &allow_all); assert_eq!( - Evaluation::Match { + Evaluation { decision: Decision::Allow, matched_rules: vec![RuleMatch::PrefixRuleMatch { matched_prefix: tokens(&["bash", "-c"]), @@ -229,9 +237,9 @@ prefix_rule( bash_eval ); - let sh_eval = policy.check(&tokens(&["sh", "-l", "echo", "hi"])); + let sh_eval = policy.check(&tokens(&["sh", "-l", "echo", "hi"]), &allow_all); assert_eq!( - Evaluation::Match { + Evaluation { decision: Decision::Allow, matched_rules: vec![RuleMatch::PrefixRuleMatch { matched_prefix: tokens(&["sh", "-l"]), @@ -273,9 +281,9 @@ prefix_rule( rules ); - let npm_i = policy.check(&tokens(&["npm", "i", "--legacy-peer-deps"])); + let npm_i = policy.check(&tokens(&["npm", "i", "--legacy-peer-deps"]), &allow_all); assert_eq!( - Evaluation::Match { + Evaluation { decision: Decision::Allow, matched_rules: vec![RuleMatch::PrefixRuleMatch { matched_prefix: tokens(&["npm", "i", "--legacy-peer-deps"]), @@ -285,9 +293,12 @@ prefix_rule( npm_i ); - let npm_install = policy.check(&tokens(&["npm", "install", "--no-save", "leftpad"])); + let npm_install = policy.check( + &tokens(&["npm", "install", "--no-save", "leftpad"]), + &allow_all, + ); assert_eq!( - Evaluation::Match { + Evaluation { decision: Decision::Allow, matched_rules: vec![RuleMatch::PrefixRuleMatch { matched_prefix: tokens(&["npm", "install", "--no-save"]), @@ -314,9 +325,9 @@ prefix_rule( let mut parser = PolicyParser::new(); parser.parse("test.codexpolicy", policy_src)?; let policy = parser.build(); - let match_eval = policy.check(&tokens(&["git", "status"])); + let match_eval = policy.check(&tokens(&["git", "status"]), &allow_all); assert_eq!( - Evaluation::Match { + Evaluation { decision: Decision::Allow, matched_rules: vec![RuleMatch::PrefixRuleMatch { matched_prefix: tokens(&["git", "status"]), @@ -326,13 +337,20 @@ prefix_rule( match_eval ); - let no_match_eval = policy.check(&tokens(&[ - "git", - "--config", - "color.status=always", - "status", - ])); - assert_eq!(Evaluation::NoMatch {}, no_match_eval); + let no_match_eval = policy.check( + &tokens(&["git", "--config", "color.status=always", "status"]), + &allow_all, + ); + assert_eq!( + Evaluation { + decision: Decision::Allow, + matched_rules: vec![RuleMatch::HeuristicsRuleMatch { + command: tokens(&["git", "--config", "color.status=always", "status",]), + decision: Decision::Allow, + }], + }, + no_match_eval + ); Ok(()) } @@ -352,9 +370,9 @@ prefix_rule( parser.parse("test.codexpolicy", policy_src)?; let policy = parser.build(); - let commit = policy.check(&tokens(&["git", "commit", "-m", "hi"])); + let commit = policy.check(&tokens(&["git", "commit", "-m", "hi"]), &allow_all); assert_eq!( - Evaluation::Match { + Evaluation { decision: Decision::Forbidden, matched_rules: vec![ RuleMatch::PrefixRuleMatch { @@ -393,9 +411,9 @@ prefix_rule( tokens(&["git", "commit", "-m", "hi"]), ]; - let evaluation = policy.check_multiple(&commands); + let evaluation = policy.check_multiple(&commands, &allow_all); assert_eq!( - Evaluation::Match { + Evaluation { decision: Decision::Forbidden, matched_rules: vec![ RuleMatch::PrefixRuleMatch { @@ -416,3 +434,21 @@ prefix_rule( ); Ok(()) } + +#[test] +fn heuristics_match_is_returned_when_no_policy_matches() { + let policy = Policy::empty(); + let command = tokens(&["python"]); + + let evaluation = policy.check(&command, &prompt_all); + assert_eq!( + Evaluation { + decision: Decision::Prompt, + matched_rules: vec![RuleMatch::HeuristicsRuleMatch { + command, + decision: Decision::Prompt, + }], + }, + evaluation + ); +} diff --git a/codex-rs/mcp-server/src/codex_tool_runner.rs b/codex-rs/mcp-server/src/codex_tool_runner.rs index cbee0fe0e7..aa895d8dd3 100644 --- a/codex-rs/mcp-server/src/codex_tool_runner.rs +++ b/codex-rs/mcp-server/src/codex_tool_runner.rs @@ -180,7 +180,7 @@ async fn run_codex_tool_session_inner( call_id, reason: _, risk, - allow_prefix: _, + proposed_execpolicy_amendment: _, parsed_cmd, }) => { handle_exec_approval_request( diff --git a/codex-rs/protocol/src/approvals.rs b/codex-rs/protocol/src/approvals.rs index 54a9efca9f..c892b6ec99 100644 --- a/codex-rs/protocol/src/approvals.rs +++ b/codex-rs/protocol/src/approvals.rs @@ -17,6 +17,34 @@ pub enum SandboxRiskLevel { High, } +/// Proposed execpolicy change to allow commands starting with this prefix. +/// +/// The `command` tokens form the prefix that would be added as an execpolicy +/// `prefix_rule(..., decision="allow")`, letting the agent bypass approval for +/// commands that start with this token sequence. +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)] +#[serde(transparent)] +#[ts(type = "Array")] +pub struct ExecPolicyAmendment { + pub command: Vec, +} + +impl ExecPolicyAmendment { + pub fn new(command: Vec) -> Self { + Self { command } + } + + pub fn command(&self) -> &[String] { + &self.command + } +} + +impl From> for ExecPolicyAmendment { + fn from(command: Vec) -> Self { + Self { command } + } +} + #[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)] pub struct SandboxCommandAssessment { pub description: String, @@ -51,10 +79,10 @@ pub struct ExecApprovalRequestEvent { /// Optional model-provided risk assessment describing the blocked command. #[serde(skip_serializing_if = "Option::is_none")] pub risk: Option, - /// Prefix rule that can be added to the user's execpolicy to allow future runs. + /// Proposed execpolicy amendment that can be applied to allow future runs. #[serde(default, skip_serializing_if = "Option::is_none")] - #[ts(optional, type = "Array")] - pub allow_prefix: Option>, + #[ts(optional)] + pub proposed_execpolicy_amendment: Option, pub parsed_cmd: Vec, } diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index ef06c2e1dc..4089c79373 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -39,6 +39,7 @@ use ts_rs::TS; pub use crate::approvals::ApplyPatchApprovalRequestEvent; pub use crate::approvals::ElicitationAction; pub use crate::approvals::ExecApprovalRequestEvent; +pub use crate::approvals::ExecPolicyAmendment; pub use crate::approvals::SandboxCommandAssessment; pub use crate::approvals::SandboxRiskLevel; @@ -1655,9 +1656,11 @@ pub enum ReviewDecision { /// User has approved this command and the agent should execute it. Approved, - /// User has approved this command and wants to add the command prefix to - /// the execpolicy allow list so future matching commands are permitted. - ApprovedAllowPrefix { allow_prefix: Vec }, + /// User has approved this command and wants to apply the proposed execpolicy + /// amendment so future matching commands are permitted. + ApprovedExecpolicyAmendment { + proposed_execpolicy_amendment: ExecPolicyAmendment, + }, /// User has approved this command and wants to automatically approve any /// future identical instances (`command` and `cwd` match exactly) for the diff --git a/codex-rs/tui/src/bottom_pane/approval_overlay.rs b/codex-rs/tui/src/bottom_pane/approval_overlay.rs index 3defdbf9e3..7a8a64948e 100644 --- a/codex-rs/tui/src/bottom_pane/approval_overlay.rs +++ b/codex-rs/tui/src/bottom_pane/approval_overlay.rs @@ -17,6 +17,7 @@ use crate::render::highlight::highlight_bash_to_lines; use crate::render::renderable::ColumnRenderable; use crate::render::renderable::Renderable; use codex_core::protocol::ElicitationAction; +use codex_core::protocol::ExecPolicyAmendment; use codex_core::protocol::FileChange; use codex_core::protocol::Op; use codex_core::protocol::ReviewDecision; @@ -43,7 +44,7 @@ pub(crate) enum ApprovalRequest { command: Vec, reason: Option, risk: Option, - allow_prefix: Option>, + proposed_execpolicy_amendment: Option, }, ApplyPatch { id: String, @@ -105,8 +106,11 @@ impl ApprovalOverlay { header: Box, ) -> (Vec, SelectionViewParams) { let (options, title) = match &variant { - ApprovalVariant::Exec { allow_prefix, .. } => ( - exec_options(allow_prefix.clone()), + ApprovalVariant::Exec { + proposed_execpolicy_amendment, + .. + } => ( + exec_options(proposed_execpolicy_amendment.clone()), "Would you like to run the following command?".to_string(), ), ApprovalVariant::ApplyPatch { .. } => ( @@ -337,7 +341,7 @@ impl From for ApprovalRequestState { command, reason, risk, - allow_prefix, + proposed_execpolicy_amendment, } => { let reason = reason.filter(|item| !item.is_empty()); let has_reason = reason.is_some(); @@ -360,7 +364,7 @@ impl From for ApprovalRequestState { variant: ApprovalVariant::Exec { id, command, - allow_prefix, + proposed_execpolicy_amendment, }, header: Box::new(Paragraph::new(header).wrap(Wrap { trim: false })), } @@ -437,7 +441,7 @@ enum ApprovalVariant { Exec { id: String, command: Vec, - allow_prefix: Option>, + proposed_execpolicy_amendment: Option, }, ApplyPatch { id: String, @@ -470,7 +474,7 @@ impl ApprovalOption { } } -fn exec_options(allow_prefix: Option>) -> Vec { +fn exec_options(proposed_execpolicy_amendment: Option) -> Vec { vec![ ApprovalOption { label: "Yes, proceed".to_string(), @@ -486,10 +490,10 @@ fn exec_options(allow_prefix: Option>) -> Vec { }, ] .into_iter() - .chain(allow_prefix.map(|prefix| ApprovalOption { + .chain(proposed_execpolicy_amendment.map(|prefix| ApprovalOption { label: "Yes, and don't ask again for commands with this prefix".to_string(), - decision: ApprovalDecision::Review(ReviewDecision::ApprovedAllowPrefix { - allow_prefix: prefix, + decision: ApprovalDecision::Review(ReviewDecision::ApprovedExecpolicyAmendment { + proposed_execpolicy_amendment: prefix, }), display_shortcut: None, additional_shortcuts: vec![key_hint::plain(KeyCode::Char('p'))], @@ -556,7 +560,7 @@ mod tests { command: vec!["echo".to_string(), "hi".to_string()], reason: Some("reason".to_string()), risk: None, - allow_prefix: None, + proposed_execpolicy_amendment: None, } } @@ -590,7 +594,7 @@ mod tests { } #[test] - fn exec_prefix_option_emits_allow_prefix() { + fn exec_prefix_option_emits_execpolicy_amendment() { let (tx, mut rx) = unbounded_channel::(); let tx = AppEventSender::new(tx); let mut view = ApprovalOverlay::new( @@ -599,7 +603,9 @@ mod tests { command: vec!["echo".to_string()], reason: None, risk: None, - allow_prefix: Some(vec!["echo".to_string()]), + proposed_execpolicy_amendment: Some(ExecPolicyAmendment::new(vec![ + "echo".to_string(), + ])), }, tx, ); @@ -609,8 +615,10 @@ mod tests { if let AppEvent::CodexOp(Op::ExecApproval { decision, .. }) = ev { assert_eq!( decision, - ReviewDecision::ApprovedAllowPrefix { - allow_prefix: vec!["echo".to_string()] + ReviewDecision::ApprovedExecpolicyAmendment { + proposed_execpolicy_amendment: ExecPolicyAmendment::new(vec![ + "echo".to_string() + ]) } ); saw_op = true; @@ -619,7 +627,7 @@ mod tests { } assert!( saw_op, - "expected approval decision to emit an op with allow prefix" + "expected approval decision to emit an op with command prefix" ); } @@ -633,7 +641,7 @@ mod tests { command, reason: None, risk: None, - allow_prefix: None, + proposed_execpolicy_amendment: None, }; let view = ApprovalOverlay::new(exec_request, tx); diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index 844442c24c..5ebb492a26 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -570,7 +570,7 @@ mod tests { command: vec!["echo".into(), "ok".into()], reason: None, risk: None, - allow_prefix: None, + proposed_execpolicy_amendment: None, } } diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 4aa397ad90..2684fa9ff9 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -1074,7 +1074,7 @@ impl ChatWidget { command: ev.command, reason: ev.reason, risk: ev.risk, - allow_prefix: ev.allow_prefix, + proposed_execpolicy_amendment: ev.proposed_execpolicy_amendment, }; self.bottom_pane.push_approval_request(request); self.request_redraw(); diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index 2b75f960bd..abce9d4283 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -23,6 +23,7 @@ use codex_core::protocol::ExecApprovalRequestEvent; use codex_core::protocol::ExecCommandBeginEvent; use codex_core::protocol::ExecCommandEndEvent; use codex_core::protocol::ExecCommandSource; +use codex_core::protocol::ExecPolicyAmendment; use codex_core::protocol::ExitedReviewModeEvent; use codex_core::protocol::FileChange; use codex_core::protocol::Op; @@ -688,7 +689,7 @@ fn exec_approval_emits_proposed_command_and_decision_history() { "this is a test reason such as one that would be produced by the model".into(), ), risk: None, - allow_prefix: None, + proposed_execpolicy_amendment: None, parsed_cmd: vec![], }; chat.handle_codex_event(Event { @@ -733,7 +734,7 @@ fn exec_approval_decision_truncates_multiline_and_long_commands() { "this is a test reason such as one that would be produced by the model".into(), ), risk: None, - allow_prefix: None, + proposed_execpolicy_amendment: None, parsed_cmd: vec![], }; chat.handle_codex_event(Event { @@ -784,7 +785,7 @@ fn exec_approval_decision_truncates_multiline_and_long_commands() { cwd: std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")), reason: None, risk: None, - allow_prefix: None, + proposed_execpolicy_amendment: None, parsed_cmd: vec![], }; chat.handle_codex_event(Event { @@ -1993,7 +1994,11 @@ fn approval_modal_exec_snapshot() { "this is a test reason such as one that would be produced by the model".into(), ), risk: None, - allow_prefix: Some(vec!["echo".into(), "hello".into(), "world".into()]), + proposed_execpolicy_amendment: Some(ExecPolicyAmendment::new(vec![ + "echo".into(), + "hello".into(), + "world".into(), + ])), parsed_cmd: vec![], }; chat.handle_codex_event(Event { @@ -2040,7 +2045,11 @@ fn approval_modal_exec_without_reason_snapshot() { cwd: std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")), reason: None, risk: None, - allow_prefix: Some(vec!["echo".into(), "hello".into(), "world".into()]), + proposed_execpolicy_amendment: Some(ExecPolicyAmendment::new(vec![ + "echo".into(), + "hello".into(), + "world".into(), + ])), parsed_cmd: vec![], }; chat.handle_codex_event(Event { @@ -2254,7 +2263,10 @@ fn status_widget_and_approval_modal_snapshot() { "this is a test reason such as one that would be produced by the model".into(), ), risk: None, - allow_prefix: Some(vec!["echo".into(), "hello world".into()]), + proposed_execpolicy_amendment: Some(ExecPolicyAmendment::new(vec![ + "echo".into(), + "hello world".into(), + ])), parsed_cmd: vec![], }; chat.handle_codex_event(Event { diff --git a/codex-rs/tui/src/history_cell.rs b/codex-rs/tui/src/history_cell.rs index 90a54268cb..bdcaca7bea 100644 --- a/codex-rs/tui/src/history_cell.rs +++ b/codex-rs/tui/src/history_cell.rs @@ -409,7 +409,7 @@ pub fn new_approval_decision_cell( ], ) } - ApprovedAllowPrefix { .. } => { + ApprovedExecpolicyAmendment { .. } => { let snippet = Span::from(exec_snippet(&command)).dim(); ( "✔ ".green(), @@ -418,7 +418,7 @@ pub fn new_approval_decision_cell( "approved".bold(), " codex to run ".into(), snippet, - " and added its prefix to your allow list".bold(), + " and applied the execpolicy amendment".bold(), ], ) } diff --git a/docs/config.md b/docs/config.md index 6aa1bde8c5..3b06d73019 100644 --- a/docs/config.md +++ b/docs/config.md @@ -603,7 +603,7 @@ metadata above): - `codex.tool_decision` - `tool_name` - `call_id` - - `decision` (`approved`, `approved_allow_prefix`, `approved_for_session`, `denied`, or `abort`) + - `decision` (`approved`, `approved_execpolicy_amendment`, `approved_for_session`, `denied`, or `abort`) - `source` (`config` or `user`) - `codex.tool_result` - `tool_name` diff --git a/docs/execpolicy.md b/docs/execpolicy.md index a5b77e402e..d543a414e7 100644 --- a/docs/execpolicy.md +++ b/docs/execpolicy.md @@ -33,6 +33,30 @@ codex execpolicy check --policy ~/.codex/policy/default.codexpolicy git push ori Pass multiple `--policy` flags to test how several files combine, and use `--pretty` for formatted JSON output. See the [`codex-rs/execpolicy` README](../codex-rs/execpolicy/README.md) for a more detailed walkthrough of the available syntax. +Example output when a rule matches: + +```json +{ + "matchedRules": [ + { + "prefixRuleMatch": { + "matchedPrefix": ["git", "push"], + "decision": "prompt" + } + } + ], + "decision": "prompt" +} +``` + +When no rules match, `matchedRules` is an empty array and `decision` is omitted. + +```json +{ + "matchedRules": [] +} +``` + ## Status `execpolicy` commands are still in preview. The API may have breaking changes in the future. From 871f44f385452d324bb465ea8949d94943347b85 Mon Sep 17 00:00:00 2001 From: ae Date: Wed, 3 Dec 2025 23:47:46 -0800 Subject: [PATCH 29/30] Add Enterprise plan to ChatGPT login description (#6918) ## Summary - update ChatGPT onboarding login description to mention Enterprise plans alongside Plus, Pro, and Team ## Testing - just fmt ------ [Codex Task](https://chatgpt.com/codex/tasks/task_i_691e088daf20832c88d8b667adf45128) --- codex-rs/tui/src/onboarding/auth.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/codex-rs/tui/src/onboarding/auth.rs b/codex-rs/tui/src/onboarding/auth.rs index 04096ce0ec..86ddcfb64e 100644 --- a/codex-rs/tui/src/onboarding/auth.rs +++ b/codex-rs/tui/src/onboarding/auth.rs @@ -217,7 +217,7 @@ impl AuthModeWidget { }; let chatgpt_description = if self.is_chatgpt_login_allowed() { - "Usage included with Plus, Pro, and Team plans" + "Usage included with Plus, Pro, Team, and Enterprise plans" } else { "ChatGPT login is disabled" }; From 87666695ba99a98f8eb26982394f314358f8597b Mon Sep 17 00:00:00 2001 From: zhao-oai Date: Thu, 4 Dec 2025 02:58:13 -0500 Subject: [PATCH 30/30] execpolicy tui flow (#7543) ## Updating the `execpolicy` TUI flow In the TUI, when going through the command approval flow, codex will now ask the user if they would like to whitelist the FIRST unmatched command among a chain of commands. For example, let's say the agent wants to run `apple | pear` with an empty `execpolicy` Neither apple nor pear will match to an `execpolicy` rule. Thus, when prompting the user, codex tui will ask the user if they would like to whitelist `apple`. If the agent wants to run `apple | pear` again, they would be prompted again because pear is still unknown. when prompted, the user will now be asked if they'd like to whitelist `pear`. Here's a demo video of this flow: https://github.com/user-attachments/assets/fd160717-f6cb-46b0-9f4a-f0a974d4e710 This PR also removed the `allow for this session` option from the TUI. --- .../tui/src/bottom_pane/approval_overlay.rs | 102 ++++++++++++------ codex-rs/tui/src/bottom_pane/mod.rs | 14 ++- codex-rs/tui/src/chatwidget.rs | 9 +- ...hatwidget__tests__approval_modal_exec.snap | 8 +- ..._tests__approval_modal_exec_no_reason.snap | 5 +- ...dget__tests__exec_approval_modal_exec.snap | 13 +-- ...sts__status_widget_and_approval_modal.snap | 28 +++-- codex-rs/tui/src/chatwidget/tests.rs | 20 ++-- 8 files changed, 120 insertions(+), 79 deletions(-) diff --git a/codex-rs/tui/src/bottom_pane/approval_overlay.rs b/codex-rs/tui/src/bottom_pane/approval_overlay.rs index 7a8a64948e..768fe030d4 100644 --- a/codex-rs/tui/src/bottom_pane/approval_overlay.rs +++ b/codex-rs/tui/src/bottom_pane/approval_overlay.rs @@ -16,6 +16,8 @@ use crate::key_hint::KeyBinding; use crate::render::highlight::highlight_bash_to_lines; use crate::render::renderable::ColumnRenderable; use crate::render::renderable::Renderable; +use codex_core::features::Feature; +use codex_core::features::Features; use codex_core::protocol::ElicitationAction; use codex_core::protocol::ExecPolicyAmendment; use codex_core::protocol::FileChange; @@ -69,10 +71,11 @@ pub(crate) struct ApprovalOverlay { options: Vec, current_complete: bool, done: bool, + features: Features, } impl ApprovalOverlay { - pub fn new(request: ApprovalRequest, app_event_tx: AppEventSender) -> Self { + pub fn new(request: ApprovalRequest, app_event_tx: AppEventSender, features: Features) -> Self { let mut view = Self { current_request: None, current_variant: None, @@ -82,6 +85,7 @@ impl ApprovalOverlay { options: Vec::new(), current_complete: false, done: false, + features, }; view.set_current(request); view @@ -96,7 +100,7 @@ impl ApprovalOverlay { let ApprovalRequestState { variant, header } = ApprovalRequestState::from(request); self.current_variant = Some(variant.clone()); self.current_complete = false; - let (options, params) = Self::build_options(variant, header); + let (options, params) = Self::build_options(variant, header, &self.features); self.options = options; self.list = ListSelectionView::new(params, self.app_event_tx.clone()); } @@ -104,13 +108,14 @@ impl ApprovalOverlay { fn build_options( variant: ApprovalVariant, header: Box, + features: &Features, ) -> (Vec, SelectionViewParams) { let (options, title) = match &variant { ApprovalVariant::Exec { proposed_execpolicy_amendment, .. } => ( - exec_options(proposed_execpolicy_amendment.clone()), + exec_options(proposed_execpolicy_amendment.clone(), features), "Would you like to run the following command?".to_string(), ), ApprovalVariant::ApplyPatch { .. } => ( @@ -474,30 +479,36 @@ impl ApprovalOption { } } -fn exec_options(proposed_execpolicy_amendment: Option) -> Vec { - vec![ - ApprovalOption { - label: "Yes, proceed".to_string(), - decision: ApprovalDecision::Review(ReviewDecision::Approved), - display_shortcut: None, - additional_shortcuts: vec![key_hint::plain(KeyCode::Char('y'))], - }, - ApprovalOption { - label: "Yes, and don't ask again this session".to_string(), - decision: ApprovalDecision::Review(ReviewDecision::ApprovedForSession), - display_shortcut: None, - additional_shortcuts: vec![key_hint::plain(KeyCode::Char('a'))], - }, - ] - .into_iter() - .chain(proposed_execpolicy_amendment.map(|prefix| ApprovalOption { - label: "Yes, and don't ask again for commands with this prefix".to_string(), - decision: ApprovalDecision::Review(ReviewDecision::ApprovedExecpolicyAmendment { - proposed_execpolicy_amendment: prefix, - }), +fn exec_options( + proposed_execpolicy_amendment: Option, + features: &Features, +) -> Vec { + vec![ApprovalOption { + label: "Yes, proceed".to_string(), + decision: ApprovalDecision::Review(ReviewDecision::Approved), display_shortcut: None, - additional_shortcuts: vec![key_hint::plain(KeyCode::Char('p'))], - })) + additional_shortcuts: vec![key_hint::plain(KeyCode::Char('y'))], + }] + .into_iter() + .chain( + proposed_execpolicy_amendment + .filter(|_| features.enabled(Feature::ExecPolicy)) + .map(|prefix| { + let rendered_prefix = strip_bash_lc_and_escape(prefix.command()); + ApprovalOption { + label: format!( + "Yes, and don't ask again for commands that start with `{rendered_prefix}`" + ), + decision: ApprovalDecision::Review( + ReviewDecision::ApprovedExecpolicyAmendment { + proposed_execpolicy_amendment: prefix, + }, + ), + display_shortcut: None, + additional_shortcuts: vec![key_hint::plain(KeyCode::Char('p'))], + } + }), + ) .chain([ApprovalOption { label: "No, and tell Codex what to do differently".to_string(), decision: ApprovalDecision::Review(ReviewDecision::Abort), @@ -568,7 +579,7 @@ mod tests { fn ctrl_c_aborts_and_clears_queue() { let (tx, _rx) = unbounded_channel::(); let tx = AppEventSender::new(tx); - let mut view = ApprovalOverlay::new(make_exec_request(), tx); + let mut view = ApprovalOverlay::new(make_exec_request(), tx, Features::with_defaults()); view.enqueue_request(make_exec_request()); assert_eq!(CancellationEvent::Handled, view.on_ctrl_c()); assert!(view.queue.is_empty()); @@ -579,7 +590,7 @@ mod tests { fn shortcut_triggers_selection() { let (tx, mut rx) = unbounded_channel::(); let tx = AppEventSender::new(tx); - let mut view = ApprovalOverlay::new(make_exec_request(), tx); + let mut view = ApprovalOverlay::new(make_exec_request(), tx, Features::with_defaults()); assert!(!view.is_complete()); view.handle_key_event(KeyEvent::new(KeyCode::Char('y'), KeyModifiers::NONE)); // We expect at least one CodexOp message in the queue. @@ -608,6 +619,7 @@ mod tests { ])), }, tx, + Features::with_defaults(), ); view.handle_key_event(KeyEvent::new(KeyCode::Char('p'), KeyModifiers::NONE)); let mut saw_op = false; @@ -631,6 +643,33 @@ mod tests { ); } + #[test] + fn exec_prefix_option_hidden_when_execpolicy_disabled() { + let (tx, mut rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx); + let mut view = ApprovalOverlay::new( + ApprovalRequest::Exec { + id: "test".to_string(), + command: vec!["echo".to_string()], + reason: None, + risk: None, + proposed_execpolicy_amendment: Some(ExecPolicyAmendment::new(vec![ + "echo".to_string(), + ])), + }, + tx, + { + let mut features = Features::with_defaults(); + features.disable(Feature::ExecPolicy); + features + }, + ); + assert_eq!(view.options.len(), 2); + view.handle_key_event(KeyEvent::new(KeyCode::Char('p'), KeyModifiers::NONE)); + assert!(!view.is_complete()); + assert!(rx.try_recv().is_err()); + } + #[test] fn header_includes_command_snippet() { let (tx, _rx) = unbounded_channel::(); @@ -644,7 +683,7 @@ mod tests { proposed_execpolicy_amendment: None, }; - let view = ApprovalOverlay::new(exec_request, tx); + let view = ApprovalOverlay::new(exec_request, tx, Features::with_defaults()); let mut buf = Buffer::empty(Rect::new(0, 0, 80, view.desired_height(80))); view.render(Rect::new(0, 0, 80, view.desired_height(80)), &mut buf); @@ -694,8 +733,7 @@ mod tests { fn enter_sets_last_selected_index_without_dismissing() { let (tx_raw, mut rx) = unbounded_channel::(); let tx = AppEventSender::new(tx_raw); - let mut view = ApprovalOverlay::new(make_exec_request(), tx); - view.handle_key_event(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE)); + let mut view = ApprovalOverlay::new(make_exec_request(), tx, Features::with_defaults()); view.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); assert!( @@ -710,6 +748,6 @@ mod tests { break; } } - assert_eq!(decision, Some(ReviewDecision::ApprovedForSession)); + assert_eq!(decision, Some(ReviewDecision::Approved)); } } diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index 5ebb492a26..8a4336f6fe 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -8,6 +8,7 @@ use crate::render::renderable::Renderable; use crate::render::renderable::RenderableItem; use crate::tui::FrameRequester; use bottom_pane_view::BottomPaneView; +use codex_core::features::Features; use codex_core::skills::model::SkillMetadata; use codex_file_search::FileMatch; use crossterm::event::KeyCode; @@ -409,7 +410,7 @@ impl BottomPane { } /// Called when the agent requests user approval. - pub fn push_approval_request(&mut self, request: ApprovalRequest) { + pub fn push_approval_request(&mut self, request: ApprovalRequest, features: &Features) { let request = if let Some(view) = self.view_stack.last_mut() { match view.try_consume_approval_request(request) { Some(request) => request, @@ -423,7 +424,7 @@ impl BottomPane { }; // Otherwise create a new approval modal overlay. - let modal = ApprovalOverlay::new(request, self.app_event_tx.clone()); + let modal = ApprovalOverlay::new(request, self.app_event_tx.clone(), features.clone()); self.pause_status_timer_for_modal(); self.push_view(Box::new(modal)); } @@ -578,6 +579,7 @@ mod tests { fn ctrl_c_on_modal_consumes_and_shows_quit_hint() { let (tx_raw, _rx) = unbounded_channel::(); let tx = AppEventSender::new(tx_raw); + let features = Features::with_defaults(); let mut pane = BottomPane::new(BottomPaneParams { app_event_tx: tx, frame_requester: FrameRequester::test_dummy(), @@ -588,7 +590,7 @@ mod tests { animations_enabled: true, skills: Some(Vec::new()), }); - pane.push_approval_request(exec_request()); + pane.push_approval_request(exec_request(), &features); assert_eq!(CancellationEvent::Handled, pane.on_ctrl_c()); assert!(pane.ctrl_c_quit_hint_visible()); assert_eq!(CancellationEvent::NotHandled, pane.on_ctrl_c()); @@ -600,6 +602,7 @@ mod tests { fn overlay_not_shown_above_approval_modal() { let (tx_raw, _rx) = unbounded_channel::(); let tx = AppEventSender::new(tx_raw); + let features = Features::with_defaults(); let mut pane = BottomPane::new(BottomPaneParams { app_event_tx: tx, frame_requester: FrameRequester::test_dummy(), @@ -612,7 +615,7 @@ mod tests { }); // Create an approval modal (active view). - pane.push_approval_request(exec_request()); + pane.push_approval_request(exec_request(), &features); // Render and verify the top row does not include an overlay. let area = Rect::new(0, 0, 60, 6); @@ -633,6 +636,7 @@ mod tests { fn composer_shown_after_denied_while_task_running() { let (tx_raw, _rx) = unbounded_channel::(); let tx = AppEventSender::new(tx_raw); + let features = Features::with_defaults(); let mut pane = BottomPane::new(BottomPaneParams { app_event_tx: tx, frame_requester: FrameRequester::test_dummy(), @@ -648,7 +652,7 @@ mod tests { pane.set_task_running(true); // Push an approval modal (e.g., command approval) which should hide the status view. - pane.push_approval_request(exec_request()); + pane.push_approval_request(exec_request(), &features); // Simulate pressing 'n' (No) on the modal. use crossterm::event::KeyCode; diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 2684fa9ff9..8f9db3b9d9 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -1076,7 +1076,8 @@ impl ChatWidget { risk: ev.risk, proposed_execpolicy_amendment: ev.proposed_execpolicy_amendment, }; - self.bottom_pane.push_approval_request(request); + self.bottom_pane + .push_approval_request(request, &self.config.features); self.request_redraw(); } @@ -1093,7 +1094,8 @@ impl ChatWidget { changes: ev.changes.clone(), cwd: self.config.cwd.clone(), }; - self.bottom_pane.push_approval_request(request); + self.bottom_pane + .push_approval_request(request, &self.config.features); self.request_redraw(); self.notify(Notification::EditApprovalRequested { cwd: self.config.cwd.clone(), @@ -1113,7 +1115,8 @@ impl ChatWidget { request_id: ev.id, message: ev.message, }; - self.bottom_pane.push_approval_request(request); + self.bottom_pane + .push_approval_request(request, &self.config.features); self.request_redraw(); } diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__approval_modal_exec.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__approval_modal_exec.snap index eaf0fed3a1..ca093f271a 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__approval_modal_exec.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__approval_modal_exec.snap @@ -4,14 +4,12 @@ expression: terminal.backend().vt100().screen().contents() --- Would you like to run the following command? - Reason: this is a test reason such as one that would be produced by the - model + Reason: this is a test reason such as one that would be produced by the model $ echo hello world › 1. Yes, proceed (y) - 2. Yes, and don't ask again this session (a) - 3. Yes, and don't ask again for commands with this prefix (p) - 4. No, and tell Codex what to do differently (esc) + 2. Yes, and don't ask again for commands that start with `echo hello world` (p) + 3. No, and tell Codex what to do differently (esc) Press enter to confirm or esc to cancel diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__approval_modal_exec_no_reason.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__approval_modal_exec_no_reason.snap index ce2277ce62..2bbe9aefcd 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__approval_modal_exec_no_reason.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__approval_modal_exec_no_reason.snap @@ -7,8 +7,7 @@ expression: terminal.backend().vt100().screen().contents() $ echo hello world › 1. Yes, proceed (y) - 2. Yes, and don't ask again this session (a) - 3. Yes, and don't ask again for commands with this prefix (p) - 4. No, and tell Codex what to do differently (esc) + 2. Yes, and don't ask again for commands that start with `echo hello world` (p) + 3. No, and tell Codex what to do differently (esc) Press enter to confirm or esc to cancel diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__exec_approval_modal_exec.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__exec_approval_modal_exec.snap index ff70d7d492..1c6a3ef136 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__exec_approval_modal_exec.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__exec_approval_modal_exec.snap @@ -3,7 +3,7 @@ source: tui/src/chatwidget/tests.rs expression: "format!(\"{buf:?}\")" --- Buffer { - area: Rect { x: 0, y: 0, width: 80, height: 14 }, + area: Rect { x: 0, y: 0, width: 80, height: 13 }, content: [ " ", " ", @@ -15,8 +15,7 @@ Buffer { " $ echo hello world ", " ", "› 1. Yes, proceed (y) ", - " 2. Yes, and don't ask again this session (a) ", - " 3. No, and tell Codex what to do differently (esc) ", + " 2. No, and tell Codex what to do differently (esc) ", " ", " Press enter to confirm or esc to cancel ", ], @@ -30,10 +29,8 @@ Buffer { x: 7, y: 5, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, x: 0, y: 9, fg: Cyan, bg: Reset, underline: Reset, modifier: BOLD, x: 21, y: 9, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, - x: 44, y: 10, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, - x: 45, y: 10, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, - x: 48, y: 11, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, - x: 51, y: 11, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, - x: 2, y: 13, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 48, y: 10, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, + x: 51, y: 10, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, + x: 2, y: 12, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, ] } diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__status_widget_and_approval_modal.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__status_widget_and_approval_modal.snap index 5ce388c87b..5e6e33dece 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__status_widget_and_approval_modal.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__status_widget_and_approval_modal.snap @@ -2,18 +2,16 @@ source: tui/src/chatwidget/tests.rs expression: terminal.backend() --- -" " -" " -" Would you like to run the following command? " -" " -" Reason: this is a test reason such as one that would be produced by the " -" model " -" " -" $ echo 'hello world' " -" " -"› 1. Yes, proceed (y) " -" 2. Yes, and don't ask again this session (a) " -" 3. Yes, and don't ask again for commands with this prefix (p) " -" 4. No, and tell Codex what to do differently (esc) " -" " -" Press enter to confirm or esc to cancel " +" " +" " +" Would you like to run the following command? " +" " +" Reason: this is a test reason such as one that would be produced by the model " +" " +" $ echo 'hello world' " +" " +"› 1. Yes, proceed (y) " +" 2. Yes, and don't ask again for commands that start with `echo 'hello world'` (p) " +" 3. No, and tell Codex what to do differently (esc) " +" " +" Press enter to confirm or esc to cancel " diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index abce9d4283..ef1de1fde3 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -2007,11 +2007,12 @@ fn approval_modal_exec_snapshot() { }); // Render to a fixed-size test terminal and snapshot. // Call desired_height first and use that exact height for rendering. - let height = chat.desired_height(80); + let width = 100; + let height = chat.desired_height(width); let mut terminal = - crate::custom_terminal::Terminal::with_options(VT100Backend::new(80, height)) + crate::custom_terminal::Terminal::with_options(VT100Backend::new(width, height)) .expect("create terminal"); - let viewport = Rect::new(0, 0, 80, height); + let viewport = Rect::new(0, 0, width, height); terminal.set_viewport_area(viewport); terminal @@ -2057,10 +2058,11 @@ fn approval_modal_exec_without_reason_snapshot() { msg: EventMsg::ExecApprovalRequest(ev), }); - let height = chat.desired_height(80); + let width = 100; + let height = chat.desired_height(width); let mut terminal = - ratatui::Terminal::new(VT100Backend::new(80, height)).expect("create terminal"); - terminal.set_viewport_area(Rect::new(0, 0, 80, height)); + ratatui::Terminal::new(VT100Backend::new(width, height)).expect("create terminal"); + terminal.set_viewport_area(Rect::new(0, 0, width, height)); terminal .draw(|f| chat.render(f.area(), f.buffer_mut())) .expect("draw approval modal (no reason)"); @@ -2275,9 +2277,11 @@ fn status_widget_and_approval_modal_snapshot() { }); // Render at the widget's desired height and snapshot. - let height = chat.desired_height(80); - let mut terminal = ratatui::Terminal::new(ratatui::backend::TestBackend::new(80, height)) + let width: u16 = 100; + let height = chat.desired_height(width); + let mut terminal = ratatui::Terminal::new(ratatui::backend::TestBackend::new(width, height)) .expect("create terminal"); + terminal.set_viewport_area(Rect::new(0, 0, width, height)); terminal .draw(|f| chat.render(f.area(), f.buffer_mut())) .expect("draw status + approval modal");