From 18b2b30841aaba88d6e37ef6cdde57e99f8d6564 Mon Sep 17 00:00:00 2001 From: Dylan Date: Mon, 21 Jul 2025 21:01:56 -0700 Subject: [PATCH 01/58] [mcp-server] Add reply tool call (#1643) ## Summary Adds a new mcp tool call, `codex-reply`, so we can continue existing sessions. This is a first draft and does not yet support sessions from previous processes. ## Testing - [x] tested with mcp client --- codex-rs/Cargo.lock | 1 + codex-rs/cli/src/proto.rs | 2 +- codex-rs/core/src/codex.rs | 15 +- codex-rs/core/src/codex_wrapper.rs | 7 +- codex-rs/core/tests/client.rs | 2 +- codex-rs/core/tests/live_agent.rs | 3 +- codex-rs/core/tests/previous_response_id.rs | 2 +- codex-rs/core/tests/stream_no_completed.rs | 2 +- codex-rs/exec/src/lib.rs | 2 +- codex-rs/mcp-server/Cargo.toml | 1 + codex-rs/mcp-server/src/codex_tool_config.rs | 71 ++++++++ codex-rs/mcp-server/src/codex_tool_runner.rs | 56 ++++++- codex-rs/mcp-server/src/message_processor.rs | 167 ++++++++++++++++--- codex-rs/tui/src/chatwidget.rs | 17 +- 14 files changed, 301 insertions(+), 47 deletions(-) diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 6a8e76dd8a..9c604e7948 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -807,6 +807,7 @@ dependencies = [ "toml 0.9.1", "tracing", "tracing-subscriber", + "uuid", "wiremock", ] diff --git a/codex-rs/cli/src/proto.rs b/codex-rs/cli/src/proto.rs index 148699552a..ec395dd108 100644 --- a/codex-rs/cli/src/proto.rs +++ b/codex-rs/cli/src/proto.rs @@ -35,7 +35,7 @@ pub async fn run_main(opts: ProtoCli) -> anyhow::Result<()> { let config = Config::load_with_cli_overrides(overrides_vec, ConfigOverrides::default())?; let ctrl_c = notify_on_sigint(); - let (codex, _init_id) = Codex::spawn(config, ctrl_c.clone()).await?; + let (codex, _init_id, _session_id) = Codex::spawn(config, ctrl_c.clone()).await?; let codex = Arc::new(codex); // Task that reads JSON lines from stdin and forwards to Submission Queue diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index d23981b95f..392e84ea10 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -101,7 +101,7 @@ impl Codex { /// Spawn a new [`Codex`] and initialize the session. Returns the instance /// of `Codex` and the ID of the `SessionInitialized` event that was /// submitted to start the session. - pub async fn spawn(config: Config, ctrl_c: Arc) -> CodexResult<(Codex, String)> { + pub async fn spawn(config: Config, ctrl_c: Arc) -> CodexResult<(Codex, String, Uuid)> { // experimental resume path (undocumented) let resume_path = config.experimental_resume.clone(); info!("resume_path: {resume_path:?}"); @@ -124,7 +124,12 @@ impl Codex { }; let config = Arc::new(config); - tokio::spawn(submission_loop(config, rx_sub, tx_event, ctrl_c)); + + // Generate a unique ID for the lifetime of this Codex session. + let session_id = Uuid::new_v4(); + tokio::spawn(submission_loop( + session_id, config, rx_sub, tx_event, ctrl_c, + )); let codex = Codex { next_id: AtomicU64::new(0), tx_sub, @@ -132,7 +137,7 @@ impl Codex { }; let init_id = codex.submit(configure_session).await?; - Ok((codex, init_id)) + Ok((codex, init_id, session_id)) } /// Submit the `op` wrapped in a `Submission` with a unique ID. @@ -521,14 +526,12 @@ impl AgentTask { } async fn submission_loop( + mut session_id: Uuid, config: Arc, rx_sub: Receiver, tx_event: Sender, ctrl_c: Arc, ) { - // Generate a unique ID for the lifetime of this Codex session. - let mut session_id = Uuid::new_v4(); - let mut sess: Option> = None; // shorthand - send an event when there is no active session let send_no_session_event = |sub_id: String| async { diff --git a/codex-rs/core/src/codex_wrapper.rs b/codex-rs/core/src/codex_wrapper.rs index f2ece22da7..31f8295ed4 100644 --- a/codex-rs/core/src/codex_wrapper.rs +++ b/codex-rs/core/src/codex_wrapper.rs @@ -6,15 +6,16 @@ use crate::protocol::Event; use crate::protocol::EventMsg; use crate::util::notify_on_sigint; use tokio::sync::Notify; +use uuid::Uuid; /// Spawn a new [`Codex`] and initialize the session. /// /// Returns the wrapped [`Codex`] **and** the `SessionInitialized` event that /// is received as a response to the initial `ConfigureSession` submission so /// that callers can surface the information to the UI. -pub async fn init_codex(config: Config) -> anyhow::Result<(Codex, Event, Arc)> { +pub async fn init_codex(config: Config) -> anyhow::Result<(Codex, Event, Arc, Uuid)> { let ctrl_c = notify_on_sigint(); - let (codex, init_id) = Codex::spawn(config, ctrl_c.clone()).await?; + let (codex, init_id, session_id) = Codex::spawn(config, ctrl_c.clone()).await?; // The first event must be `SessionInitialized`. Validate and forward it to // the caller so that they can display it in the conversation history. @@ -33,5 +34,5 @@ pub async fn init_codex(config: Config) -> anyhow::Result<(Codex, Event, Arc Result { let mut config = load_default_config_for_test(&codex_home); config.model_provider.request_max_retries = Some(2); config.model_provider.stream_max_retries = Some(2); - let (agent, _init_id) = Codex::spawn(config, std::sync::Arc::new(Notify::new())).await?; + let (agent, _init_id, _session_id) = + Codex::spawn(config, std::sync::Arc::new(Notify::new())).await?; Ok(agent) } diff --git a/codex-rs/core/tests/previous_response_id.rs b/codex-rs/core/tests/previous_response_id.rs index 9630cc1028..6523c76441 100644 --- a/codex-rs/core/tests/previous_response_id.rs +++ b/codex-rs/core/tests/previous_response_id.rs @@ -113,7 +113,7 @@ async fn keeps_previous_response_id_between_tasks() { let mut config = load_default_config_for_test(&codex_home); config.model_provider = model_provider; let ctrl_c = std::sync::Arc::new(tokio::sync::Notify::new()); - let (codex, _init_id) = Codex::spawn(config, ctrl_c.clone()).await.unwrap(); + let (codex, _init_id, _session_id) = Codex::spawn(config, ctrl_c.clone()).await.unwrap(); // Task 1 – triggers first request (no previous_response_id) codex diff --git a/codex-rs/core/tests/stream_no_completed.rs b/codex-rs/core/tests/stream_no_completed.rs index f2de5de188..1a0455be7c 100644 --- a/codex-rs/core/tests/stream_no_completed.rs +++ b/codex-rs/core/tests/stream_no_completed.rs @@ -95,7 +95,7 @@ async fn retries_on_early_close() { let codex_home = TempDir::new().unwrap(); let mut config = load_default_config_for_test(&codex_home); config.model_provider = model_provider; - let (codex, _init_id) = Codex::spawn(config, ctrl_c).await.unwrap(); + let (codex, _init_id, _session_id) = Codex::spawn(config, ctrl_c).await.unwrap(); codex .submit(Op::UserInput { diff --git a/codex-rs/exec/src/lib.rs b/codex-rs/exec/src/lib.rs index b557c89397..769d3c3b01 100644 --- a/codex-rs/exec/src/lib.rs +++ b/codex-rs/exec/src/lib.rs @@ -153,7 +153,7 @@ pub async fn run_main(cli: Cli, codex_linux_sandbox_exe: Option) -> any .with_writer(std::io::stderr) .try_init(); - let (codex_wrapper, event, ctrl_c) = codex_wrapper::init_codex(config).await?; + let (codex_wrapper, event, ctrl_c, _session_id) = codex_wrapper::init_codex(config).await?; let codex = Arc::new(codex_wrapper); info!("Codex initialized with event: {event:?}"); diff --git a/codex-rs/mcp-server/Cargo.toml b/codex-rs/mcp-server/Cargo.toml index f43b101bd9..e524576a88 100644 --- a/codex-rs/mcp-server/Cargo.toml +++ b/codex-rs/mcp-server/Cargo.toml @@ -33,6 +33,7 @@ tokio = { version = "1", features = [ "rt-multi-thread", "signal", ] } +uuid = { version = "1", features = ["serde", "v4"] } [dev-dependencies] assert_cmd = "2" diff --git a/codex-rs/mcp-server/src/codex_tool_config.rs b/codex-rs/mcp-server/src/codex_tool_config.rs index 9a31dbcccc..54d108c0fd 100644 --- a/codex-rs/mcp-server/src/codex_tool_config.rs +++ b/codex-rs/mcp-server/src/codex_tool_config.rs @@ -160,6 +160,47 @@ impl CodexToolCallParam { } } +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub(crate) struct CodexToolCallReplyParam { + /// The *session id* for this conversation. + pub session_id: String, + + /// The *next user prompt* to continue the Codex conversation. + pub prompt: String, +} + +/// Builds a `Tool` definition for the `codex-reply` tool-call. +pub(crate) fn create_tool_for_codex_tool_call_reply_param() -> Tool { + let schema = SchemaSettings::draft2019_09() + .with(|s| { + s.inline_subschemas = true; + s.option_add_null_type = false; + }) + .into_generator() + .into_root_schema_for::(); + + #[expect(clippy::expect_used)] + let schema_value = + serde_json::to_value(&schema).expect("Codex reply tool schema should serialise to JSON"); + + let tool_input_schema = + serde_json::from_value::(schema_value).unwrap_or_else(|e| { + panic!("failed to create Tool from schema: {e}"); + }); + + Tool { + name: "codex-reply".to_string(), + title: Some("Codex Reply".to_string()), + input_schema: tool_input_schema, + output_schema: None, + description: Some( + "Continue a Codex session by providing the session id and prompt.".to_string(), + ), + annotations: None, + } +} + #[cfg(test)] mod tests { use super::*; @@ -235,4 +276,34 @@ mod tests { }); assert_eq!(expected_tool_json, tool_json); } + + #[test] + fn verify_codex_tool_reply_json_schema() { + let tool = create_tool_for_codex_tool_call_reply_param(); + #[expect(clippy::expect_used)] + let tool_json = serde_json::to_value(&tool).expect("tool serializes"); + let expected_tool_json = serde_json::json!({ + "description": "Continue a Codex session by providing the session id and prompt.", + "inputSchema": { + "properties": { + "prompt": { + "description": "The *next user prompt* to continue the Codex conversation.", + "type": "string" + }, + "sessionId": { + "description": "The *session id* for this conversation.", + "type": "string" + }, + }, + "required": [ + "prompt", + "sessionId", + ], + "type": "object", + }, + "name": "codex-reply", + "title": "Codex Reply", + }); + assert_eq!(expected_tool_json, tool_json); + } } diff --git a/codex-rs/mcp-server/src/codex_tool_runner.rs b/codex-rs/mcp-server/src/codex_tool_runner.rs index 163055de5c..3893a48595 100644 --- a/codex-rs/mcp-server/src/codex_tool_runner.rs +++ b/codex-rs/mcp-server/src/codex_tool_runner.rs @@ -2,6 +2,7 @@ //! Tokio task. Separated from `message_processor.rs` to keep that file small //! and to make future feature-growth easier to manage. +use std::collections::HashMap; use std::path::PathBuf; use std::sync::Arc; @@ -27,7 +28,9 @@ use mcp_types::TextContent; use serde::Deserialize; use serde::Serialize; use serde_json::json; +use tokio::sync::Mutex; use tracing::error; +use uuid::Uuid; use crate::outgoing_message::OutgoingMessageSender; @@ -42,8 +45,9 @@ pub async fn run_codex_tool_session( initial_prompt: String, config: CodexConfig, outgoing: Arc, + session_map: Arc>>>, ) { - let (codex, first_event, _ctrl_c) = match init_codex(config).await { + let (codex, first_event, _ctrl_c, session_id) = match init_codex(config).await { Ok(res) => res, Err(e) => { let result = CallToolResult { @@ -61,6 +65,11 @@ pub async fn run_codex_tool_session( }; let codex = Arc::new(codex); + // update the session map so we can retrieve the session in a reply, and then drop it, since + // we no longer need it for this function + session_map.lock().await.insert(session_id, codex.clone()); + drop(session_map); + // Send initial SessionConfigured event. outgoing.send_event_as_notification(&first_event).await; @@ -85,6 +94,37 @@ pub async fn run_codex_tool_session( tracing::error!("Failed to submit initial prompt: {e}"); } + run_codex_tool_session_inner(codex, outgoing, id).await; +} + +pub async fn run_codex_tool_session_reply( + codex: Arc, + outgoing: Arc, + request_id: RequestId, + prompt: String, +) { + if let Err(e) = codex + .submit(Op::UserInput { + items: vec![InputItem::Text { text: prompt }], + }) + .await + { + tracing::error!("Failed to submit user input: {e}"); + } + + run_codex_tool_session_inner(codex, outgoing, request_id).await; +} + +async fn run_codex_tool_session_inner( + codex: Arc, + outgoing: Arc, + request_id: RequestId, +) { + let sub_id = match &request_id { + RequestId::String(s) => s.clone(), + RequestId::Integer(n) => n.to_string(), + }; + // Stream events until the task needs to pause for user interaction or // completes. loop { @@ -128,7 +168,7 @@ pub async fn run_codex_tool_session( outgoing .send_error( - id.clone(), + request_id.clone(), JSONRPCErrorError { code: INVALID_PARAMS_ERROR_CODE, message, @@ -168,7 +208,9 @@ pub async fn run_codex_tool_session( is_error: None, structured_content: None, }; - outgoing.send_response(id.clone(), result.into()).await; + outgoing + .send_response(request_id.clone(), result.into()) + .await; // Continue, don't break so the session continues. continue; } @@ -186,7 +228,9 @@ pub async fn run_codex_tool_session( is_error: None, structured_content: None, }; - outgoing.send_response(id.clone(), result.into()).await; + outgoing + .send_response(request_id.clone(), result.into()) + .await; break; } EventMsg::SessionConfigured(_) => { @@ -234,7 +278,9 @@ pub async fn run_codex_tool_session( // structured way. structured_content: None, }; - outgoing.send_response(id.clone(), result.into()).await; + outgoing + .send_response(request_id.clone(), result.into()) + .await; break; } } diff --git a/codex-rs/mcp-server/src/message_processor.rs b/codex-rs/mcp-server/src/message_processor.rs index 61c320edb9..e72a52e006 100644 --- a/codex-rs/mcp-server/src/message_processor.rs +++ b/codex-rs/mcp-server/src/message_processor.rs @@ -1,10 +1,14 @@ +use std::collections::HashMap; use std::path::PathBuf; use std::sync::Arc; use crate::codex_tool_config::CodexToolCallParam; +use crate::codex_tool_config::CodexToolCallReplyParam; use crate::codex_tool_config::create_tool_for_codex_tool_call_param; +use crate::codex_tool_config::create_tool_for_codex_tool_call_reply_param; use crate::outgoing_message::OutgoingMessageSender; +use codex_core::Codex; use codex_core::config::Config as CodexConfig; use mcp_types::CallToolRequestParams; use mcp_types::CallToolResult; @@ -22,12 +26,15 @@ use mcp_types::ServerCapabilitiesTools; use mcp_types::ServerNotification; use mcp_types::TextContent; use serde_json::json; +use tokio::sync::Mutex; use tokio::task; +use uuid::Uuid; pub(crate) struct MessageProcessor { outgoing: Arc, initialized: bool, codex_linux_sandbox_exe: Option, + session_map: Arc>>>, } impl MessageProcessor { @@ -41,6 +48,7 @@ impl MessageProcessor { outgoing: Arc::new(outgoing), initialized: false, codex_linux_sandbox_exe, + session_map: Arc::new(Mutex::new(HashMap::new())), } } @@ -272,7 +280,10 @@ impl MessageProcessor { ) { tracing::trace!("tools/list -> {params:?}"); let result = ListToolsResult { - tools: vec![create_tool_for_codex_tool_call_param()], + tools: vec![ + create_tool_for_codex_tool_call_param(), + create_tool_for_codex_tool_call_reply_param(), + ], next_cursor: None, }; @@ -288,23 +299,29 @@ impl MessageProcessor { tracing::info!("tools/call -> params: {:?}", params); let CallToolRequestParams { name, arguments } = params; - // We only support the "codex" tool for now. - if name != "codex" { - // Tool not found – return error result so the LLM can react. - let result = CallToolResult { - content: vec![ContentBlock::TextContent(TextContent { - r#type: "text".to_string(), - text: format!("Unknown tool '{name}'"), - annotations: None, - })], - is_error: Some(true), - structured_content: None, - }; - self.send_response::(id, result) - .await; - return; + match name.as_str() { + "codex" => self.handle_tool_call_codex(id, arguments).await, + "codex-reply" => { + self.handle_tool_call_codex_session_reply(id, arguments) + .await + } + _ => { + let result = CallToolResult { + content: vec![ContentBlock::TextContent(TextContent { + r#type: "text".to_string(), + text: format!("Unknown tool '{name}'"), + annotations: None, + })], + is_error: Some(true), + structured_content: None, + }; + self.send_response::(id, result) + .await; + } } + } + async fn handle_tool_call_codex(&self, id: RequestId, arguments: Option) { let (initial_prompt, config): (String, CodexConfig) = match arguments { Some(json_val) => match serde_json::from_value::(json_val) { Ok(tool_cfg) => match tool_cfg.into_config(self.codex_linux_sandbox_exe.clone()) { @@ -359,15 +376,127 @@ impl MessageProcessor { } }; - // Clone outgoing sender to move into async task. + // Clone outgoing and session map to move into async task. let outgoing = self.outgoing.clone(); + let session_map = self.session_map.clone(); // Spawn an async task to handle the Codex session so that we do not // block the synchronous message-processing loop. task::spawn(async move { // Run the Codex session and stream events back to the client. - crate::codex_tool_runner::run_codex_tool_session(id, initial_prompt, config, outgoing) - .await; + crate::codex_tool_runner::run_codex_tool_session( + id, + initial_prompt, + config, + outgoing, + session_map, + ) + .await; + }); + } + + async fn handle_tool_call_codex_session_reply( + &self, + request_id: RequestId, + arguments: Option, + ) { + tracing::info!("tools/call -> params: {:?}", arguments); + + // parse arguments + let CodexToolCallReplyParam { session_id, prompt } = match arguments { + Some(json_val) => match serde_json::from_value::(json_val) { + Ok(params) => params, + Err(e) => { + tracing::error!("Failed to parse Codex tool call reply parameters: {e}"); + let result = CallToolResult { + content: vec![ContentBlock::TextContent(TextContent { + r#type: "text".to_owned(), + text: format!("Failed to parse configuration for Codex tool: {e}"), + annotations: None, + })], + is_error: Some(true), + structured_content: None, + }; + self.send_response::(request_id, result) + .await; + return; + } + }, + None => { + tracing::error!( + "Missing arguments for codex-reply tool-call; the `session_id` and `prompt` fields are required." + ); + let result = CallToolResult { + content: vec![ContentBlock::TextContent(TextContent { + r#type: "text".to_owned(), + text: "Missing arguments for codex-reply tool-call; the `session_id` and `prompt` fields are required.".to_owned(), + annotations: None, + })], + is_error: Some(true), + structured_content: None, + }; + self.send_response::(request_id, result) + .await; + return; + } + }; + let session_id = match Uuid::parse_str(&session_id) { + Ok(id) => id, + Err(e) => { + tracing::error!("Failed to parse session_id: {e}"); + let result = CallToolResult { + content: vec![ContentBlock::TextContent(TextContent { + r#type: "text".to_owned(), + text: format!("Failed to parse session_id: {e}"), + annotations: None, + })], + is_error: Some(true), + structured_content: None, + }; + self.send_response::(request_id, result) + .await; + return; + } + }; + + // load codex from session map + let session_map_mutex = Arc::clone(&self.session_map); + + // Clone outgoing and session map to move into async task. + let outgoing = self.outgoing.clone(); + + // Spawn an async task to handle the Codex session so that we do not + // block the synchronous message-processing loop. + task::spawn(async move { + let session_map = session_map_mutex.lock().await; + let codex = match session_map.get(&session_id) { + Some(codex) => codex, + None => { + tracing::warn!("Session not found for session_id: {session_id}"); + let result = CallToolResult { + content: vec![ContentBlock::TextContent(TextContent { + r#type: "text".to_owned(), + text: format!("Session not found for session_id: {session_id}"), + annotations: None, + })], + is_error: Some(true), + structured_content: None, + }; + // unwrap_or_default is fine here because we know the result is valid JSON + outgoing + .send_response(request_id, serde_json::to_value(result).unwrap_or_default()) + .await; + return; + } + }; + + crate::codex_tool_runner::run_codex_tool_session_reply( + codex.clone(), + outgoing, + request_id, + prompt.clone(), + ) + .await; }); } diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index c22bbf9704..c70c6f6d72 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -96,14 +96,15 @@ impl ChatWidget<'_> { // Create the Codex asynchronously so the UI loads as quickly as possible. let config_for_agent_loop = config.clone(); tokio::spawn(async move { - let (codex, session_event, _ctrl_c) = match init_codex(config_for_agent_loop).await { - Ok(vals) => vals, - Err(e) => { - // TODO: surface this error to the user. - tracing::error!("failed to initialize codex: {e}"); - return; - } - }; + let (codex, session_event, _ctrl_c, _session_id) = + match init_codex(config_for_agent_loop).await { + Ok(vals) => vals, + Err(e) => { + // TODO: surface this error to the user. + tracing::error!("failed to initialize codex: {e}"); + return; + } + }; // Forward the captured `SessionInitialized` event that was consumed // inside `init_codex()` so it can be rendered in the UI. From 6cf4b96f9dbbef8a94acc1ff703eb118481514d8 Mon Sep 17 00:00:00 2001 From: Michael Bolin Date: Mon, 21 Jul 2025 22:38:50 -0700 Subject: [PATCH 02/58] fix: check flags to ripgrep when deciding whether the invocation is "trusted" (#1644) With this change, if any of `--pre`, `--hostname-bin`, `--search-zip`, or `-z` are used with a proposed invocation of `rg`, do not auto-approve. --- codex-cli/src/approvals.ts | 33 ++++++++++++++- codex-cli/tests/approvals.test.ts | 26 ++++++++++++ codex-rs/core/src/is_safe_command.rs | 63 ++++++++++++++++++++++++++-- 3 files changed, 118 insertions(+), 4 deletions(-) diff --git a/codex-cli/src/approvals.ts b/codex-cli/src/approvals.ts index e626da7fa5..35b8c0ae16 100644 --- a/codex-cli/src/approvals.ts +++ b/codex-cli/src/approvals.ts @@ -370,11 +370,26 @@ export function isSafeCommand( reason: "View file with line numbers", group: "Reading files", }; - case "rg": + case "rg": { + // Certain ripgrep options execute external commands or invoke other + // processes, so we must reject them. + const isUnsafe = command.some( + (arg: string) => + UNSAFE_OPTIONS_FOR_RIPGREP_WITHOUT_ARGS.has(arg) || + [...UNSAFE_OPTIONS_FOR_RIPGREP_WITH_ARGS].some( + (opt) => arg === opt || arg.startsWith(`${opt}=`), + ), + ); + + if (isUnsafe) { + break; + } + return { reason: "Ripgrep search", group: "Searching", }; + } case "find": { // Certain options to `find` allow executing arbitrary processes, so we // cannot auto-approve them. @@ -495,6 +510,22 @@ const UNSAFE_OPTIONS_FOR_FIND_COMMAND: ReadonlySet = new Set([ "-fprintf", ]); +// Ripgrep options that are considered unsafe because they may execute +// arbitrary commands or spawn auxiliary processes. +const UNSAFE_OPTIONS_FOR_RIPGREP_WITH_ARGS: ReadonlySet = new Set([ + // Executes an arbitrary command for each matching file. + "--pre", + // Allows custom hostname command which could leak environment details. + "--hostname-bin", +]); + +const UNSAFE_OPTIONS_FOR_RIPGREP_WITHOUT_ARGS: ReadonlySet = new Set([ + // Enables searching inside archives which triggers external decompression + // utilities – reject out of an abundance of caution. + "--search-zip", + "-z", +]); + // ---------------- Helper utilities for complex shell expressions ----------------- // A conservative allow-list of bash operators that do not, on their own, cause diff --git a/codex-cli/tests/approvals.test.ts b/codex-cli/tests/approvals.test.ts index c592c39525..645ab44ce9 100644 --- a/codex-cli/tests/approvals.test.ts +++ b/codex-cli/tests/approvals.test.ts @@ -44,6 +44,14 @@ describe("canAutoApprove()", () => { group: "Navigating", runInSandbox: false, }); + + // Ripgrep safe invocation. + expect(check(["rg", "TODO"])).toEqual({ + type: "auto-approve", + reason: "Ripgrep search", + group: "Searching", + runInSandbox: false, + }); }); test("simple safe commands within a `bash -lc` call", () => { @@ -67,6 +75,24 @@ describe("canAutoApprove()", () => { }); }); + test("ripgrep unsafe flags", () => { + // Flags that do not take arguments + expect(check(["rg", "--search-zip", "TODO"])).toEqual({ type: "ask-user" }); + expect(check(["rg", "-z", "TODO"])).toEqual({ type: "ask-user" }); + + // Flags that take arguments (provided separately) + expect(check(["rg", "--pre", "cat", "TODO"])).toEqual({ type: "ask-user" }); + expect(check(["rg", "--hostname-bin", "hostname", "TODO"])).toEqual({ + type: "ask-user", + }); + + // Flags that take arguments in = form + expect(check(["rg", "--pre=cat", "TODO"])).toEqual({ type: "ask-user" }); + expect(check(["rg", "--hostname-bin=hostname", "TODO"])).toEqual({ + type: "ask-user", + }); + }); + test("bash -lc commands with unsafe redirects", () => { expect(check(["bash", "-lc", "echo hello > file.txt"])).toEqual({ type: "ask-user", diff --git a/codex-rs/core/src/is_safe_command.rs b/codex-rs/core/src/is_safe_command.rs index 98c41dbdc2..237123c581 100644 --- a/codex-rs/core/src/is_safe_command.rs +++ b/codex-rs/core/src/is_safe_command.rs @@ -23,9 +23,9 @@ fn is_safe_to_call_with_exec(command: &[String]) -> bool { let cmd0 = command.first().map(String::as_str); match cmd0 { - Some( - "cat" | "cd" | "echo" | "grep" | "head" | "ls" | "pwd" | "rg" | "tail" | "wc" | "which", - ) => true, + Some("cat" | "cd" | "echo" | "grep" | "head" | "ls" | "pwd" | "tail" | "wc" | "which") => { + true + } Some("find") => { // Certain options to `find` can delete files, write to files, or @@ -46,6 +46,29 @@ fn is_safe_to_call_with_exec(command: &[String]) -> bool { .any(|arg| UNSAFE_FIND_OPTIONS.contains(&arg.as_str())) } + // Ripgrep + Some("rg") => { + const UNSAFE_RIPGREP_OPTIONS_WITH_ARGS: &[&str] = &[ + // Takes an arbitrary command that is executed for each match. + "--pre", + // Takes a command that can be used to obtain the local hostname. + "--hostname-bin", + ]; + const UNSAFE_RIPGREP_OPTIONS_WITHOUT_ARGS: &[&str] = &[ + // Calls out to other decompression tools, so do not auto-approve + // out of an abundance of caution. + "--search-zip", + "-z", + ]; + + !command.iter().any(|arg| { + UNSAFE_RIPGREP_OPTIONS_WITHOUT_ARGS.contains(&arg.as_str()) + || UNSAFE_RIPGREP_OPTIONS_WITH_ARGS + .iter() + .any(|&opt| arg == opt || arg.starts_with(&format!("{opt}="))) + }) + } + // Git Some("git") => matches!( command.get(1).map(String::as_str), @@ -245,6 +268,40 @@ mod tests { } } + #[test] + fn ripgrep_rules() { + // Safe ripgrep invocations – none of the unsafe flags are present. + assert!(is_safe_to_call_with_exec(&vec_str(&[ + "rg", + "Cargo.toml", + "-n" + ]))); + + // Unsafe flags that do not take an argument (present verbatim). + for args in [ + vec_str(&["rg", "--search-zip", "files"]), + vec_str(&["rg", "-z", "files"]), + ] { + assert!( + !is_safe_to_call_with_exec(&args), + "expected {args:?} to be considered unsafe due to zip-search flag", + ); + } + + // Unsafe flags that expect a value, provided in both split and = forms. + for args in [ + vec_str(&["rg", "--pre", "pwned", "files"]), + vec_str(&["rg", "--pre=pwned", "files"]), + vec_str(&["rg", "--hostname-bin", "pwned", "files"]), + vec_str(&["rg", "--hostname-bin=pwned", "files"]), + ] { + assert!( + !is_safe_to_call_with_exec(&args), + "expected {args:?} to be considered unsafe due to external-command flag", + ); + } + } + #[test] fn bash_lc_safe_examples() { assert!(is_known_safe_command(&vec_str(&["bash", "-lc", "ls"]))); From 710f728124989b26e7c2e8910a31b198826d1025 Mon Sep 17 00:00:00 2001 From: Gabriel Peal Date: Mon, 21 Jul 2025 23:58:41 -0700 Subject: [PATCH 03/58] Add an elicitation for approve patch and refactor tool calls (#1642) 1. Added an elicitation for `approve-patch` which is very similar to `approve-exec`. 2. Extracted both elicitations to their own files to prevent `codex_tool_runner` from blowing up in size. --- codex-rs/mcp-server/src/codex_tool_runner.rs | 183 +++---------- codex-rs/mcp-server/src/exec_approval.rs | 145 ++++++++++ codex-rs/mcp-server/src/lib.rs | 8 +- codex-rs/mcp-server/src/patch_approval.rs | 147 ++++++++++ .../mcp-server/tests/common/mcp_process.rs | 8 +- codex-rs/mcp-server/tests/common/mod.rs | 1 + codex-rs/mcp-server/tests/common/responses.rs | 36 +++ codex-rs/mcp-server/tests/elicitation.rs | 253 +++++++++++++++--- 8 files changed, 584 insertions(+), 197 deletions(-) create mode 100644 codex-rs/mcp-server/src/exec_approval.rs create mode 100644 codex-rs/mcp-server/src/patch_approval.rs diff --git a/codex-rs/mcp-server/src/codex_tool_runner.rs b/codex-rs/mcp-server/src/codex_tool_runner.rs index 3893a48595..df2154dd1f 100644 --- a/codex-rs/mcp-server/src/codex_tool_runner.rs +++ b/codex-rs/mcp-server/src/codex_tool_runner.rs @@ -3,38 +3,31 @@ //! and to make future feature-growth easier to manage. use std::collections::HashMap; -use std::path::PathBuf; use std::sync::Arc; use codex_core::Codex; use codex_core::codex_wrapper::init_codex; use codex_core::config::Config as CodexConfig; use codex_core::protocol::AgentMessageEvent; +use codex_core::protocol::ApplyPatchApprovalRequestEvent; use codex_core::protocol::EventMsg; use codex_core::protocol::ExecApprovalRequestEvent; use codex_core::protocol::InputItem; use codex_core::protocol::Op; -use codex_core::protocol::ReviewDecision; use codex_core::protocol::Submission; use codex_core::protocol::TaskCompleteEvent; use mcp_types::CallToolResult; use mcp_types::ContentBlock; -use mcp_types::ElicitRequest; -use mcp_types::ElicitRequestParamsRequestedSchema; -use mcp_types::JSONRPCErrorError; -use mcp_types::ModelContextProtocolRequest; use mcp_types::RequestId; use mcp_types::TextContent; -use serde::Deserialize; -use serde::Serialize; -use serde_json::json; use tokio::sync::Mutex; -use tracing::error; use uuid::Uuid; +use crate::exec_approval::handle_exec_approval_request; use crate::outgoing_message::OutgoingMessageSender; +use crate::patch_approval::handle_patch_approval_request; -const INVALID_PARAMS_ERROR_CODE: i64 = -32602; +pub(crate) const INVALID_PARAMS_ERROR_CODE: i64 = -32602; /// Run a complete Codex session and stream events back to the client. /// @@ -120,7 +113,7 @@ async fn run_codex_tool_session_inner( outgoing: Arc, request_id: RequestId, ) { - let sub_id = match &request_id { + let request_id_str = match &request_id { RequestId::String(s) => s.clone(), RequestId::Integer(n) => n.to_string(), }; @@ -138,80 +131,34 @@ async fn run_codex_tool_session_inner( cwd, reason: _, }) => { - let escaped_command = shlex::try_join(command.iter().map(|s| s.as_str())) - .unwrap_or_else(|_| command.join(" ")); - let message = format!( - "Allow Codex to run `{escaped_command}` in `{cwd}`?", - cwd = cwd.to_string_lossy() - ); - - let params = ExecApprovalElicitRequestParams { - message, - requested_schema: ElicitRequestParamsRequestedSchema { - r#type: "object".to_string(), - properties: json!({}), - required: None, - }, - codex_elicitation: "exec-approval".to_string(), - codex_mcp_tool_call_id: sub_id.clone(), - codex_event_id: event.id.clone(), - codex_command: command, - codex_cwd: cwd, - }; - let params_json = match serde_json::to_value(¶ms) { - Ok(value) => value, - Err(err) => { - let message = format!( - "Failed to serialize ExecApprovalElicitRequestParams: {err}" - ); - tracing::error!("{message}"); - - outgoing - .send_error( - request_id.clone(), - JSONRPCErrorError { - code: INVALID_PARAMS_ERROR_CODE, - message, - data: None, - }, - ) - .await; - - continue; - } - }; - - let on_response = outgoing - .send_request(ElicitRequest::METHOD, Some(params_json)) - .await; - - // Listen for the response on a separate task so we do - // not block the main loop of this function. - { - let codex = codex.clone(); - let event_id = event.id.clone(); - tokio::spawn(async move { - on_exec_approval_response(event_id, on_response, codex).await; - }); - } - - // Continue, don't break so the session continues. + handle_exec_approval_request( + command, + cwd, + outgoing.clone(), + codex.clone(), + request_id.clone(), + request_id_str.clone(), + event.id.clone(), + ) + .await; continue; } - EventMsg::ApplyPatchApprovalRequest(_) => { - let result = CallToolResult { - content: vec![ContentBlock::TextContent(TextContent { - r#type: "text".to_string(), - text: "PATCH_APPROVAL_REQUIRED".to_string(), - annotations: None, - })], - is_error: None, - structured_content: None, - }; - outgoing - .send_response(request_id.clone(), result.into()) - .await; - // Continue, don't break so the session continues. + EventMsg::ApplyPatchApprovalRequest(ApplyPatchApprovalRequestEvent { + reason, + grant_root, + changes, + }) => { + handle_patch_approval_request( + reason, + grant_root, + changes, + outgoing.clone(), + codex.clone(), + request_id.clone(), + request_id_str.clone(), + event.id.clone(), + ) + .await; continue; } EventMsg::TaskComplete(TaskCompleteEvent { last_agent_message }) => { @@ -286,71 +233,3 @@ async fn run_codex_tool_session_inner( } } } - -async fn on_exec_approval_response( - event_id: String, - receiver: tokio::sync::oneshot::Receiver, - codex: Arc, -) { - let response = receiver.await; - let value = match response { - Ok(value) => value, - Err(err) => { - error!("request failed: {err:?}"); - return; - } - }; - - // Try to deserialize `value` and then make the appropriate call to `codex`. - let response = match serde_json::from_value::(value) { - Ok(response) => response, - Err(err) => { - error!("failed to deserialize ExecApprovalResponse: {err}"); - // If we cannot deserialize the response, we deny the request to be - // conservative. - ExecApprovalResponse { - decision: ReviewDecision::Denied, - } - } - }; - - if let Err(err) = codex - .submit(Op::ExecApproval { - id: event_id, - decision: response.decision, - }) - .await - { - error!("failed to submit ExecApproval: {err}"); - } -} - -// TODO(mbolin): ExecApprovalResponse does not conform to ElicitResult. See: -// - https://github.com/modelcontextprotocol/modelcontextprotocol/blob/f962dc1780fa5eed7fb7c8a0232f1fc83ef220cd/schema/2025-06-18/schema.json#L617-L636 -// - https://modelcontextprotocol.io/specification/draft/client/elicitation#protocol-messages -// It should have "action" and "content" fields. - -#[derive(Debug, Serialize, Deserialize)] -pub struct ExecApprovalResponse { - pub decision: ReviewDecision, -} - -/// Conforms to [`mcp_types::ElicitRequestParams`] so that it can be used as the -/// `params` field of an [`mcp_types::ElicitRequest`]. -#[derive(Debug, Serialize)] -pub struct ExecApprovalElicitRequestParams { - // These fields are required so that `params` - // conforms to ElicitRequestParams. - pub message: String, - - #[serde(rename = "requestedSchema")] - pub requested_schema: ElicitRequestParamsRequestedSchema, - - // These are additional fields the client can use to - // correlate the request with the codex tool call. - pub codex_elicitation: String, - pub codex_mcp_tool_call_id: String, - pub codex_event_id: String, - pub codex_command: Vec, - pub codex_cwd: PathBuf, -} diff --git a/codex-rs/mcp-server/src/exec_approval.rs b/codex-rs/mcp-server/src/exec_approval.rs new file mode 100644 index 0000000000..fc0c41d0d1 --- /dev/null +++ b/codex-rs/mcp-server/src/exec_approval.rs @@ -0,0 +1,145 @@ +use std::path::PathBuf; +use std::sync::Arc; + +use codex_core::Codex; +use codex_core::protocol::Op; +use codex_core::protocol::ReviewDecision; +use mcp_types::ElicitRequest; +use mcp_types::ElicitRequestParamsRequestedSchema; +use mcp_types::JSONRPCErrorError; +use mcp_types::ModelContextProtocolRequest; +use mcp_types::RequestId; +use serde::Deserialize; +use serde::Serialize; +use serde_json::json; +use tracing::error; + +use crate::codex_tool_runner::INVALID_PARAMS_ERROR_CODE; + +/// Conforms to [`mcp_types::ElicitRequestParams`] so that it can be used as the +/// `params` field of an [`ElicitRequest`]. +#[derive(Debug, Serialize)] +pub struct ExecApprovalElicitRequestParams { + // These fields are required so that `params` + // conforms to ElicitRequestParams. + pub message: String, + + #[serde(rename = "requestedSchema")] + pub requested_schema: ElicitRequestParamsRequestedSchema, + + // These are additional fields the client can use to + // correlate the request with the codex tool call. + pub codex_elicitation: String, + pub codex_mcp_tool_call_id: String, + pub codex_event_id: String, + pub codex_command: Vec, + pub codex_cwd: PathBuf, +} + +// TODO(mbolin): ExecApprovalResponse does not conform to ElicitResult. See: +// - https://github.com/modelcontextprotocol/modelcontextprotocol/blob/f962dc1780fa5eed7fb7c8a0232f1fc83ef220cd/schema/2025-06-18/schema.json#L617-L636 +// - https://modelcontextprotocol.io/specification/draft/client/elicitation#protocol-messages +// It should have "action" and "content" fields. +#[derive(Debug, Serialize, Deserialize)] +pub struct ExecApprovalResponse { + pub decision: ReviewDecision, +} + +pub(crate) async fn handle_exec_approval_request( + command: Vec, + cwd: PathBuf, + outgoing: Arc, + codex: Arc, + request_id: RequestId, + tool_call_id: String, + event_id: String, +) { + let escaped_command = + shlex::try_join(command.iter().map(|s| s.as_str())).unwrap_or_else(|_| command.join(" ")); + let message = format!( + "Allow Codex to run `{escaped_command}` in `{cwd}`?", + cwd = cwd.to_string_lossy() + ); + + let params = ExecApprovalElicitRequestParams { + message, + requested_schema: ElicitRequestParamsRequestedSchema { + r#type: "object".to_string(), + properties: json!({}), + required: None, + }, + codex_elicitation: "exec-approval".to_string(), + codex_mcp_tool_call_id: tool_call_id.clone(), + codex_event_id: event_id.clone(), + codex_command: command, + codex_cwd: cwd, + }; + let params_json = match serde_json::to_value(¶ms) { + Ok(value) => value, + Err(err) => { + let message = format!("Failed to serialize ExecApprovalElicitRequestParams: {err}"); + error!("{message}"); + + outgoing + .send_error( + request_id.clone(), + JSONRPCErrorError { + code: INVALID_PARAMS_ERROR_CODE, + message, + data: None, + }, + ) + .await; + + return; + } + }; + + let on_response = outgoing + .send_request(ElicitRequest::METHOD, Some(params_json)) + .await; + + // Listen for the response on a separate task so we don't block the main agent loop. + { + let codex = codex.clone(); + let event_id = event_id.clone(); + tokio::spawn(async move { + on_exec_approval_response(event_id, on_response, codex).await; + }); + } +} + +async fn on_exec_approval_response( + event_id: String, + receiver: tokio::sync::oneshot::Receiver, + codex: Arc, +) { + let response = receiver.await; + let value = match response { + Ok(value) => value, + Err(err) => { + error!("request failed: {err:?}"); + return; + } + }; + + // Try to deserialize `value` and then make the appropriate call to `codex`. + let response = serde_json::from_value::(value).unwrap_or_else(|err| { + error!("failed to deserialize ExecApprovalResponse: {err}"); + // If we cannot deserialize the response, we deny the request to be + // conservative. + ExecApprovalResponse { + decision: ReviewDecision::Denied, + } + }); + + if let Err(err) = codex + .submit(Op::ExecApproval { + id: event_id, + decision: response.decision, + }) + .await + { + error!("failed to submit ExecApproval: {err}"); + } +} diff --git a/codex-rs/mcp-server/src/lib.rs b/codex-rs/mcp-server/src/lib.rs index 1f1ecc3f2a..300d1b5fe5 100644 --- a/codex-rs/mcp-server/src/lib.rs +++ b/codex-rs/mcp-server/src/lib.rs @@ -16,17 +16,21 @@ use tracing::info; mod codex_tool_config; mod codex_tool_runner; +mod exec_approval; mod json_to_toml; mod message_processor; mod outgoing_message; +mod patch_approval; use crate::message_processor::MessageProcessor; use crate::outgoing_message::OutgoingMessage; use crate::outgoing_message::OutgoingMessageSender; pub use crate::codex_tool_config::CodexToolCallParam; -pub use crate::codex_tool_runner::ExecApprovalElicitRequestParams; -pub use crate::codex_tool_runner::ExecApprovalResponse; +pub use crate::exec_approval::ExecApprovalElicitRequestParams; +pub use crate::exec_approval::ExecApprovalResponse; +pub use crate::patch_approval::PatchApprovalElicitRequestParams; +pub use crate::patch_approval::PatchApprovalResponse; /// Size of the bounded channels used to communicate between tasks. The value /// is a balance between throughput and memory usage – 128 messages should be diff --git a/codex-rs/mcp-server/src/patch_approval.rs b/codex-rs/mcp-server/src/patch_approval.rs new file mode 100644 index 0000000000..bfccfa50ee --- /dev/null +++ b/codex-rs/mcp-server/src/patch_approval.rs @@ -0,0 +1,147 @@ +use std::collections::HashMap; +use std::path::PathBuf; +use std::sync::Arc; + +use codex_core::Codex; +use codex_core::protocol::FileChange; +use codex_core::protocol::Op; +use codex_core::protocol::ReviewDecision; +use mcp_types::ElicitRequest; +use mcp_types::ElicitRequestParamsRequestedSchema; +use mcp_types::JSONRPCErrorError; +use mcp_types::ModelContextProtocolRequest; +use mcp_types::RequestId; +use serde::Deserialize; +use serde::Serialize; +use serde_json::json; +use tracing::error; + +use crate::codex_tool_runner::INVALID_PARAMS_ERROR_CODE; +use crate::outgoing_message::OutgoingMessageSender; + +#[derive(Debug, Serialize)] +pub struct PatchApprovalElicitRequestParams { + pub message: String, + #[serde(rename = "requestedSchema")] + pub requested_schema: ElicitRequestParamsRequestedSchema, + pub codex_elicitation: String, + pub codex_mcp_tool_call_id: String, + pub codex_event_id: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub codex_reason: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub codex_grant_root: Option, + pub codex_changes: HashMap, +} + +#[derive(Debug, Deserialize, Serialize)] +pub struct PatchApprovalResponse { + pub decision: ReviewDecision, +} + +#[allow(clippy::too_many_arguments)] +pub(crate) async fn handle_patch_approval_request( + reason: Option, + grant_root: Option, + changes: HashMap, + outgoing: Arc, + codex: Arc, + request_id: RequestId, + tool_call_id: String, + event_id: String, +) { + let mut message_lines = Vec::new(); + if let Some(r) = &reason { + message_lines.push(r.clone()); + } + message_lines.push("Allow Codex to apply proposed code changes?".to_string()); + + let params = PatchApprovalElicitRequestParams { + message: message_lines.join("\n"), + requested_schema: ElicitRequestParamsRequestedSchema { + r#type: "object".to_string(), + properties: json!({}), + required: None, + }, + codex_elicitation: "patch-approval".to_string(), + codex_mcp_tool_call_id: tool_call_id.clone(), + codex_event_id: event_id.clone(), + codex_reason: reason, + codex_grant_root: grant_root, + codex_changes: changes, + }; + let params_json = match serde_json::to_value(¶ms) { + Ok(value) => value, + Err(err) => { + let message = format!("Failed to serialize PatchApprovalElicitRequestParams: {err}"); + error!("{message}"); + + outgoing + .send_error( + request_id.clone(), + JSONRPCErrorError { + code: INVALID_PARAMS_ERROR_CODE, + message, + data: None, + }, + ) + .await; + + return; + } + }; + + let on_response = outgoing + .send_request(ElicitRequest::METHOD, Some(params_json)) + .await; + + // Listen for the response on a separate task so we don't block the main agent loop. + { + let codex = codex.clone(); + let event_id = event_id.clone(); + tokio::spawn(async move { + on_patch_approval_response(event_id, on_response, codex).await; + }); + } +} + +pub(crate) async fn on_patch_approval_response( + event_id: String, + receiver: tokio::sync::oneshot::Receiver, + codex: Arc, +) { + let response = receiver.await; + let value = match response { + Ok(value) => value, + Err(err) => { + error!("request failed: {err:?}"); + if let Err(submit_err) = codex + .submit(Op::PatchApproval { + id: event_id.clone(), + decision: ReviewDecision::Denied, + }) + .await + { + error!("failed to submit denied PatchApproval after request failure: {submit_err}"); + } + return; + } + }; + + let response = serde_json::from_value::(value).unwrap_or_else(|err| { + error!("failed to deserialize PatchApprovalResponse: {err}"); + PatchApprovalResponse { + decision: ReviewDecision::Denied, + } + }); + + if let Err(err) = codex + .submit(Op::PatchApproval { + id: event_id, + decision: response.decision, + }) + .await + { + error!("failed to submit PatchApproval: {err}"); + } +} diff --git a/codex-rs/mcp-server/tests/common/mcp_process.rs b/codex-rs/mcp-server/tests/common/mcp_process.rs index 42d15f7877..df9cc98acf 100644 --- a/codex-rs/mcp-server/tests/common/mcp_process.rs +++ b/codex-rs/mcp-server/tests/common/mcp_process.rs @@ -139,14 +139,18 @@ impl McpProcess { /// Returns the id used to make the request so it can be used when /// correlating notifications. - pub async fn send_codex_tool_call(&mut self, prompt: &str) -> anyhow::Result { + pub async fn send_codex_tool_call( + &mut self, + cwd: Option, + prompt: &str, + ) -> anyhow::Result { let codex_tool_call_params = CallToolRequestParams { name: "codex".to_string(), arguments: Some(serde_json::to_value(CodexToolCallParam { + cwd, prompt: prompt.to_string(), model: None, profile: None, - cwd: None, approval_policy: None, sandbox: None, config: None, diff --git a/codex-rs/mcp-server/tests/common/mod.rs b/codex-rs/mcp-server/tests/common/mod.rs index 61a5774bc4..b338e2e8ce 100644 --- a/codex-rs/mcp-server/tests/common/mod.rs +++ b/codex-rs/mcp-server/tests/common/mod.rs @@ -4,5 +4,6 @@ mod responses; pub use mcp_process::McpProcess; pub use mock_model_server::create_mock_chat_completions_server; +pub use responses::create_apply_patch_sse_response; pub use responses::create_final_assistant_message_sse_response; pub use responses::create_shell_sse_response; diff --git a/codex-rs/mcp-server/tests/common/responses.rs b/codex-rs/mcp-server/tests/common/responses.rs index a11c72d0f4..9a827fb986 100644 --- a/codex-rs/mcp-server/tests/common/responses.rs +++ b/codex-rs/mcp-server/tests/common/responses.rs @@ -57,3 +57,39 @@ pub fn create_final_assistant_message_sse_response(message: &str) -> anyhow::Res ); Ok(sse) } + +pub fn create_apply_patch_sse_response( + patch_content: &str, + call_id: &str, +) -> anyhow::Result { + // Use shell command to call apply_patch with heredoc format + let shell_command = format!("apply_patch <<'EOF'\n{patch_content}\nEOF"); + let tool_call_arguments = serde_json::to_string(&json!({ + "command": ["bash", "-lc", shell_command] + }))?; + + let tool_call = json!({ + "choices": [ + { + "delta": { + "tool_calls": [ + { + "id": call_id, + "function": { + "name": "shell", + "arguments": tool_call_arguments + } + } + ] + }, + "finish_reason": "tool_calls" + } + ] + }); + + let sse = format!( + "data: {}\n\ndata: DONE\n\n", + serde_json::to_string(&tool_call)? + ); + Ok(sse) +} diff --git a/codex-rs/mcp-server/tests/elicitation.rs b/codex-rs/mcp-server/tests/elicitation.rs index 7fd68d6775..ac9435e874 100644 --- a/codex-rs/mcp-server/tests/elicitation.rs +++ b/codex-rs/mcp-server/tests/elicitation.rs @@ -1,11 +1,17 @@ mod common; +use std::collections::HashMap; +use std::env; use std::path::Path; +use std::path::PathBuf; use codex_core::exec::CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR; +use codex_core::protocol::FileChange; use codex_core::protocol::ReviewDecision; use codex_mcp_server::ExecApprovalElicitRequestParams; use codex_mcp_server::ExecApprovalResponse; +use codex_mcp_server::PatchApprovalElicitRequestParams; +use codex_mcp_server::PatchApprovalResponse; use mcp_types::ElicitRequest; use mcp_types::ElicitRequestParamsRequestedSchema; use mcp_types::JSONRPC_VERSION; @@ -17,8 +23,10 @@ use pretty_assertions::assert_eq; use serde_json::json; use tempfile::TempDir; use tokio::time::timeout; +use wiremock::MockServer; use crate::common::McpProcess; +use crate::common::create_apply_patch_sse_response; use crate::common::create_final_assistant_message_sse_response; use crate::common::create_mock_chat_completions_server; use crate::common::create_shell_sse_response; @@ -30,7 +38,7 @@ const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs /// command, as expected. #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn test_shell_command_approval_triggers_elicitation() { - if std::env::var(CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR).is_ok() { + if env::var(CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR).is_ok() { println!( "Skipping test because it cannot execute when network is disabled in a Codex sandbox." ); @@ -49,12 +57,11 @@ async fn shell_command_approval_triggers_elicitation() -> anyhow::Result<()> { let shell_command = vec!["git".to_string(), "init".to_string()]; let workdir_for_shell_function_call = TempDir::new()?; - // Configure the mock server so it makes two responses: - // 1. The first response is a shell function call that will trigger an - // elicitation request. - // 2. The second response is the final assistant message that should be - // returned after the elicitation is approved and the command is run. - let server = create_mock_chat_completions_server(vec![ + let McpHandle { + process: mut mcp_process, + server: _server, + dir: _dir, + } = create_mcp_process(vec![ create_shell_sse_response( shell_command.clone(), Some(workdir_for_shell_function_call.path()), @@ -63,18 +70,14 @@ async fn shell_command_approval_triggers_elicitation() -> anyhow::Result<()> { )?, create_final_assistant_message_sse_response("Enjoy your new git repo!")?, ]) - .await; - - // Run `codex mcp` with a specific config.toml. - let codex_home = TempDir::new()?; - create_config_toml(codex_home.path(), server.uri())?; - let mut mcp_process = McpProcess::new(codex_home.path()).await?; - timeout(DEFAULT_READ_TIMEOUT, mcp_process.initialize()).await??; + .await?; // Send a "codex" tool request, which should hit the completions endpoint. // In turn, it should reply with a tool call, which the MCP should forward // as an elicitation. - let codex_request_id = mcp_process.send_codex_tool_call("run `git init`").await?; + let codex_request_id = mcp_process + .send_codex_tool_call(None, "run `git init`") + .await?; let elicitation_request = timeout( DEFAULT_READ_TIMEOUT, mcp_process.read_stream_until_request_message(), @@ -136,32 +139,6 @@ async fn shell_command_approval_triggers_elicitation() -> anyhow::Result<()> { Ok(()) } -/// Create a Codex config that uses the mock server as the model provider. -/// It also uses `approval_policy = "untrusted"` so that we exercise the -/// elicitation code path for shell commands. -fn create_config_toml(codex_home: &Path, server_uri: String) -> std::io::Result<()> { - let config_toml = codex_home.join("config.toml"); - std::fs::write( - config_toml, - format!( - r#" -model = "mock-model" -approval_policy = "untrusted" -sandbox_policy = "read-only" - -model_provider = "mock_provider" - -[model_providers.mock_provider] -name = "Mock provider for test" -base_url = "{server_uri}/v1" -wire_api = "chat" -request_max_retries = 0 -stream_max_retries = 0 -"# - ), - ) -} - fn create_expected_elicitation_request( elicitation_request_id: RequestId, command: Vec, @@ -193,3 +170,197 @@ fn create_expected_elicitation_request( })?), }) } + +/// Test that patch approval triggers an elicitation request to the MCP and that +/// sending the approval applies the patch, as expected. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn test_patch_approval_triggers_elicitation() { + if env::var(CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR).is_ok() { + println!( + "Skipping test because it cannot execute when network is disabled in a Codex sandbox." + ); + return; + } + + if let Err(err) = patch_approval_triggers_elicitation().await { + panic!("failure: {err}"); + } +} + +async fn patch_approval_triggers_elicitation() -> anyhow::Result<()> { + let cwd = TempDir::new()?; + let test_file = cwd.path().join("destination_file.txt"); + std::fs::write(&test_file, "original content\n")?; + + let patch_content = format!( + "*** Begin Patch\n*** Update File: {}\n-original content\n+modified content\n*** End Patch", + test_file.as_path().to_string_lossy() + ); + + let McpHandle { + process: mut mcp_process, + server: _server, + dir: _dir, + } = create_mcp_process(vec![ + create_apply_patch_sse_response(&patch_content, "call1234")?, + create_final_assistant_message_sse_response("Patch has been applied successfully!")?, + ]) + .await?; + + // Send a "codex" tool request that will trigger the apply_patch command + let codex_request_id = mcp_process + .send_codex_tool_call( + Some(cwd.path().to_string_lossy().to_string()), + "please modify the test file", + ) + .await?; + let elicitation_request = timeout( + DEFAULT_READ_TIMEOUT, + mcp_process.read_stream_until_request_message(), + ) + .await??; + + let elicitation_request_id = RequestId::Integer(0); + + let mut expected_changes = HashMap::new(); + expected_changes.insert( + test_file.as_path().to_path_buf(), + FileChange::Update { + unified_diff: "@@ -1 +1 @@\n-original content\n+modified content\n".to_string(), + move_path: None, + }, + ); + + let expected_elicitation_request = create_expected_patch_approval_elicitation_request( + elicitation_request_id.clone(), + expected_changes, + None, // No grant_root expected + None, // No reason expected + codex_request_id.to_string(), + "1".to_string(), + )?; + assert_eq!(expected_elicitation_request, elicitation_request); + + // Accept the patch approval request by responding to the elicitation + mcp_process + .send_response( + elicitation_request_id, + serde_json::to_value(PatchApprovalResponse { + decision: ReviewDecision::Approved, + })?, + ) + .await?; + + // Verify the original `codex` tool call completes + let codex_response = timeout( + DEFAULT_READ_TIMEOUT, + mcp_process.read_stream_until_response_message(RequestId::Integer(codex_request_id)), + ) + .await??; + assert_eq!( + JSONRPCResponse { + jsonrpc: JSONRPC_VERSION.into(), + id: RequestId::Integer(codex_request_id), + result: json!({ + "content": [ + { + "text": "Patch has been applied successfully!", + "type": "text" + } + ] + }), + }, + codex_response + ); + + let file_contents = std::fs::read_to_string(test_file.as_path())?; + assert_eq!(file_contents, "modified content\n"); + + Ok(()) +} + +fn create_expected_patch_approval_elicitation_request( + elicitation_request_id: RequestId, + changes: HashMap, + grant_root: Option, + reason: Option, + codex_mcp_tool_call_id: String, + codex_event_id: String, +) -> anyhow::Result { + let mut message_lines = Vec::new(); + if let Some(r) = &reason { + message_lines.push(r.clone()); + } + message_lines.push("Allow Codex to apply proposed code changes?".to_string()); + + Ok(JSONRPCRequest { + jsonrpc: JSONRPC_VERSION.into(), + id: elicitation_request_id, + method: ElicitRequest::METHOD.to_string(), + params: Some(serde_json::to_value(&PatchApprovalElicitRequestParams { + message: message_lines.join("\n"), + requested_schema: ElicitRequestParamsRequestedSchema { + r#type: "object".to_string(), + properties: json!({}), + required: None, + }, + codex_elicitation: "patch-approval".to_string(), + codex_mcp_tool_call_id, + codex_event_id, + codex_reason: reason, + codex_grant_root: grant_root, + codex_changes: changes, + })?), + }) +} + +/// This handle is used to ensure that the MockServer and TempDir are not dropped while +/// the McpProcess is still running. +pub struct McpHandle { + pub process: McpProcess, + /// Retain the server for the lifetime of the McpProcess. + #[allow(dead_code)] + server: MockServer, + /// Retain the temporary directory for the lifetime of the McpProcess. + #[allow(dead_code)] + dir: TempDir, +} + +async fn create_mcp_process(responses: Vec) -> anyhow::Result { + let server = create_mock_chat_completions_server(responses).await; + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path(), &server.uri())?; + let mut mcp_process = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp_process.initialize()).await??; + Ok(McpHandle { + process: mcp_process, + server, + dir: codex_home, + }) +} + +/// Create a Codex config that uses the mock server as the model provider. +/// It also uses `approval_policy = "untrusted"` so that we exercise the +/// elicitation code path for shell commands. +fn create_config_toml(codex_home: &Path, server_uri: &str) -> std::io::Result<()> { + let config_toml = codex_home.join("config.toml"); + std::fs::write( + config_toml, + format!( + r#" +model = "mock-model" +approval_policy = "untrusted" +sandbox_policy = "read-only" + +model_provider = "mock_provider" + +[model_providers.mock_provider] +name = "Mock provider for test" +base_url = "{server_uri}/v1" +wire_api = "chat" +request_max_retries = 0 +stream_max_retries = 0 +"# + ), + ) +} From d51654822fa0ad4327c7465fe03ea9427be9e150 Mon Sep 17 00:00:00 2001 From: Michael Bolin Date: Tue, 22 Jul 2025 00:41:27 -0700 Subject: [PATCH 04/58] fix: use PR_SET_PDEATHSIG so to ensure child processes are killed in a timely manner (#1626) Some users have reported issues where child processes are not cleaned up after Codex exits (e.g., https://github.com/openai/codex/issues/1570). This is generally a tricky issue on operating systems: if a parent process receives `SIGKILL`, then it terminates immediately and cannot communicate with the child. **It only helps on Linux**, but this PR introduces the use of `prctl(2)` so that if the parent process dies, `SIGTERM` will be delivered to the child process. Whereas previously, I believe that if Codex spawned a long-running process (like `tsc --watch`) and the Codex process received `SIGKILL`, the `tsc --watch` process would be reparented to the init process and would never be killed. Now with the use of `prctl(2)`, the `tsc --watch` process should receive `SIGTERM` in that scenario. We still need to come up with a solution for macOS. I've started to look at `launchd`, but I'm researching a number of options. --- codex-rs/Cargo.lock | 1 + codex-rs/core/Cargo.toml | 1 + codex-rs/core/src/exec.rs | 25 +++++++++++++++++++++++++ 3 files changed, 27 insertions(+) diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 9c604e7948..9b4a4e32d4 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -669,6 +669,7 @@ dependencies = [ "fs2", "futures", "landlock", + "libc", "maplit", "mcp-types", "mime_guess", diff --git a/codex-rs/core/Cargo.toml b/codex-rs/core/Cargo.toml index e192a71f39..a87894bc4d 100644 --- a/codex-rs/core/Cargo.toml +++ b/codex-rs/core/Cargo.toml @@ -22,6 +22,7 @@ env-flags = "0.1.1" eventsource-stream = "0.2.3" fs2 = "0.4.3" futures = "0.3" +libc = "0.2.174" mcp-types = { path = "../mcp-types" } mime_guess = "2.0" rand = "0.9" diff --git a/codex-rs/core/src/exec.rs b/codex-rs/core/src/exec.rs index 3b37cb538d..4b33b0b3b5 100644 --- a/codex-rs/core/src/exec.rs +++ b/codex-rs/core/src/exec.rs @@ -384,6 +384,31 @@ async fn spawn_child_async( cmd.env(CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR, "1"); } + // If this Codex process dies (including being killed via SIGKILL), we want + // any child processes that were spawned as part of a `"shell"` tool call + // to also be terminated. + + // This relies on prctl(2), so it only works on Linux. + #[cfg(target_os = "linux")] + unsafe { + cmd.pre_exec(|| { + // This prctl call effectively requests, "deliver SIGTERM when my + // current parent dies." + if libc::prctl(libc::PR_SET_PDEATHSIG, libc::SIGTERM) == -1 { + return Err(io::Error::last_os_error()); + } + + // Though if there was a race condition and this pre_exec() block is + // run _after_ the parent (i.e., the Codex process) has already + // exited, then the parent is the _init_ process (which will never + // die), so we should just terminate the child process now. + if libc::getppid() == 1 { + libc::raise(libc::SIGTERM); + } + Ok(()) + }); + } + match stdio_policy { StdioPolicy::RedirectForShellTool => { // Do not create a file descriptor for stdin because otherwise some From ed206d568780ef2d757db638e359b4c497f417f1 Mon Sep 17 00:00:00 2001 From: pakrym-oai Date: Tue, 22 Jul 2025 09:28:00 -0700 Subject: [PATCH 05/58] Log response.failed error message and request-id (#1649) To help with diagnosing failures. --- codex-rs/core/src/client.rs | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/codex-rs/core/src/client.rs b/codex-rs/core/src/client.rs index 62fcabe05b..beeaa453ad 100644 --- a/codex-rs/core/src/client.rs +++ b/codex-rs/core/src/client.rs @@ -151,6 +151,17 @@ impl ModelClient { .json(&payload); let res = req_builder.send().await; + if let Ok(resp) = &res { + trace!( + "Response status: {}, request-id: {}", + resp.status(), + resp.headers() + .get("x-request-id") + .map(|v| v.to_str().unwrap_or_default()) + .unwrap_or_default() + ); + } + match res { Ok(resp) if resp.status().is_success() => { let (tx_event, rx_event) = mpsc::channel::>(1600); @@ -374,6 +385,19 @@ async fn process_sse( let _ = tx_event.send(Ok(ResponseEvent::Created {})).await; } } + "response.failed" => { + if let Some(resp_val) = event.response { + let error = resp_val + .get("error") + .and_then(|v| v.get("message")) + .and_then(|v| v.as_str()) + .unwrap_or("response.failed event received"); + + let _ = tx_event + .send(Err(CodexErr::Stream(error.to_string()))) + .await; + } + } // Final response completed – includes array of output items & id "response.completed" => { if let Some(resp_val) = event.response { From 6d82907082a7317e72976e625ecd647a6f439128 Mon Sep 17 00:00:00 2001 From: pakrym-oai Date: Tue, 22 Jul 2025 09:42:22 -0700 Subject: [PATCH 06/58] Add support for custom base instructions (#1645) Allows providing custom instructions file as a config parameter and custom instruction text via MCP tool call. --- codex-rs/core/src/client_common.rs | 9 +- codex-rs/core/src/codex.rs | 36 ++++--- codex-rs/core/src/config.rs | 37 ++++++-- codex-rs/core/src/project_doc.rs | 8 +- codex-rs/core/src/protocol.rs | 8 +- codex-rs/core/tests/client.rs | 95 +++++++++++++++---- codex-rs/core/tests/test_support.rs | 21 ++++ codex-rs/exec/src/lib.rs | 1 + codex-rs/mcp-server/src/codex_tool_config.rs | 12 ++- .../tests/{elicitation.rs => codex_tool.rs} | 84 +++++++++++++++- .../mcp-server/tests/common/mcp_process.rs | 13 +-- codex-rs/tui/src/lib.rs | 1 + 12 files changed, 264 insertions(+), 61 deletions(-) rename codex-rs/mcp-server/tests/{elicitation.rs => codex_tool.rs} (81%) diff --git a/codex-rs/core/src/client_common.rs b/codex-rs/core/src/client_common.rs index 3e3c2e7efa..94d09e7fd4 100644 --- a/codex-rs/core/src/client_common.rs +++ b/codex-rs/core/src/client_common.rs @@ -34,11 +34,18 @@ pub struct Prompt { /// the "fully qualified" tool name (i.e., prefixed with the server name), /// which should be reported to the model in place of Tool::name. pub extra_tools: HashMap, + + /// Optional override for the built-in BASE_INSTRUCTIONS. + pub base_instructions_override: Option, } impl Prompt { pub(crate) fn get_full_instructions(&self, model: &str) -> Cow<'_, str> { - let mut sections: Vec<&str> = vec![BASE_INSTRUCTIONS]; + let base = self + .base_instructions_override + .as_deref() + .unwrap_or(BASE_INSTRUCTIONS); + let mut sections: Vec<&str> = vec![base]; if let Some(ref user) = self.user_instructions { sections.push(user); } diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 392e84ea10..6eb1715fbf 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -108,13 +108,15 @@ impl Codex { let (tx_sub, rx_sub) = async_channel::bounded(64); let (tx_event, rx_event) = async_channel::bounded(1600); - let instructions = get_user_instructions(&config).await; + let user_instructions = get_user_instructions(&config).await; + let configure_session = Op::ConfigureSession { provider: config.model_provider.clone(), model: config.model.clone(), model_reasoning_effort: config.model_reasoning_effort, model_reasoning_summary: config.model_reasoning_summary, - instructions, + user_instructions, + base_instructions: config.base_instructions.clone(), approval_policy: config.approval_policy, sandbox_policy: config.sandbox_policy.clone(), disable_response_storage: config.disable_response_storage, @@ -183,7 +185,8 @@ pub(crate) struct Session { /// the model as well as sandbox policies are resolved against this path /// instead of `std::env::current_dir()`. cwd: PathBuf, - instructions: Option, + base_instructions: Option, + user_instructions: Option, approval_policy: AskForApproval, sandbox_policy: SandboxPolicy, shell_environment_policy: ShellEnvironmentPolicy, @@ -577,7 +580,8 @@ async fn submission_loop( model, model_reasoning_effort, model_reasoning_summary, - instructions, + user_instructions, + base_instructions, approval_policy, sandbox_policy, disable_response_storage, @@ -625,15 +629,17 @@ async fn submission_loop( let rollout_recorder = match rollout_recorder { Some(rec) => Some(rec), - None => match RolloutRecorder::new(&config, session_id, instructions.clone()) - .await - { - Ok(r) => Some(r), - Err(e) => { - warn!("failed to initialise rollout recorder: {e}"); - None + None => { + match RolloutRecorder::new(&config, session_id, user_instructions.clone()) + .await + { + Ok(r) => Some(r), + Err(e) => { + warn!("failed to initialise rollout recorder: {e}"); + None + } } - }, + } }; let client = ModelClient::new( @@ -699,7 +705,8 @@ async fn submission_loop( client, tx_event: tx_event.clone(), ctrl_c: Arc::clone(&ctrl_c), - instructions, + user_instructions, + base_instructions, approval_policy, sandbox_policy, shell_environment_policy: config.shell_environment_policy.clone(), @@ -1067,9 +1074,10 @@ async fn run_turn( let prompt = Prompt { input, prev_id, - user_instructions: sess.instructions.clone(), + user_instructions: sess.user_instructions.clone(), store, extra_tools, + base_instructions_override: sess.base_instructions.clone(), }; let mut retries = 0; diff --git a/codex-rs/core/src/config.rs b/codex-rs/core/src/config.rs index f1d0dd9d60..8ed06c45af 100644 --- a/codex-rs/core/src/config.rs +++ b/codex-rs/core/src/config.rs @@ -63,7 +63,10 @@ pub struct Config { pub disable_response_storage: bool, /// User-provided instructions from instructions.md. - pub instructions: Option, + pub user_instructions: Option, + + /// Base instructions override. + pub base_instructions: Option, /// Optional external notifier command. When set, Codex will spawn this /// program after each completed *turn* (i.e. when the agent finishes @@ -327,6 +330,9 @@ pub struct ConfigToml { /// Experimental rollout resume path (absolute path to .jsonl; undocumented). pub experimental_resume: Option, + + /// Experimental path to a file whose contents replace the built-in BASE_INSTRUCTIONS. + pub experimental_instructions_file: Option, } impl ConfigToml { @@ -359,6 +365,7 @@ pub struct ConfigOverrides { pub model_provider: Option, pub config_profile: Option, pub codex_linux_sandbox_exe: Option, + pub base_instructions: Option, } impl Config { @@ -369,7 +376,7 @@ impl Config { overrides: ConfigOverrides, codex_home: PathBuf, ) -> std::io::Result { - let instructions = Self::load_instructions(Some(&codex_home)); + let user_instructions = Self::load_instructions(Some(&codex_home)); // Destructure ConfigOverrides fully to ensure all overrides are applied. let ConfigOverrides { @@ -380,6 +387,7 @@ impl Config { model_provider, config_profile: config_profile_key, codex_linux_sandbox_exe, + base_instructions, } = overrides; let config_profile = match config_profile_key.as_ref().or(cfg.profile.as_ref()) { @@ -457,6 +465,10 @@ impl Config { let experimental_resume = cfg.experimental_resume; + let base_instructions = base_instructions.or(Self::get_base_instructions( + cfg.experimental_instructions_file.as_ref(), + )); + let config = Self { model, model_context_window, @@ -475,7 +487,8 @@ impl Config { .or(cfg.disable_response_storage) .unwrap_or(false), notify: cfg.notify, - instructions, + user_instructions, + base_instructions, mcp_servers: cfg.mcp_servers, model_providers, project_doc_max_bytes: cfg.project_doc_max_bytes.unwrap_or(PROJECT_DOC_MAX_BYTES), @@ -525,6 +538,15 @@ impl Config { } }) } + + fn get_base_instructions(path: Option<&PathBuf>) -> Option { + let path = path.as_ref()?; + + std::fs::read_to_string(path) + .ok() + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + } } fn default_model() -> String { @@ -801,7 +823,7 @@ disable_response_storage = true sandbox_policy: SandboxPolicy::new_read_only_policy(), shell_environment_policy: ShellEnvironmentPolicy::default(), disable_response_storage: false, - instructions: None, + user_instructions: None, notify: None, cwd: fixture.cwd(), mcp_servers: HashMap::new(), @@ -818,6 +840,7 @@ disable_response_storage = true model_supports_reasoning_summaries: false, chatgpt_base_url: "https://chatgpt.com/backend-api/".to_string(), experimental_resume: None, + base_instructions: None, }, o3_profile_config ); @@ -848,7 +871,7 @@ disable_response_storage = true sandbox_policy: SandboxPolicy::new_read_only_policy(), shell_environment_policy: ShellEnvironmentPolicy::default(), disable_response_storage: false, - instructions: None, + user_instructions: None, notify: None, cwd: fixture.cwd(), mcp_servers: HashMap::new(), @@ -865,6 +888,7 @@ disable_response_storage = true model_supports_reasoning_summaries: false, chatgpt_base_url: "https://chatgpt.com/backend-api/".to_string(), experimental_resume: None, + base_instructions: None, }; assert_eq!(expected_gpt3_profile_config, gpt3_profile_config); @@ -910,7 +934,7 @@ disable_response_storage = true sandbox_policy: SandboxPolicy::new_read_only_policy(), shell_environment_policy: ShellEnvironmentPolicy::default(), disable_response_storage: true, - instructions: None, + user_instructions: None, notify: None, cwd: fixture.cwd(), mcp_servers: HashMap::new(), @@ -927,6 +951,7 @@ disable_response_storage = true model_supports_reasoning_summaries: false, chatgpt_base_url: "https://chatgpt.com/backend-api/".to_string(), experimental_resume: None, + base_instructions: None, }; assert_eq!(expected_zdr_profile_config, zdr_profile_config); diff --git a/codex-rs/core/src/project_doc.rs b/codex-rs/core/src/project_doc.rs index ab9d46186f..9f46159d1d 100644 --- a/codex-rs/core/src/project_doc.rs +++ b/codex-rs/core/src/project_doc.rs @@ -27,16 +27,16 @@ const PROJECT_DOC_SEPARATOR: &str = "\n\n--- project-doc ---\n\n"; /// string of instructions. pub(crate) async fn get_user_instructions(config: &Config) -> Option { match find_project_doc(config).await { - Ok(Some(project_doc)) => match &config.instructions { + Ok(Some(project_doc)) => match &config.user_instructions { Some(original_instructions) => Some(format!( "{original_instructions}{PROJECT_DOC_SEPARATOR}{project_doc}" )), None => Some(project_doc), }, - Ok(None) => config.instructions.clone(), + Ok(None) => config.user_instructions.clone(), Err(e) => { error!("error trying to find project doc: {e:#}"); - config.instructions.clone() + config.user_instructions.clone() } } } @@ -159,7 +159,7 @@ mod tests { config.cwd = root.path().to_path_buf(); config.project_doc_max_bytes = limit; - config.instructions = instructions.map(ToOwned::to_owned); + config.user_instructions = instructions.map(ToOwned::to_owned); config } diff --git a/codex-rs/core/src/protocol.rs b/codex-rs/core/src/protocol.rs index 08d55b9749..9f6e004b67 100644 --- a/codex-rs/core/src/protocol.rs +++ b/codex-rs/core/src/protocol.rs @@ -44,8 +44,12 @@ pub enum Op { model_reasoning_effort: ReasoningEffortConfig, model_reasoning_summary: ReasoningSummaryConfig, - /// Model instructions - instructions: Option, + /// Model instructions that are appended to the base instructions. + user_instructions: Option, + + /// Base instructions override. + base_instructions: Option, + /// When to escalate for approval for execution approval_policy: AskForApproval, /// How to sandbox commands executed in the system diff --git a/codex-rs/core/tests/client.rs b/codex-rs/core/tests/client.rs index fe4710c89b..5a6b6100eb 100644 --- a/codex-rs/core/tests/client.rs +++ b/codex-rs/core/tests/client.rs @@ -1,5 +1,3 @@ -use std::time::Duration; - use codex_core::Codex; use codex_core::ModelProviderInfo; use codex_core::exec::CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR; @@ -11,7 +9,6 @@ mod test_support; use tempfile::TempDir; use test_support::load_default_config_for_test; use test_support::load_sse_fixture_with_id; -use tokio::time::timeout; use wiremock::Mock; use wiremock::MockServer; use wiremock::ResponseTemplate; @@ -86,21 +83,15 @@ async fn includes_session_id_and_model_headers_in_request() { .await .unwrap(); - let mut current_session_id = None; - // Wait for TaskComplete - loop { - let ev = timeout(Duration::from_secs(1), codex.next_event()) + let EventMsg::SessionConfigured(SessionConfiguredEvent { session_id, .. }) = + test_support::wait_for_event(&codex, |ev| matches!(ev, EventMsg::SessionConfigured(_))) .await - .unwrap() - .unwrap(); + else { + unreachable!() + }; - if let EventMsg::SessionConfigured(SessionConfiguredEvent { session_id, .. }) = ev.msg { - current_session_id = Some(session_id.to_string()); - } - if matches!(ev.msg, EventMsg::TaskComplete(_)) { - break; - } - } + let current_session_id = Some(session_id.to_string()); + test_support::wait_for_event(&codex, |ev| matches!(ev, EventMsg::TaskComplete(_))).await; // get request from the server let request = &server.received_requests().await.unwrap()[0]; @@ -108,6 +99,76 @@ async fn includes_session_id_and_model_headers_in_request() { let originator = request.headers.get("originator").unwrap(); assert!(current_session_id.is_some()); - assert_eq!(request_body.to_str().unwrap(), ¤t_session_id.unwrap()); + assert_eq!( + request_body.to_str().unwrap(), + current_session_id.as_ref().unwrap() + ); assert_eq!(originator.to_str().unwrap(), "codex_cli_rs"); } + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn includes_base_instructions_override_in_request() { + #![allow(clippy::unwrap_used)] + + // Mock server + let server = MockServer::start().await; + + // First request – must NOT include `previous_response_id`. + let first = ResponseTemplate::new(200) + .insert_header("content-type", "text/event-stream") + .set_body_raw(sse_completed("resp1"), "text/event-stream"); + + Mock::given(method("POST")) + .and(path("/v1/responses")) + .respond_with(first) + .expect(1) + .mount(&server) + .await; + + let model_provider = ModelProviderInfo { + name: "openai".into(), + base_url: format!("{}/v1", server.uri()), + // Environment variable that should exist in the test environment. + // ModelClient will return an error if the environment variable for the + // provider is not set. + env_key: Some("PATH".into()), + env_key_instructions: None, + wire_api: codex_core::WireApi::Responses, + query_params: None, + http_headers: None, + env_http_headers: None, + request_max_retries: Some(0), + stream_max_retries: Some(0), + stream_idle_timeout_ms: None, + }; + + let codex_home = TempDir::new().unwrap(); + let mut config = load_default_config_for_test(&codex_home); + + config.base_instructions = Some("test instructions".to_string()); + config.model_provider = model_provider; + + let ctrl_c = std::sync::Arc::new(tokio::sync::Notify::new()); + let (codex, ..) = Codex::spawn(config, ctrl_c.clone()).await.unwrap(); + + codex + .submit(Op::UserInput { + items: vec![InputItem::Text { + text: "hello".into(), + }], + }) + .await + .unwrap(); + + test_support::wait_for_event(&codex, |ev| matches!(ev, EventMsg::TaskComplete(_))).await; + + let request = &server.received_requests().await.unwrap()[0]; + let request_body = request.body_json::().unwrap(); + + assert!( + request_body["instructions"] + .as_str() + .unwrap() + .contains("test instructions") + ); +} diff --git a/codex-rs/core/tests/test_support.rs b/codex-rs/core/tests/test_support.rs index 7d1e3a7fef..83b8a14793 100644 --- a/codex-rs/core/tests/test_support.rs +++ b/codex-rs/core/tests/test_support.rs @@ -76,3 +76,24 @@ pub fn load_sse_fixture_with_id(path: impl AsRef, id: &str) -> }) .collect() } + +#[allow(dead_code)] +pub async fn wait_for_event( + codex: &codex_core::Codex, + mut predicate: F, +) -> codex_core::protocol::EventMsg +where + F: FnMut(&codex_core::protocol::EventMsg) -> bool, +{ + use tokio::time::Duration; + use tokio::time::timeout; + loop { + let ev = timeout(Duration::from_secs(1), codex.next_event()) + .await + .expect("timeout waiting for event") + .expect("stream ended unexpectedly"); + if predicate(&ev.msg) { + return ev.msg; + } + } +} diff --git a/codex-rs/exec/src/lib.rs b/codex-rs/exec/src/lib.rs index 769d3c3b01..620ab82327 100644 --- a/codex-rs/exec/src/lib.rs +++ b/codex-rs/exec/src/lib.rs @@ -110,6 +110,7 @@ pub async fn run_main(cli: Cli, codex_linux_sandbox_exe: Option) -> any cwd: cwd.map(|p| p.canonicalize().unwrap_or(p)), model_provider: None, codex_linux_sandbox_exe, + base_instructions: None, }; // Parse `-c` overrides. let cli_kv_overrides = match config_overrides.parse_overrides() { diff --git a/codex-rs/mcp-server/src/codex_tool_config.rs b/codex-rs/mcp-server/src/codex_tool_config.rs index 54d108c0fd..6357c94bd1 100644 --- a/codex-rs/mcp-server/src/codex_tool_config.rs +++ b/codex-rs/mcp-server/src/codex_tool_config.rs @@ -14,7 +14,7 @@ use std::path::PathBuf; use crate::json_to_toml::json_to_toml; /// Client-supplied configuration for a `codex` tool-call. -#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, Default)] #[serde(rename_all = "kebab-case")] pub struct CodexToolCallParam { /// The *initial user prompt* to start the Codex conversation. @@ -46,6 +46,10 @@ pub struct CodexToolCallParam { /// CODEX_HOME/config.toml. #[serde(default, skip_serializing_if = "Option::is_none")] pub config: Option>, + + /// The set of instructions to use instead of the default ones. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub base_instructions: Option, } /// Custom enum mirroring [`AskForApproval`], but has an extra dependency on @@ -135,6 +139,7 @@ impl CodexToolCallParam { approval_policy, sandbox, config: cli_overrides, + base_instructions, } = self; // Build the `ConfigOverrides` recognised by codex-core. @@ -146,6 +151,7 @@ impl CodexToolCallParam { sandbox_mode: sandbox.map(Into::into), model_provider: None, codex_linux_sandbox_exe, + base_instructions, }; let cli_overrides = cli_overrides @@ -268,6 +274,10 @@ mod tests { "description": "The *initial user prompt* to start the Codex conversation.", "type": "string" }, + "base-instructions": { + "description": "The set of instructions to use instead of the default ones.", + "type": "string" + }, }, "required": [ "prompt" diff --git a/codex-rs/mcp-server/tests/elicitation.rs b/codex-rs/mcp-server/tests/codex_tool.rs similarity index 81% rename from codex-rs/mcp-server/tests/elicitation.rs rename to codex-rs/mcp-server/tests/codex_tool.rs index ac9435e874..d36813ce9f 100644 --- a/codex-rs/mcp-server/tests/elicitation.rs +++ b/codex-rs/mcp-server/tests/codex_tool.rs @@ -8,6 +8,7 @@ use std::path::PathBuf; use codex_core::exec::CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR; use codex_core::protocol::FileChange; use codex_core::protocol::ReviewDecision; +use codex_mcp_server::CodexToolCallParam; use codex_mcp_server::ExecApprovalElicitRequestParams; use codex_mcp_server::ExecApprovalResponse; use codex_mcp_server::PatchApprovalElicitRequestParams; @@ -76,7 +77,10 @@ async fn shell_command_approval_triggers_elicitation() -> anyhow::Result<()> { // In turn, it should reply with a tool call, which the MCP should forward // as an elicitation. let codex_request_id = mcp_process - .send_codex_tool_call(None, "run `git init`") + .send_codex_tool_call(CodexToolCallParam { + prompt: "run `git init`".to_string(), + ..Default::default() + }) .await?; let elicitation_request = timeout( DEFAULT_READ_TIMEOUT, @@ -209,10 +213,11 @@ async fn patch_approval_triggers_elicitation() -> anyhow::Result<()> { // Send a "codex" tool request that will trigger the apply_patch command let codex_request_id = mcp_process - .send_codex_tool_call( - Some(cwd.path().to_string_lossy().to_string()), - "please modify the test file", - ) + .send_codex_tool_call(CodexToolCallParam { + cwd: Some(cwd.path().to_string_lossy().to_string()), + prompt: "please modify the test file".to_string(), + ..Default::default() + }) .await?; let elicitation_request = timeout( DEFAULT_READ_TIMEOUT, @@ -279,6 +284,75 @@ async fn patch_approval_triggers_elicitation() -> anyhow::Result<()> { Ok(()) } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn test_codex_tool_passes_base_instructions() { + if std::env::var(CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR).is_ok() { + println!( + "Skipping test because it cannot execute when network is disabled in a Codex sandbox." + ); + return; + } + + // Apparently `#[tokio::test]` must return `()`, so we create a helper + // function that returns `Result` so we can use `?` in favor of `unwrap`. + if let Err(err) = codex_tool_passes_base_instructions().await { + panic!("failure: {err}"); + } +} + +async fn codex_tool_passes_base_instructions() -> anyhow::Result<()> { + #![allow(clippy::unwrap_used)] + + let server = + create_mock_chat_completions_server(vec![create_final_assistant_message_sse_response( + "Enjoy!", + )?]) + .await; + + // Run `codex mcp` with a specific config.toml. + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path(), &server.uri())?; + let mut mcp_process = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp_process.initialize()).await??; + + // Send a "codex" tool request, which should hit the completions endpoint. + let codex_request_id = mcp_process + .send_codex_tool_call(CodexToolCallParam { + prompt: "How are you?".to_string(), + base_instructions: Some("You are a helpful assistant.".to_string()), + ..Default::default() + }) + .await?; + + let codex_response = timeout( + DEFAULT_READ_TIMEOUT, + mcp_process.read_stream_until_response_message(RequestId::Integer(codex_request_id)), + ) + .await??; + assert_eq!( + JSONRPCResponse { + jsonrpc: JSONRPC_VERSION.into(), + id: RequestId::Integer(codex_request_id), + result: json!({ + "content": [ + { + "text": "Enjoy!", + "type": "text" + } + ] + }), + }, + codex_response + ); + + let requests = server.received_requests().await.unwrap(); + let request = requests[0].body_json::().unwrap(); + let instructions = request["messages"][0]["content"].as_str().unwrap(); + assert!(instructions.starts_with("You are a helpful assistant.")); + + Ok(()) +} + fn create_expected_patch_approval_elicitation_request( elicitation_request_id: RequestId, changes: HashMap, diff --git a/codex-rs/mcp-server/tests/common/mcp_process.rs b/codex-rs/mcp-server/tests/common/mcp_process.rs index df9cc98acf..a86deaab75 100644 --- a/codex-rs/mcp-server/tests/common/mcp_process.rs +++ b/codex-rs/mcp-server/tests/common/mcp_process.rs @@ -141,20 +141,11 @@ impl McpProcess { /// correlating notifications. pub async fn send_codex_tool_call( &mut self, - cwd: Option, - prompt: &str, + params: CodexToolCallParam, ) -> anyhow::Result { let codex_tool_call_params = CallToolRequestParams { name: "codex".to_string(), - arguments: Some(serde_json::to_value(CodexToolCallParam { - cwd, - prompt: prompt.to_string(), - model: None, - profile: None, - approval_policy: None, - sandbox: None, - config: None, - })?), + arguments: Some(serde_json::to_value(params)?), }; self.send_request( mcp_types::CallToolRequest::METHOD, diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index 4ca305b35e..05a55edc7b 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -75,6 +75,7 @@ pub fn run_main(cli: Cli, codex_linux_sandbox_exe: Option) -> std::io:: model_provider: None, config_profile: cli.config_profile.clone(), codex_linux_sandbox_exe, + base_instructions: None, }; // Parse `-c` overrides from the CLI. let cli_kv_overrides = match cli.config_overrides.parse_overrides() { From 4082246f6aac288a817330b92cfd5573df61f0e7 Mon Sep 17 00:00:00 2001 From: Michael Bolin Date: Tue, 22 Jul 2025 10:58:09 -0700 Subject: [PATCH 07/58] chore: install an extension for TOML syntax highlighting in the devcontainer (#1650) Small quality-of-life improvement when doing devcontainer development. --- .devcontainer/devcontainer.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index f276868484..1bed79c3ca 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -21,7 +21,7 @@ "settings": { "terminal.integrated.defaultProfile.linux": "bash" }, - "extensions": ["rust-lang.rust-analyzer"] + "extensions": ["rust-lang.rust-analyzer", "tamasfe.even-better-toml"] } } } From 01c0896f0f0867ba072ca8e742532252220049bc Mon Sep 17 00:00:00 2001 From: aibrahim-oai Date: Tue, 22 Jul 2025 13:33:49 -0700 Subject: [PATCH 08/58] Adding interrupt Support to MCP (#1646) --- codex-rs/mcp-server/src/codex_tool_config.rs | 2 +- codex-rs/mcp-server/src/codex_tool_runner.rs | 49 ++++- codex-rs/mcp-server/src/lib.rs | 3 +- codex-rs/mcp-server/src/message_processor.rs | 98 ++++++++-- .../mcp-server/tests/common/mcp_process.rs | 81 +++++++- codex-rs/mcp-server/tests/common/mod.rs | 2 + codex-rs/mcp-server/tests/common/responses.rs | 4 + codex-rs/mcp-server/tests/interrupt.rs | 176 ++++++++++++++++++ 8 files changed, 389 insertions(+), 26 deletions(-) create mode 100644 codex-rs/mcp-server/tests/interrupt.rs diff --git a/codex-rs/mcp-server/src/codex_tool_config.rs b/codex-rs/mcp-server/src/codex_tool_config.rs index 6357c94bd1..9f6f7a782d 100644 --- a/codex-rs/mcp-server/src/codex_tool_config.rs +++ b/codex-rs/mcp-server/src/codex_tool_config.rs @@ -168,7 +168,7 @@ impl CodexToolCallParam { #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] #[serde(rename_all = "camelCase")] -pub(crate) struct CodexToolCallReplyParam { +pub struct CodexToolCallReplyParam { /// The *session id* for this conversation. pub session_id: String, diff --git a/codex-rs/mcp-server/src/codex_tool_runner.rs b/codex-rs/mcp-server/src/codex_tool_runner.rs index df2154dd1f..9aaab54395 100644 --- a/codex-rs/mcp-server/src/codex_tool_runner.rs +++ b/codex-rs/mcp-server/src/codex_tool_runner.rs @@ -20,6 +20,7 @@ use mcp_types::CallToolResult; use mcp_types::ContentBlock; use mcp_types::RequestId; use mcp_types::TextContent; +use serde_json::json; use tokio::sync::Mutex; use uuid::Uuid; @@ -39,6 +40,7 @@ pub async fn run_codex_tool_session( config: CodexConfig, outgoing: Arc, session_map: Arc>>>, + running_requests_id_to_codex_uuid: Arc>>, ) { let (codex, first_event, _ctrl_c, session_id) = match init_codex(config).await { Ok(res) => res, @@ -73,7 +75,10 @@ pub async fn run_codex_tool_session( RequestId::String(s) => s.clone(), RequestId::Integer(n) => n.to_string(), }; - + running_requests_id_to_codex_uuid + .lock() + .await + .insert(id.clone(), session_id); let submission = Submission { id: sub_id.clone(), op: Op::UserInput { @@ -85,9 +90,12 @@ pub async fn run_codex_tool_session( if let Err(e) = codex.submit_with_id(submission).await { tracing::error!("Failed to submit initial prompt: {e}"); + // unregister the id so we don't keep it in the map + running_requests_id_to_codex_uuid.lock().await.remove(&id); + return; } - run_codex_tool_session_inner(codex, outgoing, id).await; + run_codex_tool_session_inner(codex, outgoing, id, running_requests_id_to_codex_uuid).await; } pub async fn run_codex_tool_session_reply( @@ -95,7 +103,13 @@ pub async fn run_codex_tool_session_reply( outgoing: Arc, request_id: RequestId, prompt: String, + running_requests_id_to_codex_uuid: Arc>>, + session_id: Uuid, ) { + running_requests_id_to_codex_uuid + .lock() + .await + .insert(request_id.clone(), session_id); if let Err(e) = codex .submit(Op::UserInput { items: vec![InputItem::Text { text: prompt }], @@ -103,15 +117,28 @@ pub async fn run_codex_tool_session_reply( .await { tracing::error!("Failed to submit user input: {e}"); + // unregister the id so we don't keep it in the map + running_requests_id_to_codex_uuid + .lock() + .await + .remove(&request_id); + return; } - run_codex_tool_session_inner(codex, outgoing, request_id).await; + run_codex_tool_session_inner( + codex, + outgoing, + request_id, + running_requests_id_to_codex_uuid, + ) + .await; } async fn run_codex_tool_session_inner( codex: Arc, outgoing: Arc, request_id: RequestId, + running_requests_id_to_codex_uuid: Arc>>, ) { let request_id_str = match &request_id { RequestId::String(s) => s.clone(), @@ -143,6 +170,14 @@ async fn run_codex_tool_session_inner( .await; continue; } + EventMsg::Error(err_event) => { + // Return a response to conclude the tool call when the Codex session reports an error (e.g., interruption). + let result = json!({ + "error": err_event.message, + }); + outgoing.send_response(request_id.clone(), result).await; + break; + } EventMsg::ApplyPatchApprovalRequest(ApplyPatchApprovalRequestEvent { reason, grant_root, @@ -178,6 +213,11 @@ async fn run_codex_tool_session_inner( outgoing .send_response(request_id.clone(), result.into()) .await; + // unregister the id so we don't keep it in the map + running_requests_id_to_codex_uuid + .lock() + .await + .remove(&request_id); break; } EventMsg::SessionConfigured(_) => { @@ -192,8 +232,7 @@ async fn run_codex_tool_session_inner( EventMsg::AgentMessage(AgentMessageEvent { .. }) => { // TODO: think how we want to support this in the MCP } - EventMsg::Error(_) - | EventMsg::TaskStarted + EventMsg::TaskStarted | EventMsg::TokenCount(_) | EventMsg::AgentReasoning(_) | EventMsg::McpToolCallBegin(_) diff --git a/codex-rs/mcp-server/src/lib.rs b/codex-rs/mcp-server/src/lib.rs index 300d1b5fe5..79981e4992 100644 --- a/codex-rs/mcp-server/src/lib.rs +++ b/codex-rs/mcp-server/src/lib.rs @@ -27,6 +27,7 @@ use crate::outgoing_message::OutgoingMessage; use crate::outgoing_message::OutgoingMessageSender; pub use crate::codex_tool_config::CodexToolCallParam; +pub use crate::codex_tool_config::CodexToolCallReplyParam; pub use crate::exec_approval::ExecApprovalElicitRequestParams; pub use crate::exec_approval::ExecApprovalResponse; pub use crate::patch_approval::PatchApprovalElicitRequestParams; @@ -81,7 +82,7 @@ pub async fn run_main(codex_linux_sandbox_exe: Option) -> IoResult<()> match msg { JSONRPCMessage::Request(r) => processor.process_request(r).await, JSONRPCMessage::Response(r) => processor.process_response(r).await, - JSONRPCMessage::Notification(n) => processor.process_notification(n), + JSONRPCMessage::Notification(n) => processor.process_notification(n).await, JSONRPCMessage::Error(e) => processor.process_error(e), } } diff --git a/codex-rs/mcp-server/src/message_processor.rs b/codex-rs/mcp-server/src/message_processor.rs index e72a52e006..7ba827d60b 100644 --- a/codex-rs/mcp-server/src/message_processor.rs +++ b/codex-rs/mcp-server/src/message_processor.rs @@ -10,6 +10,7 @@ use crate::outgoing_message::OutgoingMessageSender; use codex_core::Codex; use codex_core::config::Config as CodexConfig; +use codex_core::protocol::Submission; use mcp_types::CallToolRequestParams; use mcp_types::CallToolResult; use mcp_types::ClientRequest; @@ -35,6 +36,7 @@ pub(crate) struct MessageProcessor { initialized: bool, codex_linux_sandbox_exe: Option, session_map: Arc>>>, + running_requests_id_to_codex_uuid: Arc>>, } impl MessageProcessor { @@ -49,6 +51,7 @@ impl MessageProcessor { initialized: false, codex_linux_sandbox_exe, session_map: Arc::new(Mutex::new(HashMap::new())), + running_requests_id_to_codex_uuid: Arc::new(Mutex::new(HashMap::new())), } } @@ -116,7 +119,7 @@ impl MessageProcessor { } /// Handle a fire-and-forget JSON-RPC notification. - pub(crate) fn process_notification(&mut self, notification: JSONRPCNotification) { + pub(crate) async fn process_notification(&mut self, notification: JSONRPCNotification) { let server_notification = match ServerNotification::try_from(notification) { Ok(n) => n, Err(e) => { @@ -129,7 +132,7 @@ impl MessageProcessor { // handler so additional logic can be implemented incrementally. match server_notification { ServerNotification::CancelledNotification(params) => { - self.handle_cancelled_notification(params); + self.handle_cancelled_notification(params).await; } ServerNotification::ProgressNotification(params) => { self.handle_progress_notification(params); @@ -379,6 +382,7 @@ impl MessageProcessor { // Clone outgoing and session map to move into async task. let outgoing = self.outgoing.clone(); let session_map = self.session_map.clone(); + let running_requests_id_to_codex_uuid = self.running_requests_id_to_codex_uuid.clone(); // Spawn an async task to handle the Codex session so that we do not // block the synchronous message-processing loop. @@ -390,6 +394,7 @@ impl MessageProcessor { config, outgoing, session_map, + running_requests_id_to_codex_uuid, ) .await; }); @@ -464,13 +469,12 @@ impl MessageProcessor { // Clone outgoing and session map to move into async task. let outgoing = self.outgoing.clone(); + let running_requests_id_to_codex_uuid = self.running_requests_id_to_codex_uuid.clone(); - // Spawn an async task to handle the Codex session so that we do not - // block the synchronous message-processing loop. - task::spawn(async move { + let codex = { let session_map = session_map_mutex.lock().await; - let codex = match session_map.get(&session_id) { - Some(codex) => codex, + match session_map.get(&session_id).cloned() { + Some(c) => c, None => { tracing::warn!("Session not found for session_id: {session_id}"); let result = CallToolResult { @@ -482,21 +486,32 @@ impl MessageProcessor { is_error: Some(true), structured_content: None, }; - // unwrap_or_default is fine here because we know the result is valid JSON outgoing .send_response(request_id, serde_json::to_value(result).unwrap_or_default()) .await; return; } - }; + } + }; - crate::codex_tool_runner::run_codex_tool_session_reply( - codex.clone(), - outgoing, - request_id, - prompt.clone(), - ) - .await; + // Spawn the long-running reply handler. + tokio::spawn({ + let codex = codex.clone(); + let outgoing = outgoing.clone(); + let prompt = prompt.clone(); + let running_requests_id_to_codex_uuid = running_requests_id_to_codex_uuid.clone(); + + async move { + crate::codex_tool_runner::run_codex_tool_session_reply( + codex, + outgoing, + request_id, + prompt, + running_requests_id_to_codex_uuid, + session_id, + ) + .await; + } }); } @@ -518,11 +533,58 @@ impl MessageProcessor { // Notification handlers // --------------------------------------------------------------------- - fn handle_cancelled_notification( + async fn handle_cancelled_notification( &self, params: ::Params, ) { - tracing::info!("notifications/cancelled -> params: {:?}", params); + let request_id = params.request_id; + // Create a stable string form early for logging and submission id. + let request_id_string = match &request_id { + RequestId::String(s) => s.clone(), + RequestId::Integer(i) => i.to_string(), + }; + + // Obtain the session_id while holding the first lock, then release. + let session_id = { + let map_guard = self.running_requests_id_to_codex_uuid.lock().await; + match map_guard.get(&request_id) { + Some(id) => *id, // Uuid is Copy + None => { + tracing::warn!("Session not found for request_id: {}", request_id_string); + return; + } + } + }; + tracing::info!("session_id: {session_id}"); + + // Obtain the Codex Arc while holding the session_map lock, then release. + let codex_arc = { + let sessions_guard = self.session_map.lock().await; + match sessions_guard.get(&session_id) { + Some(codex) => Arc::clone(codex), + None => { + tracing::warn!("Session not found for session_id: {session_id}"); + return; + } + } + }; + + // Submit interrupt to Codex. + let err = codex_arc + .submit_with_id(Submission { + id: request_id_string, + op: codex_core::protocol::Op::Interrupt, + }) + .await; + if let Err(e) = err { + tracing::error!("Failed to submit interrupt to Codex: {e}"); + return; + } + // unregister the id so we don't keep it in the map + self.running_requests_id_to_codex_uuid + .lock() + .await + .remove(&request_id); } fn handle_progress_notification( diff --git a/codex-rs/mcp-server/tests/common/mcp_process.rs b/codex-rs/mcp-server/tests/common/mcp_process.rs index a86deaab75..8f1f7a9e36 100644 --- a/codex-rs/mcp-server/tests/common/mcp_process.rs +++ b/codex-rs/mcp-server/tests/common/mcp_process.rs @@ -12,6 +12,7 @@ use tokio::process::ChildStdout; use anyhow::Context; use assert_cmd::prelude::*; use codex_mcp_server::CodexToolCallParam; +use codex_mcp_server::CodexToolCallReplyParam; use mcp_types::CallToolRequestParams; use mcp_types::ClientCapabilities; use mcp_types::Implementation; @@ -154,6 +155,25 @@ impl McpProcess { .await } + pub async fn send_codex_reply_tool_call( + &mut self, + session_id: &str, + prompt: &str, + ) -> anyhow::Result { + let codex_tool_call_params = CallToolRequestParams { + name: "codex-reply".to_string(), + arguments: Some(serde_json::to_value(CodexToolCallReplyParam { + prompt: prompt.to_string(), + session_id: session_id.to_string(), + })?), + }; + self.send_request( + mcp_types::CallToolRequest::METHOD, + Some(serde_json::to_value(codex_tool_call_params)?), + ) + .await + } + async fn send_request( &mut self, method: &str, @@ -171,6 +191,8 @@ impl McpProcess { Ok(request_id) } + // allow dead code + #[allow(dead_code)] pub async fn send_response( &mut self, id: RequestId, @@ -198,7 +220,8 @@ impl McpProcess { let message = serde_json::from_str::(&line)?; Ok(message) } - + // allow dead code + #[allow(dead_code)] pub async fn read_stream_until_request_message(&mut self) -> anyhow::Result { loop { let message = self.read_jsonrpc_message().await?; @@ -221,6 +244,8 @@ impl McpProcess { } } + // allow dead code + #[allow(dead_code)] pub async fn read_stream_until_response_message( &mut self, request_id: RequestId, @@ -247,4 +272,58 @@ impl McpProcess { } } } + + pub async fn read_stream_until_configured_response_message( + &mut self, + ) -> anyhow::Result { + loop { + let message = self.read_jsonrpc_message().await?; + eprint!("message: {message:?}"); + + match message { + JSONRPCMessage::Notification(notification) => { + if notification.method == "codex/event" { + if let Some(params) = notification.params { + if let Some(msg) = params.get("msg") { + if let Some(msg_type) = msg.get("type") { + if msg_type == "session_configured" { + if let Some(session_id) = msg.get("session_id") { + return Ok(session_id + .to_string() + .trim_matches('"') + .to_string()); + } + } + } + } + } + } + } + JSONRPCMessage::Request(_) => { + anyhow::bail!("unexpected JSONRPCMessage::Request: {message:?}"); + } + JSONRPCMessage::Error(_) => { + anyhow::bail!("unexpected JSONRPCMessage::Error: {message:?}"); + } + JSONRPCMessage::Response(_) => { + anyhow::bail!("unexpected JSONRPCMessage::Response: {message:?}"); + } + } + } + } + + // allow dead code + #[allow(dead_code)] + pub async fn send_notification( + &mut self, + method: &str, + params: Option, + ) -> anyhow::Result<()> { + self.send_jsonrpc_message(JSONRPCMessage::Notification(JSONRPCNotification { + jsonrpc: JSONRPC_VERSION.into(), + method: method.to_string(), + params, + })) + .await + } } diff --git a/codex-rs/mcp-server/tests/common/mod.rs b/codex-rs/mcp-server/tests/common/mod.rs index b338e2e8ce..a9593e399a 100644 --- a/codex-rs/mcp-server/tests/common/mod.rs +++ b/codex-rs/mcp-server/tests/common/mod.rs @@ -4,6 +4,8 @@ mod responses; pub use mcp_process::McpProcess; pub use mock_model_server::create_mock_chat_completions_server; +#[allow(unused_imports)] pub use responses::create_apply_patch_sse_response; +#[allow(unused_imports)] pub use responses::create_final_assistant_message_sse_response; pub use responses::create_shell_sse_response; diff --git a/codex-rs/mcp-server/tests/common/responses.rs b/codex-rs/mcp-server/tests/common/responses.rs index 9a827fb986..f47952a5ac 100644 --- a/codex-rs/mcp-server/tests/common/responses.rs +++ b/codex-rs/mcp-server/tests/common/responses.rs @@ -39,6 +39,8 @@ pub fn create_shell_sse_response( Ok(sse) } +// allow dead code +#[allow(dead_code)] pub fn create_final_assistant_message_sse_response(message: &str) -> anyhow::Result { let assistant_message = json!({ "choices": [ @@ -58,6 +60,8 @@ pub fn create_final_assistant_message_sse_response(message: &str) -> anyhow::Res Ok(sse) } +// allow dead code +#[allow(dead_code)] pub fn create_apply_patch_sse_response( patch_content: &str, call_id: &str, diff --git a/codex-rs/mcp-server/tests/interrupt.rs b/codex-rs/mcp-server/tests/interrupt.rs new file mode 100644 index 0000000000..64cf8b477e --- /dev/null +++ b/codex-rs/mcp-server/tests/interrupt.rs @@ -0,0 +1,176 @@ +#![cfg(unix)] +mod common; + +use std::path::Path; + +use codex_core::exec::CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR; +use codex_mcp_server::CodexToolCallParam; +use mcp_types::JSONRPCResponse; +use mcp_types::RequestId; +use serde_json::json; +use tempfile::TempDir; +use tokio::time::timeout; + +use crate::common::McpProcess; +use crate::common::create_mock_chat_completions_server; +use crate::common::create_shell_sse_response; + +const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn test_shell_command_interruption() { + if std::env::var(CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR).is_ok() { + println!( + "Skipping test because it cannot execute when network is disabled in a Codex sandbox." + ); + return; + } + + if let Err(err) = shell_command_interruption().await { + panic!("failure: {err}"); + } +} + +async fn shell_command_interruption() -> anyhow::Result<()> { + // Use a cross-platform blocking command. On Windows plain `sleep` is not guaranteed to exist + // (MSYS/GNU coreutils may be absent) and the failure causes the tool call to finish immediately, + // which triggers a second model request before the test sends the explicit follow-up. That + // prematurely consumes the second mocked SSE response and leads to a third POST (panic: no response for 2). + // Powershell Start-Sleep is always available on Windows runners. On Unix we keep using `sleep`. + #[cfg(target_os = "windows")] + let shell_command = vec![ + "powershell".to_string(), + "-Command".to_string(), + "Start-Sleep -Seconds 60".to_string(), + ]; + #[cfg(not(target_os = "windows"))] + let shell_command = vec!["sleep".to_string(), "60".to_string()]; + let workdir_for_shell_function_call = TempDir::new()?; + + // Create mock server with a single SSE response: the long sleep command + let server = create_mock_chat_completions_server(vec![ + create_shell_sse_response( + shell_command.clone(), + Some(workdir_for_shell_function_call.path()), + Some(60_000), // 60 seconds timeout in ms + "call_sleep", + )?, + create_shell_sse_response( + shell_command.clone(), + Some(workdir_for_shell_function_call.path()), + Some(60_000), // 60 seconds timeout in ms + "call_sleep", + )?, + ]) + .await; + + // Create Codex configuration + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path(), server.uri())?; + let mut mcp_process = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp_process.initialize()).await??; + + // Send codex tool call that triggers "sleep 60" + let codex_request_id = mcp_process + .send_codex_tool_call(CodexToolCallParam { + cwd: None, + prompt: "First Run: run `sleep 60`".to_string(), + model: None, + profile: None, + approval_policy: None, + sandbox: None, + config: None, + base_instructions: None, + }) + .await?; + + let session_id = mcp_process + .read_stream_until_configured_response_message() + .await?; + + // Give the command a moment to start + tokio::time::sleep(std::time::Duration::from_secs(1)).await; + + // Send interrupt notification + mcp_process + .send_notification( + "notifications/cancelled", + Some(json!({ "requestId": codex_request_id })), + ) + .await?; + + // Expect Codex to return an error or interruption response + let codex_response: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp_process.read_stream_until_response_message(RequestId::Integer(codex_request_id)), + ) + .await??; + + assert!( + codex_response + .result + .as_object() + .map(|o| o.contains_key("error")) + .unwrap_or(false), + "Expected an interruption or error result, got: {codex_response:?}" + ); + + let codex_reply_request_id = mcp_process + .send_codex_reply_tool_call(&session_id, "Second Run: run `sleep 60`") + .await?; + + // Give the command a moment to start + tokio::time::sleep(std::time::Duration::from_secs(1)).await; + + // Send interrupt notification + mcp_process + .send_notification( + "notifications/cancelled", + Some(json!({ "requestId": codex_reply_request_id })), + ) + .await?; + + // Expect Codex to return an error or interruption response + let codex_response: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp_process.read_stream_until_response_message(RequestId::Integer(codex_reply_request_id)), + ) + .await??; + + assert!( + codex_response + .result + .as_object() + .map(|o| o.contains_key("error")) + .unwrap_or(false), + "Expected an interruption or error result, got: {codex_response:?}" + ); + Ok(()) +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +fn create_config_toml(codex_home: &Path, server_uri: String) -> std::io::Result<()> { + let config_toml = codex_home.join("config.toml"); + std::fs::write( + config_toml, + format!( + r#" +model = "mock-model" +approval_policy = "never" +sandbox_mode = "danger-full-access" + +model_provider = "mock_provider" + +[model_providers.mock_provider] +name = "Mock provider for test" +base_url = "{server_uri}/v1" +wire_api = "chat" +request_max_retries = 0 +stream_max_retries = 0 +"# + ), + ) +} From 3ef544fb95a6fffcbd5fd65bff75f91ef04b0739 Mon Sep 17 00:00:00 2001 From: Michael Bolin Date: Tue, 22 Jul 2025 14:35:50 -0700 Subject: [PATCH 09/58] chore: for release build, build specific targets instead of --all-targets (#1656) I noticed that releases have taken longer and longer to build. Originally, I think I did `--all-targets` to be confident that everything builds cleanly, but that's really the job of CI that runs on `main`, so we're spending a lot of time in `rust-release.yml` for not that much additional signal. --- .github/workflows/rust-release.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/rust-release.yml b/.github/workflows/rust-release.yml index 7b765bed17..3f1c084d91 100644 --- a/.github/workflows/rust-release.yml +++ b/.github/workflows/rust-release.yml @@ -93,7 +93,7 @@ jobs: sudo apt install -y musl-tools pkg-config - name: Cargo build - run: cargo build --target ${{ matrix.target }} --release --all-targets --all-features + run: cargo build --target ${{ matrix.target }} --release --bin codex --bin codex-exec --bin codex-linux-sandbox - name: Stage artifacts shell: bash From d6c4083f98094dbd8d86f70992729924d93f73c4 Mon Sep 17 00:00:00 2001 From: Michael Bolin Date: Tue, 22 Jul 2025 15:54:33 -0700 Subject: [PATCH 10/58] feat: support dotenv (including ~/.codex/.env) (#1653) This PR adds a `load_dotenv()` helper function to the `codex-common` crate that is available when the `cli` feature is enabled. The function uses [`dotenvy`](https://crates.io/crates/dotenvy) to update the environment from: - `$CODEX_HOME/.env` - `$(pwd)/.env` To test: - ran `printenv OPENAI_API_KEY` to verify the env var exists in my environment - ran `just codex exec hello` to verify the CLI uses my `OPENAI_API_KEY` - ran `unset OPENAI_API_KEY` - ran `just codex exec hello` again and got **ERROR: Missing environment variable: `OPENAI_API_KEY`**, as expected - created `~/.codex/.env` and added `OPENAI_API_KEY=sk-proj-...` (also ran `chmod 400 ~/.codex/.env` for good measure) - ran `just codex exec hello` again and it worked, verifying it picked up `OPENAI_API_KEY` from `~/.codex/.env` Note this functionality was available in the TypeScript CLI: https://github.com/openai/codex/pull/122 and was recently requested over on https://github.com/openai/codex/issues/1262#issuecomment-3093203551. --- codex-rs/Cargo.lock | 8 ++++++++ codex-rs/core/src/config.rs | 2 +- codex-rs/linux-sandbox/Cargo.toml | 2 ++ codex-rs/linux-sandbox/src/lib.rs | 12 ++++++++++++ 4 files changed, 23 insertions(+), 1 deletion(-) diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 9b4a4e32d4..3e4b84a435 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -756,7 +756,9 @@ version = "0.0.0" dependencies = [ "anyhow", "clap", + "codex-common", "codex-core", + "dotenvy", "landlock", "libc", "seccompiler", @@ -1272,6 +1274,12 @@ version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fea41bba32d969b513997752735605054bc0dfa92b4c56bf1189f2e174be7a10" +[[package]] +name = "dotenvy" +version = "0.15.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" + [[package]] name = "dupe" version = "0.9.1" diff --git a/codex-rs/core/src/config.rs b/codex-rs/core/src/config.rs index 8ed06c45af..2dfd3e55fe 100644 --- a/codex-rs/core/src/config.rs +++ b/codex-rs/core/src/config.rs @@ -561,7 +561,7 @@ fn default_model() -> String { /// function will Err if the path does not exist. /// - If `CODEX_HOME` is not set, this function does not verify that the /// directory exists. -fn find_codex_home() -> std::io::Result { +pub fn find_codex_home() -> std::io::Result { // Honor the `CODEX_HOME` environment variable when it is set to allow users // (and tests) to override the default location. if let Ok(val) = std::env::var("CODEX_HOME") { diff --git a/codex-rs/linux-sandbox/Cargo.toml b/codex-rs/linux-sandbox/Cargo.toml index c8cd1078c0..5c2dea6083 100644 --- a/codex-rs/linux-sandbox/Cargo.toml +++ b/codex-rs/linux-sandbox/Cargo.toml @@ -17,7 +17,9 @@ workspace = true [dependencies] anyhow = "1" clap = { version = "4", features = ["derive"] } +codex-common = { path = "../common", features = ["cli"] } codex-core = { path = "../core" } +dotenvy = "0.15.7" tokio = { version = "1", features = ["rt-multi-thread"] } [dev-dependencies] diff --git a/codex-rs/linux-sandbox/src/lib.rs b/codex-rs/linux-sandbox/src/lib.rs index 568f015822..960678467c 100644 --- a/codex-rs/linux-sandbox/src/lib.rs +++ b/codex-rs/linux-sandbox/src/lib.rs @@ -43,6 +43,10 @@ where crate::run_main(); } + // This modifies the environment, which is not thread-safe, so do this + // before creating any threads/the Tokio runtime. + load_dotenv(); + // Regular invocation – create a Tokio runtime and execute the provided // async entry-point. let runtime = tokio::runtime::Runtime::new()?; @@ -61,3 +65,11 @@ where pub fn run_main() -> ! { panic!("codex-linux-sandbox is only supported on Linux"); } + +/// Load env vars from ~/.codex/.env and `$(pwd)/.env`. +fn load_dotenv() { + if let Ok(codex_home) = codex_core::config::find_codex_home() { + dotenvy::from_path(codex_home.join(".env")).ok(); + } + dotenvy::dotenv().ok(); +} From 591cb6149a1adfa7cb212d739f2fdd6b035072f4 Mon Sep 17 00:00:00 2001 From: pakrym-oai Date: Wed, 23 Jul 2025 10:37:45 -0700 Subject: [PATCH 11/58] Always send entire request context (#1641) Always store the entire conversation history. Request encrypted COT when not storing Responses. Send entire input context instead of sending previous_response_id --- codex-rs/core/src/chat_completions.rs | 6 +- codex-rs/core/src/client.rs | 11 +- codex-rs/core/src/client_common.rs | 5 +- codex-rs/core/src/codex.rs | 173 +++++--------------- codex-rs/core/src/conversation_history.rs | 14 +- codex-rs/core/src/models.rs | 25 ++- codex-rs/core/src/rollout.rs | 27 +-- codex-rs/core/tests/previous_response_id.rs | 165 ------------------- 8 files changed, 101 insertions(+), 325 deletions(-) delete mode 100644 codex-rs/core/tests/previous_response_id.rs diff --git a/codex-rs/core/src/chat_completions.rs b/codex-rs/core/src/chat_completions.rs index 35045c8e1b..5adf3c4d50 100644 --- a/codex-rs/core/src/chat_completions.rs +++ b/codex-rs/core/src/chat_completions.rs @@ -41,7 +41,7 @@ pub(crate) async fn stream_chat_completions( for item in &prompt.input { match item { - ResponseItem::Message { role, content } => { + ResponseItem::Message { role, content, .. } => { let mut text = String::new(); for c in content { match c { @@ -58,6 +58,7 @@ pub(crate) async fn stream_chat_completions( name, arguments, call_id, + .. } => { messages.push(json!({ "role": "assistant", @@ -259,6 +260,7 @@ async fn process_chat_sse( content: vec![ContentItem::OutputText { text: content.to_string(), }], + id: None, }; let _ = tx_event.send(Ok(ResponseEvent::OutputItemDone(item))).await; @@ -300,6 +302,7 @@ async fn process_chat_sse( "tool_calls" if fn_call_state.active => { // Build the FunctionCall response item. let item = ResponseItem::FunctionCall { + id: None, name: fn_call_state.name.clone().unwrap_or_else(|| "".to_string()), arguments: fn_call_state.arguments.clone(), call_id: fn_call_state.call_id.clone().unwrap_or_else(String::new), @@ -402,6 +405,7 @@ where }))) => { if !this.cumulative.is_empty() { let aggregated_item = crate::models::ResponseItem::Message { + id: None, role: "assistant".to_string(), content: vec![crate::models::ContentItem::OutputText { text: std::mem::take(&mut this.cumulative), diff --git a/codex-rs/core/src/client.rs b/codex-rs/core/src/client.rs index beeaa453ad..1648da6d96 100644 --- a/codex-rs/core/src/client.rs +++ b/codex-rs/core/src/client.rs @@ -117,6 +117,15 @@ impl ModelClient { let full_instructions = prompt.get_full_instructions(&self.config.model); let tools_json = create_tools_json_for_responses_api(prompt, &self.config.model)?; let reasoning = create_reasoning_param_for_request(&self.config, self.effort, self.summary); + + // Request encrypted COT if we are not storing responses, + // otherwise reasoning items will be referenced by ID + let include = if !prompt.store && reasoning.is_some() { + vec!["reasoning.encrypted_content".to_string()] + } else { + vec![] + }; + let payload = ResponsesApiRequest { model: &self.config.model, instructions: &full_instructions, @@ -125,10 +134,10 @@ impl ModelClient { tool_choice: "auto", parallel_tool_calls: false, reasoning, - previous_response_id: prompt.prev_id.clone(), store: prompt.store, // TODO: make this configurable stream: true, + include, }; trace!( diff --git a/codex-rs/core/src/client_common.rs b/codex-rs/core/src/client_common.rs index 94d09e7fd4..afd2f04556 100644 --- a/codex-rs/core/src/client_common.rs +++ b/codex-rs/core/src/client_common.rs @@ -22,8 +22,6 @@ const BASE_INSTRUCTIONS: &str = include_str!("../prompt.md"); pub struct Prompt { /// Conversation context input items. pub input: Vec, - /// Optional previous response ID (when storage is enabled). - pub prev_id: Option, /// Optional instructions from the user to amend to the built-in agent /// instructions. pub user_instructions: Option, @@ -133,11 +131,10 @@ pub(crate) struct ResponsesApiRequest<'a> { pub(crate) tool_choice: &'static str, pub(crate) parallel_tool_calls: bool, pub(crate) reasoning: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub(crate) previous_response_id: Option, /// true when using the Responses API. pub(crate) store: bool, pub(crate) stream: bool, + pub(crate) include: Vec, } use crate::config::Config; diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 6eb1715fbf..ef2cc63bbb 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -34,7 +34,6 @@ use tracing::trace; use tracing::warn; use uuid::Uuid; -use crate::WireApi; use crate::client::ModelClient; use crate::client_common::Prompt; use crate::client_common::ResponseEvent; @@ -191,6 +190,7 @@ pub(crate) struct Session { sandbox_policy: SandboxPolicy, shell_environment_policy: ShellEnvironmentPolicy, writable_roots: Mutex>, + disable_response_storage: bool, /// Manager for external MCP servers/tools. mcp_connection_manager: McpConnectionManager, @@ -219,13 +219,9 @@ impl Session { struct State { approved_commands: HashSet>, current_task: Option, - /// Call IDs that have been sent from the Responses API but have not been sent back yet. - /// You CANNOT send a Responses API follow-up message unless you have sent back the output for all pending calls or else it will 400. - pending_call_ids: HashSet, - previous_response_id: Option, pending_approvals: HashMap>, pending_input: Vec, - zdr_transcript: Option, + history: ConversationHistory, } impl Session { @@ -320,18 +316,11 @@ impl Session { debug!("Recording items for conversation: {items:?}"); self.record_state_snapshot(items).await; - if let Some(transcript) = self.state.lock().unwrap().zdr_transcript.as_mut() { - transcript.record_items(items); - } + self.state.lock().unwrap().history.record_items(items); } async fn record_state_snapshot(&self, items: &[ResponseItem]) { - let snapshot = { - let state = self.state.lock().unwrap(); - crate::rollout::SessionStateSnapshot { - previous_response_id: state.previous_response_id.clone(), - } - }; + let snapshot = { crate::rollout::SessionStateSnapshot {} }; let recorder = { let guard = self.rollout.lock().unwrap(); @@ -433,8 +422,6 @@ impl Session { pub fn abort(&self) { info!("Aborting existing session"); let mut state = self.state.lock().unwrap(); - // Don't clear pending_call_ids because we need to keep track of them to ensure we don't 400 on the next turn. - // We will generate a synthetic aborted response for each pending call id. state.pending_approvals.clear(); state.pending_input.clear(); if let Some(task) = state.current_task.take() { @@ -479,15 +466,10 @@ impl Drop for Session { } impl State { - pub fn partial_clone(&self, retain_zdr_transcript: bool) -> Self { + pub fn partial_clone(&self) -> Self { Self { approved_commands: self.approved_commands.clone(), - previous_response_id: self.previous_response_id.clone(), - zdr_transcript: if retain_zdr_transcript { - self.zdr_transcript.clone() - } else { - None - }, + history: self.history.clone(), ..Default::default() } } @@ -606,13 +588,11 @@ async fn submission_loop( } // Optionally resume an existing rollout. let mut restored_items: Option> = None; - let mut restored_prev_id: Option = None; let rollout_recorder: Option = if let Some(path) = resume_path.as_ref() { match RolloutRecorder::resume(path).await { Ok((rec, saved)) => { session_id = saved.session_id; - restored_prev_id = saved.state.previous_response_id; if !saved.items.is_empty() { restored_items = Some(saved.items); } @@ -651,22 +631,13 @@ async fn submission_loop( ); // abort any current running session and clone its state - let retain_zdr_transcript = - record_conversation_history(disable_response_storage, provider.wire_api); let state = match sess.take() { Some(sess) => { sess.abort(); - sess.state - .lock() - .unwrap() - .partial_clone(retain_zdr_transcript) + sess.state.lock().unwrap().partial_clone() } None => State { - zdr_transcript: if retain_zdr_transcript { - Some(ConversationHistory::new()) - } else { - None - }, + history: ConversationHistory::new(), ..Default::default() }, }; @@ -717,18 +688,14 @@ async fn submission_loop( state: Mutex::new(state), rollout: Mutex::new(rollout_recorder), codex_linux_sandbox_exe: config.codex_linux_sandbox_exe.clone(), + disable_response_storage, })); // Patch restored state into the newly created session. if let Some(sess_arc) = &sess { - if restored_prev_id.is_some() || restored_items.is_some() { + if restored_items.is_some() { let mut st = sess_arc.state.lock().unwrap(); - st.previous_response_id = restored_prev_id; - if let (Some(hist), Some(items)) = - (st.zdr_transcript.as_mut(), restored_items.as_ref()) - { - hist.record_items(items.iter()); - } + st.history.record_items(restored_items.unwrap().iter()); } } @@ -875,14 +842,8 @@ async fn run_task(sess: Arc, sub_id: String, input: Vec) { sess.record_conversation_items(&[initial_input_for_turn.clone().into()]) .await; - let mut input_for_next_turn: Vec = vec![initial_input_for_turn]; let last_agent_message: Option; loop { - let mut net_new_turn_input = input_for_next_turn - .drain(..) - .map(ResponseItem::from) - .collect::>(); - // Note that pending_input would be something like a message the user // submitted through the UI while the model was running. Though the UI // may support this, the model might not. @@ -899,29 +860,7 @@ async fn run_task(sess: Arc, sub_id: String, input: Vec) { // only record the new items that originated in this turn so that it // represents an append-only log without duplicates. let turn_input: Vec = - if let Some(transcript) = sess.state.lock().unwrap().zdr_transcript.as_mut() { - // If we are using Chat/ZDR, we need to send the transcript with - // every turn. By induction, `transcript` already contains: - // - The `input` that kicked off this task. - // - Each `ResponseItem` that was recorded in the previous turn. - // - Each response to a `ResponseItem` (in practice, the only - // response type we seem to have is `FunctionCallOutput`). - // - // The only thing the `transcript` does not contain is the - // `pending_input` that was injected while the model was - // running. We need to add that to the conversation history - // so that the model can see it in the next turn. - [transcript.contents(), pending_input].concat() - } else { - // In practice, net_new_turn_input should contain only: - // - User messages - // - Outputs for function calls requested by the model - net_new_turn_input.extend(pending_input); - - // Responses API path – we can just send the new items and - // record the same. - net_new_turn_input - }; + [sess.state.lock().unwrap().history.contents(), pending_input].concat(); let turn_input_messages: Vec = turn_input .iter() @@ -997,8 +936,19 @@ async fn run_task(sess: Arc, sub_id: String, input: Vec) { }, ); } - (ResponseItem::Reasoning { .. }, None) => { - // Omit from conversation history. + ( + ResponseItem::Reasoning { + id, + summary, + encrypted_content, + }, + None, + ) => { + items_to_record_in_conversation_history.push(ResponseItem::Reasoning { + id: id.clone(), + summary: summary.clone(), + encrypted_content: encrypted_content.clone(), + }); } _ => { warn!("Unexpected response item: {item:?} with response: {response:?}"); @@ -1027,8 +977,6 @@ async fn run_task(sess: Arc, sub_id: String, input: Vec) { }); break; } - - input_for_next_turn = responses; } Err(e) => { info!("Turn error: {e:#}"); @@ -1056,26 +1004,11 @@ async fn run_turn( sub_id: String, input: Vec, ) -> CodexResult> { - // Decide whether to use server-side storage (previous_response_id) or disable it - let (prev_id, store) = { - let state = sess.state.lock().unwrap(); - let store = state.zdr_transcript.is_none(); - let prev_id = if store { - state.previous_response_id.clone() - } else { - // When using ZDR, the Responses API may send previous_response_id - // back, but trying to use it results in a 400. - None - }; - (prev_id, store) - }; - let extra_tools = sess.mcp_connection_manager.list_all_tools(); let prompt = Prompt { input, - prev_id, user_instructions: sess.user_instructions.clone(), - store, + store: !sess.disable_response_storage, extra_tools, base_instructions_override: sess.base_instructions.clone(), }; @@ -1149,11 +1082,17 @@ async fn try_run_turn( // This usually happens because the user interrupted the model before we responded to one of its tool calls // and then the user sent a follow-up message. let missing_calls = { - sess.state - .lock() - .unwrap() - .pending_call_ids + prompt + .input .iter() + .filter_map(|ri| match ri { + ResponseItem::FunctionCall { call_id, .. } => Some(call_id), + ResponseItem::LocalShellCall { + call_id: Some(call_id), + .. + } => Some(call_id), + _ => None, + }) .filter_map(|call_id| { if completed_call_ids.contains(&call_id) { None @@ -1207,31 +1146,14 @@ async fn try_run_turn( }; match event { - ResponseEvent::Created => { - let mut state = sess.state.lock().unwrap(); - // We successfully created a new response and ensured that all pending calls were included so we can clear the pending call ids. - state.pending_call_ids.clear(); - } + ResponseEvent::Created => {} ResponseEvent::OutputItemDone(item) => { - let call_id = match &item { - ResponseItem::LocalShellCall { - call_id: Some(call_id), - .. - } => Some(call_id), - ResponseItem::FunctionCall { call_id, .. } => Some(call_id), - _ => None, - }; - if let Some(call_id) = call_id { - // We just got a new call id so we need to make sure to respond to it in the next turn. - let mut state = sess.state.lock().unwrap(); - state.pending_call_ids.insert(call_id.clone()); - } let response = handle_response_item(sess, sub_id, item.clone()).await?; output.push(ProcessedResponseItem { item, response }); } ResponseEvent::Completed { - response_id, + response_id: _, token_usage, } => { if let Some(token_usage) = token_usage { @@ -1244,8 +1166,6 @@ async fn try_run_turn( .ok(); } - let mut state = sess.state.lock().unwrap(); - state.previous_response_id = Some(response_id); return Ok(output); } ResponseEvent::OutputTextDelta(delta) => { @@ -1285,7 +1205,7 @@ async fn handle_response_item( } None } - ResponseItem::Reasoning { id: _, summary } => { + ResponseItem::Reasoning { summary, .. } => { for item in summary { let text = match item { ReasoningItemReasoningSummary::SummaryText { text } => text, @@ -1302,6 +1222,7 @@ async fn handle_response_item( name, arguments, call_id, + .. } => { info!("FunctionCall: {arguments}"); Some(handle_function_call(sess, sub_id.to_string(), name, arguments, call_id).await) @@ -2092,7 +2013,7 @@ fn format_exec_output(output: &str, exit_code: i32, duration: Duration) -> Strin fn get_last_assistant_message_from_turn(responses: &[ResponseItem]) -> Option { responses.iter().rev().find_map(|item| { - if let ResponseItem::Message { role, content } = item { + if let ResponseItem::Message { role, content, .. } = item { if role == "assistant" { content.iter().rev().find_map(|ci| { if let ContentItem::OutputText { text } = ci { @@ -2109,15 +2030,3 @@ fn get_last_assistant_message_from_turn(responses: &[ResponseItem]) -> Option bool { - if disable_response_storage { - return true; - } - - match wire_api { - WireApi::Responses => false, - WireApi::Chat => true, - } -} diff --git a/codex-rs/core/src/conversation_history.rs b/codex-rs/core/src/conversation_history.rs index 52fb1ec4f4..4cd989cbd9 100644 --- a/codex-rs/core/src/conversation_history.rs +++ b/codex-rs/core/src/conversation_history.rs @@ -1,12 +1,7 @@ use crate::models::ResponseItem; -/// Transcript of conversation history that is needed: -/// - for ZDR clients for which previous_response_id is not available, so we -/// must include the transcript with every API call. This must include each -/// `function_call` and its corresponding `function_call_output`. -/// - for clients using the "chat completions" API as opposed to the -/// "responses" API. -#[derive(Debug, Clone)] +/// Transcript of conversation history +#[derive(Debug, Clone, Default)] pub(crate) struct ConversationHistory { /// The oldest items are at the beginning of the vector. items: Vec, @@ -44,7 +39,8 @@ fn is_api_message(message: &ResponseItem) -> bool { ResponseItem::Message { role, .. } => role.as_str() != "system", ResponseItem::FunctionCallOutput { .. } | ResponseItem::FunctionCall { .. } - | ResponseItem::LocalShellCall { .. } => true, - ResponseItem::Reasoning { .. } | ResponseItem::Other => false, + | ResponseItem::LocalShellCall { .. } + | ResponseItem::Reasoning { .. } => true, + ResponseItem::Other => false, } } diff --git a/codex-rs/core/src/models.rs b/codex-rs/core/src/models.rs index 6b392fb19d..166404915a 100644 --- a/codex-rs/core/src/models.rs +++ b/codex-rs/core/src/models.rs @@ -3,6 +3,7 @@ use std::collections::HashMap; use base64::Engine; use mcp_types::CallToolResult; use serde::Deserialize; +use serde::Deserializer; use serde::Serialize; use serde::ser::Serializer; @@ -37,12 +38,14 @@ pub enum ContentItem { #[serde(tag = "type", rename_all = "snake_case")] pub enum ResponseItem { Message { + id: Option, role: String, content: Vec, }, Reasoning { id: String, summary: Vec, + encrypted_content: Option, }, LocalShellCall { /// Set when using the chat completions API. @@ -53,6 +56,7 @@ pub enum ResponseItem { action: LocalShellAction, }, FunctionCall { + id: Option, name: String, // The Responses API returns the function call arguments as a *string* that contains // JSON, not as an already‑parsed object. We keep it as a raw string here and let @@ -78,7 +82,11 @@ pub enum ResponseItem { impl From for ResponseItem { fn from(item: ResponseInputItem) -> Self { match item { - ResponseInputItem::Message { role, content } => Self::Message { role, content }, + ResponseInputItem::Message { role, content } => Self::Message { + role, + content, + id: None, + }, ResponseInputItem::FunctionCallOutput { call_id, output } => { Self::FunctionCallOutput { call_id, output } } @@ -177,7 +185,7 @@ pub struct ShellToolCallParams { pub timeout_ms: Option, } -#[derive(Deserialize, Debug, Clone)] +#[derive(Debug, Clone)] pub struct FunctionCallOutputPayload { pub content: String, #[expect(dead_code)] @@ -205,6 +213,19 @@ impl Serialize for FunctionCallOutputPayload { } } +impl<'de> Deserialize<'de> for FunctionCallOutputPayload { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let s = String::deserialize(deserializer)?; + Ok(FunctionCallOutputPayload { + content: s, + success: None, + }) + } +} + // Implement Display so callers can treat the payload like a plain string when logging or doing // trivial substring checks in tests (existing tests call `.contains()` on the output). Display // returns the raw `content` field. diff --git a/codex-rs/core/src/rollout.rs b/codex-rs/core/src/rollout.rs index bb2abe45cd..0b19d13397 100644 --- a/codex-rs/core/src/rollout.rs +++ b/codex-rs/core/src/rollout.rs @@ -15,6 +15,7 @@ use tokio::io::AsyncWriteExt; use tokio::sync::mpsc::Sender; use tokio::sync::mpsc::{self}; use tracing::info; +use tracing::warn; use uuid::Uuid; use crate::config::Config; @@ -30,9 +31,7 @@ pub struct SessionMeta { } #[derive(Serialize, Deserialize, Default, Clone)] -pub struct SessionStateSnapshot { - pub previous_response_id: Option, -} +pub struct SessionStateSnapshot {} #[derive(Serialize, Deserialize, Default, Clone)] pub struct SavedSession { @@ -119,8 +118,9 @@ impl RolloutRecorder { ResponseItem::Message { .. } | ResponseItem::LocalShellCall { .. } | ResponseItem::FunctionCall { .. } - | ResponseItem::FunctionCallOutput { .. } => filtered.push(item.clone()), - ResponseItem::Reasoning { .. } | ResponseItem::Other => { + | ResponseItem::FunctionCallOutput { .. } + | ResponseItem::Reasoning { .. } => filtered.push(item.clone()), + ResponseItem::Other => { // These should never be serialized. continue; } @@ -172,13 +172,17 @@ impl RolloutRecorder { } continue; } - if let Ok(item) = serde_json::from_value::(v.clone()) { - match item { + match serde_json::from_value::(v.clone()) { + Ok(item) => match item { ResponseItem::Message { .. } | ResponseItem::LocalShellCall { .. } | ResponseItem::FunctionCall { .. } - | ResponseItem::FunctionCallOutput { .. } => items.push(item), - ResponseItem::Reasoning { .. } | ResponseItem::Other => {} + | ResponseItem::FunctionCallOutput { .. } + | ResponseItem::Reasoning { .. } => items.push(item), + ResponseItem::Other => {} + }, + Err(e) => { + warn!("failed to parse item: {v:?}, error: {e}"); } } } @@ -267,13 +271,14 @@ async fn rollout_writer( ResponseItem::Message { .. } | ResponseItem::LocalShellCall { .. } | ResponseItem::FunctionCall { .. } - | ResponseItem::FunctionCallOutput { .. } => { + | ResponseItem::FunctionCallOutput { .. } + | ResponseItem::Reasoning { .. } => { if let Ok(json) = serde_json::to_string(&item) { let _ = file.write_all(json.as_bytes()).await; let _ = file.write_all(b"\n").await; } } - ResponseItem::Reasoning { .. } | ResponseItem::Other => {} + ResponseItem::Other => {} } } let _ = file.flush().await; diff --git a/codex-rs/core/tests/previous_response_id.rs b/codex-rs/core/tests/previous_response_id.rs deleted file mode 100644 index 6523c76441..0000000000 --- a/codex-rs/core/tests/previous_response_id.rs +++ /dev/null @@ -1,165 +0,0 @@ -use std::time::Duration; - -use codex_core::Codex; -use codex_core::ModelProviderInfo; -use codex_core::exec::CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR; -use codex_core::protocol::ErrorEvent; -use codex_core::protocol::EventMsg; -use codex_core::protocol::InputItem; -use codex_core::protocol::Op; -mod test_support; -use serde_json::Value; -use tempfile::TempDir; -use test_support::load_default_config_for_test; -use test_support::load_sse_fixture_with_id; -use tokio::time::timeout; -use wiremock::Match; -use wiremock::Mock; -use wiremock::MockServer; -use wiremock::Request; -use wiremock::ResponseTemplate; -use wiremock::matchers::method; -use wiremock::matchers::path; - -/// Matcher asserting that JSON body has NO `previous_response_id` field. -struct NoPrevId; - -impl Match for NoPrevId { - fn matches(&self, req: &Request) -> bool { - serde_json::from_slice::(&req.body) - .map(|v| v.get("previous_response_id").is_none()) - .unwrap_or(false) - } -} - -/// Matcher asserting that JSON body HAS a `previous_response_id` field. -struct HasPrevId; - -impl Match for HasPrevId { - fn matches(&self, req: &Request) -> bool { - serde_json::from_slice::(&req.body) - .map(|v| v.get("previous_response_id").is_some()) - .unwrap_or(false) - } -} - -/// Build minimal SSE stream with completed marker using the JSON fixture. -fn sse_completed(id: &str) -> String { - load_sse_fixture_with_id("tests/fixtures/completed_template.json", id) -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn keeps_previous_response_id_between_tasks() { - #![allow(clippy::unwrap_used)] - - if std::env::var(CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR).is_ok() { - println!( - "Skipping test because it cannot execute when network is disabled in a Codex sandbox." - ); - return; - } - - // Mock server - let server = MockServer::start().await; - - // First request – must NOT include `previous_response_id`. - let first = ResponseTemplate::new(200) - .insert_header("content-type", "text/event-stream") - .set_body_raw(sse_completed("resp1"), "text/event-stream"); - - Mock::given(method("POST")) - .and(path("/v1/responses")) - .and(NoPrevId) - .respond_with(first) - .expect(1) - .mount(&server) - .await; - - // Second request – MUST include `previous_response_id`. - let second = ResponseTemplate::new(200) - .insert_header("content-type", "text/event-stream") - .set_body_raw(sse_completed("resp2"), "text/event-stream"); - - Mock::given(method("POST")) - .and(path("/v1/responses")) - .and(HasPrevId) - .respond_with(second) - .expect(1) - .mount(&server) - .await; - - // Configure retry behavior explicitly to avoid mutating process-wide - // environment variables. - let model_provider = ModelProviderInfo { - name: "openai".into(), - base_url: format!("{}/v1", server.uri()), - // Environment variable that should exist in the test environment. - // ModelClient will return an error if the environment variable for the - // provider is not set. - env_key: Some("PATH".into()), - env_key_instructions: None, - wire_api: codex_core::WireApi::Responses, - query_params: None, - http_headers: None, - env_http_headers: None, - // disable retries so we don't get duplicate calls in this test - request_max_retries: Some(0), - stream_max_retries: Some(0), - stream_idle_timeout_ms: None, - }; - - // Init session - let codex_home = TempDir::new().unwrap(); - let mut config = load_default_config_for_test(&codex_home); - config.model_provider = model_provider; - let ctrl_c = std::sync::Arc::new(tokio::sync::Notify::new()); - let (codex, _init_id, _session_id) = Codex::spawn(config, ctrl_c.clone()).await.unwrap(); - - // Task 1 – triggers first request (no previous_response_id) - codex - .submit(Op::UserInput { - items: vec![InputItem::Text { - text: "hello".into(), - }], - }) - .await - .unwrap(); - - // Wait for TaskComplete - loop { - let ev = timeout(Duration::from_secs(1), codex.next_event()) - .await - .unwrap() - .unwrap(); - if matches!(ev.msg, EventMsg::TaskComplete(_)) { - break; - } - } - - // Task 2 – should include `previous_response_id` (triggers second request) - codex - .submit(Op::UserInput { - items: vec![InputItem::Text { - text: "again".into(), - }], - }) - .await - .unwrap(); - - // Wait for TaskComplete or error - loop { - let ev = timeout(Duration::from_secs(1), codex.next_event()) - .await - .unwrap() - .unwrap(); - match ev.msg { - EventMsg::TaskComplete(_) => break, - EventMsg::Error(ErrorEvent { message }) => { - panic!("unexpected error: {message}") - } - _ => { - // Ignore other events. - } - } - } -} From bc944e77f53c4ad7ae4d9df93de967d4c30d704e Mon Sep 17 00:00:00 2001 From: Gabriel Peal Date: Wed, 23 Jul 2025 11:43:53 -0700 Subject: [PATCH 12/58] Improve messages emitted for exec failures (#1659) 1. Emit call_id to exec approval elicitations for mcp client convenience 2. Remove the `-retry` from the call id for the same reason as above but upstream the reset behavior to the mcp client --- codex-rs/core/src/codex.rs | 18 ++++++-------- codex-rs/core/src/protocol.rs | 2 ++ codex-rs/mcp-server/src/codex_tool_runner.rs | 1 + codex-rs/tui/src/chatwidget.rs | 3 ++- .../tui/src/conversation_history_widget.rs | 24 +++++++++++++++++++ 5 files changed, 36 insertions(+), 12 deletions(-) diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index ef2cc63bbb..9dd602e237 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -253,6 +253,7 @@ impl Session { pub async fn request_command_approval( &self, sub_id: String, + call_id: String, command: Vec, cwd: PathBuf, reason: Option, @@ -261,6 +262,7 @@ impl Session { let event = Event { id: sub_id.clone(), msg: EventMsg::ExecApprovalRequest(ExecApprovalRequestEvent { + call_id, command, cwd, reason, @@ -1393,6 +1395,7 @@ async fn handle_container_exec_with_params( let rx_approve = sess .request_command_approval( sub_id.clone(), + call_id.clone(), params.command.clone(), params.cwd.clone(), None, @@ -1520,6 +1523,7 @@ async fn handle_sandbox_error( let rx_approve = sess .request_command_approval( sub_id.clone(), + call_id.clone(), params.command.clone(), params.cwd.clone(), Some("command failed; retry without sandbox?".to_string()), @@ -1537,9 +1541,7 @@ async fn handle_sandbox_error( sess.notify_background_event(&sub_id, "retrying command without sandbox") .await; - // Emit a fresh Begin event so progress bars reset. - let retry_call_id = format!("{call_id}-retry"); - sess.notify_exec_command_begin(&sub_id, &retry_call_id, ¶ms) + sess.notify_exec_command_begin(&sub_id, &call_id, ¶ms) .await; // This is an escalated retry; the policy will not be @@ -1562,14 +1564,8 @@ async fn handle_sandbox_error( duration, } = retry_output; - sess.notify_exec_command_end( - &sub_id, - &retry_call_id, - &stdout, - &stderr, - exit_code, - ) - .await; + sess.notify_exec_command_end(&sub_id, &call_id, &stdout, &stderr, exit_code) + .await; let is_success = exit_code == 0; let content = format_exec_output( diff --git a/codex-rs/core/src/protocol.rs b/codex-rs/core/src/protocol.rs index 9f6e004b67..943c0b928f 100644 --- a/codex-rs/core/src/protocol.rs +++ b/codex-rs/core/src/protocol.rs @@ -422,6 +422,8 @@ pub struct ExecCommandEndEvent { #[derive(Debug, Clone, Deserialize, Serialize)] pub struct ExecApprovalRequestEvent { + /// Identifier for the associated exec call, if available. + pub call_id: String, /// The command to be executed. pub command: Vec, /// The command's working directory. diff --git a/codex-rs/mcp-server/src/codex_tool_runner.rs b/codex-rs/mcp-server/src/codex_tool_runner.rs index 9aaab54395..2b7b08f94c 100644 --- a/codex-rs/mcp-server/src/codex_tool_runner.rs +++ b/codex-rs/mcp-server/src/codex_tool_runner.rs @@ -156,6 +156,7 @@ async fn run_codex_tool_session_inner( EventMsg::ExecApprovalRequest(ExecApprovalRequestEvent { command, cwd, + call_id: _, reason: _, }) => { handle_exec_approval_request( diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index c70c6f6d72..51f4df5273 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -314,6 +314,7 @@ impl ChatWidget<'_> { self.bottom_pane.set_task_running(false); } EventMsg::ExecApprovalRequest(ExecApprovalRequestEvent { + call_id: _, command, cwd, reason, @@ -362,7 +363,7 @@ impl ChatWidget<'_> { cwd: _, }) => { self.conversation_history - .add_active_exec_command(call_id, command); + .reset_or_add_active_exec_command(call_id, command); self.request_redraw(); } EventMsg::PatchApplyBegin(PatchApplyBeginEvent { diff --git a/codex-rs/tui/src/conversation_history_widget.rs b/codex-rs/tui/src/conversation_history_widget.rs index 01a8dc6834..ceaf115f33 100644 --- a/codex-rs/tui/src/conversation_history_widget.rs +++ b/codex-rs/tui/src/conversation_history_widget.rs @@ -235,6 +235,30 @@ impl ConversationHistoryWidget { self.add_to_history(HistoryCell::new_active_exec_command(call_id, command)); } + /// If an ActiveExecCommand with the same call_id already exists, replace + /// it with a fresh one (resetting start time and view). Otherwise, add a new entry. + pub fn reset_or_add_active_exec_command(&mut self, call_id: String, command: Vec) { + // Find the most recent matching ActiveExecCommand. + let maybe_idx = self.entries.iter().rposition(|entry| { + if let HistoryCell::ActiveExecCommand { call_id: id, .. } = &entry.cell { + id == &call_id + } else { + false + } + }); + + if let Some(idx) = maybe_idx { + let width = self.cached_width.get(); + self.entries[idx].cell = HistoryCell::new_active_exec_command(call_id.clone(), command); + if width > 0 { + let height = self.entries[idx].cell.height(width); + self.entries[idx].line_count.set(height); + } + } else { + self.add_active_exec_command(call_id, command); + } + } + pub fn add_active_mcp_tool_call( &mut self, call_id: String, From 084236f7175aa8a8024661845acb3e11e96f7c0a Mon Sep 17 00:00:00 2001 From: Gabriel Peal Date: Wed, 23 Jul 2025 12:55:35 -0700 Subject: [PATCH 13/58] Add call_id to patch approvals and elicitations (#1660) Builds on https://github.com/openai/codex/pull/1659 and adds call_id to a few more places for the same reason. --- codex-rs/core/src/codex.rs | 13 +++++++++++-- codex-rs/core/src/protocol.rs | 2 ++ codex-rs/mcp-server/src/codex_tool_runner.rs | 5 ++++- codex-rs/mcp-server/src/exec_approval.rs | 4 ++++ codex-rs/mcp-server/src/patch_approval.rs | 3 +++ codex-rs/mcp-server/tests/codex_tool.rs | 2 ++ codex-rs/tui/src/chatwidget.rs | 1 + 7 files changed, 27 insertions(+), 3 deletions(-) diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 9dd602e237..73bb714971 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -279,6 +279,7 @@ impl Session { pub async fn request_patch_approval( &self, sub_id: String, + call_id: String, action: &ApplyPatchAction, reason: Option, grant_root: Option, @@ -287,6 +288,7 @@ impl Session { let event = Event { id: sub_id.clone(), msg: EventMsg::ApplyPatchApprovalRequest(ApplyPatchApprovalRequestEvent { + call_id, changes: convert_apply_patch_to_protocol(action), reason, grant_root, @@ -1629,7 +1631,7 @@ async fn apply_patch( // Compute a readable summary of path changes to include in the // approval request so the user can make an informed decision. let rx_approve = sess - .request_patch_approval(sub_id.clone(), &action, None, None) + .request_patch_approval(sub_id.clone(), call_id.clone(), &action, None, None) .await; match rx_approve.await.unwrap_or_default() { ReviewDecision::Approved | ReviewDecision::ApprovedForSession => false, @@ -1667,7 +1669,13 @@ async fn apply_patch( )); let rx = sess - .request_patch_approval(sub_id.clone(), &action, reason.clone(), Some(root.clone())) + .request_patch_approval( + sub_id.clone(), + call_id.clone(), + &action, + reason.clone(), + Some(root.clone()), + ) .await; if !matches!( @@ -1751,6 +1759,7 @@ async fn apply_patch( let rx = sess .request_patch_approval( sub_id.clone(), + call_id.clone(), &action, reason.clone(), Some(root.clone()), diff --git a/codex-rs/core/src/protocol.rs b/codex-rs/core/src/protocol.rs index 943c0b928f..cc201bc7ea 100644 --- a/codex-rs/core/src/protocol.rs +++ b/codex-rs/core/src/protocol.rs @@ -435,6 +435,8 @@ pub struct ExecApprovalRequestEvent { #[derive(Debug, Clone, Deserialize, Serialize)] pub struct ApplyPatchApprovalRequestEvent { + /// Responses API call id for the associated patch apply call, if available. + pub call_id: String, pub changes: HashMap, /// Optional explanatory reason (e.g. request for extra write access). #[serde(skip_serializing_if = "Option::is_none")] diff --git a/codex-rs/mcp-server/src/codex_tool_runner.rs b/codex-rs/mcp-server/src/codex_tool_runner.rs index 2b7b08f94c..4e10d158cf 100644 --- a/codex-rs/mcp-server/src/codex_tool_runner.rs +++ b/codex-rs/mcp-server/src/codex_tool_runner.rs @@ -156,7 +156,7 @@ async fn run_codex_tool_session_inner( EventMsg::ExecApprovalRequest(ExecApprovalRequestEvent { command, cwd, - call_id: _, + call_id, reason: _, }) => { handle_exec_approval_request( @@ -167,6 +167,7 @@ async fn run_codex_tool_session_inner( request_id.clone(), request_id_str.clone(), event.id.clone(), + call_id, ) .await; continue; @@ -180,11 +181,13 @@ async fn run_codex_tool_session_inner( break; } EventMsg::ApplyPatchApprovalRequest(ApplyPatchApprovalRequestEvent { + call_id, reason, grant_root, changes, }) => { handle_patch_approval_request( + call_id, reason, grant_root, changes, diff --git a/codex-rs/mcp-server/src/exec_approval.rs b/codex-rs/mcp-server/src/exec_approval.rs index fc0c41d0d1..f073214bf5 100644 --- a/codex-rs/mcp-server/src/exec_approval.rs +++ b/codex-rs/mcp-server/src/exec_approval.rs @@ -32,6 +32,7 @@ pub struct ExecApprovalElicitRequestParams { pub codex_elicitation: String, pub codex_mcp_tool_call_id: String, pub codex_event_id: String, + pub codex_call_id: String, pub codex_command: Vec, pub codex_cwd: PathBuf, } @@ -45,6 +46,7 @@ pub struct ExecApprovalResponse { pub decision: ReviewDecision, } +#[allow(clippy::too_many_arguments)] pub(crate) async fn handle_exec_approval_request( command: Vec, cwd: PathBuf, @@ -53,6 +55,7 @@ pub(crate) async fn handle_exec_approval_request( request_id: RequestId, tool_call_id: String, event_id: String, + call_id: String, ) { let escaped_command = shlex::try_join(command.iter().map(|s| s.as_str())).unwrap_or_else(|_| command.join(" ")); @@ -71,6 +74,7 @@ pub(crate) async fn handle_exec_approval_request( codex_elicitation: "exec-approval".to_string(), codex_mcp_tool_call_id: tool_call_id.clone(), codex_event_id: event_id.clone(), + codex_call_id: call_id, codex_command: command, codex_cwd: cwd, }; diff --git a/codex-rs/mcp-server/src/patch_approval.rs b/codex-rs/mcp-server/src/patch_approval.rs index bfccfa50ee..db99ee5f27 100644 --- a/codex-rs/mcp-server/src/patch_approval.rs +++ b/codex-rs/mcp-server/src/patch_approval.rs @@ -27,6 +27,7 @@ pub struct PatchApprovalElicitRequestParams { pub codex_elicitation: String, pub codex_mcp_tool_call_id: String, pub codex_event_id: String, + pub codex_call_id: String, #[serde(skip_serializing_if = "Option::is_none")] pub codex_reason: Option, #[serde(skip_serializing_if = "Option::is_none")] @@ -41,6 +42,7 @@ pub struct PatchApprovalResponse { #[allow(clippy::too_many_arguments)] pub(crate) async fn handle_patch_approval_request( + call_id: String, reason: Option, grant_root: Option, changes: HashMap, @@ -66,6 +68,7 @@ pub(crate) async fn handle_patch_approval_request( codex_elicitation: "patch-approval".to_string(), codex_mcp_tool_call_id: tool_call_id.clone(), codex_event_id: event_id.clone(), + codex_call_id: call_id, codex_reason: reason, codex_grant_root: grant_root, codex_changes: changes, diff --git a/codex-rs/mcp-server/tests/codex_tool.rs b/codex-rs/mcp-server/tests/codex_tool.rs index d36813ce9f..0e31eea46e 100644 --- a/codex-rs/mcp-server/tests/codex_tool.rs +++ b/codex-rs/mcp-server/tests/codex_tool.rs @@ -171,6 +171,7 @@ fn create_expected_elicitation_request( codex_event_id, codex_command: command, codex_cwd: workdir.to_path_buf(), + codex_call_id: "call1234".to_string(), })?), }) } @@ -384,6 +385,7 @@ fn create_expected_patch_approval_elicitation_request( codex_reason: reason, codex_grant_root: grant_root, codex_changes: changes, + codex_call_id: "call1234".to_string(), })?), }) } diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 51f4df5273..3856587010 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -328,6 +328,7 @@ impl ChatWidget<'_> { self.bottom_pane.push_approval_request(request); } EventMsg::ApplyPatchApprovalRequest(ApplyPatchApprovalRequestEvent { + call_id: _, changes, reason, grant_root, From b4ab7c1b7325fef16fb2c15f78d9b41dd1d3485b Mon Sep 17 00:00:00 2001 From: aibrahim-oai Date: Wed, 23 Jul 2025 15:03:26 -0700 Subject: [PATCH 14/58] Flaky CI fix (#1647) Flushing before sending `TaskCompleteEvent` and ending the submission loop to avoid race conditions. --- codex-rs/core/src/codex.rs | 31 +++++++++++++ codex-rs/core/src/protocol.rs | 6 +++ codex-rs/core/src/rollout.rs | 21 ++++++++- codex-rs/exec/src/event_processor.rs | 35 ++++++++++++++- .../src/event_processor_with_human_output.rs | 28 ++++++++++-- .../src/event_processor_with_json_output.rs | 24 ++++++++-- codex-rs/exec/src/lib.rs | 44 +++++-------------- codex-rs/mcp-server/src/codex_tool_runner.rs | 3 +- codex-rs/tui/src/app.rs | 4 +- codex-rs/tui/src/chatwidget.rs | 4 ++ 10 files changed, 153 insertions(+), 47 deletions(-) diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 73bb714971..4cc888b62e 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -812,6 +812,37 @@ async fn submission_loop( } }); } + Op::Shutdown => { + info!("Shutting down Codex instance"); + + // Gracefully flush and shutdown rollout recorder on session end so tests + // that inspect the rollout file do not race with the background writer. + if let Some(sess_arc) = sess { + let recorder_opt = sess_arc.rollout.lock().unwrap().take(); + if let Some(rec) = recorder_opt { + if let Err(e) = rec.shutdown().await { + warn!("failed to shutdown rollout recorder: {e}"); + let event = Event { + id: sub.id.clone(), + msg: EventMsg::Error(ErrorEvent { + message: "Failed to shutdown rollout recorder".to_string(), + }), + }; + if let Err(e) = tx_event.send(event).await { + warn!("failed to send error message: {e:?}"); + } + } + } + } + let event = Event { + id: sub.id.clone(), + msg: EventMsg::ShutdownComplete, + }; + if let Err(e) = tx_event.send(event).await { + warn!("failed to send Shutdown event: {e}"); + } + break; + } } } debug!("Agent loop exited"); diff --git a/codex-rs/core/src/protocol.rs b/codex-rs/core/src/protocol.rs index cc201bc7ea..0c375e455d 100644 --- a/codex-rs/core/src/protocol.rs +++ b/codex-rs/core/src/protocol.rs @@ -116,6 +116,9 @@ pub enum Op { /// Request a single history entry identified by `log_id` + `offset`. GetHistoryEntryRequest { offset: usize, log_id: u64 }, + + /// Request to shut down codex instance. + Shutdown, } /// Determines the conditions under which the user is consulted to approve @@ -326,6 +329,9 @@ pub enum EventMsg { /// Response to GetHistoryEntryRequest. GetHistoryEntryResponse(GetHistoryEntryResponseEvent), + + /// Notification that the agent is shutting down. + ShutdownComplete, } // Individual event payload types matching each `EventMsg` variant. diff --git a/codex-rs/core/src/rollout.rs b/codex-rs/core/src/rollout.rs index 0b19d13397..7f0f61b9eb 100644 --- a/codex-rs/core/src/rollout.rs +++ b/codex-rs/core/src/rollout.rs @@ -14,6 +14,7 @@ use time::macros::format_description; use tokio::io::AsyncWriteExt; use tokio::sync::mpsc::Sender; use tokio::sync::mpsc::{self}; +use tokio::sync::oneshot; use tracing::info; use tracing::warn; use uuid::Uuid; @@ -57,10 +58,10 @@ pub(crate) struct RolloutRecorder { tx: Sender, } -#[derive(Clone)] enum RolloutCmd { AddItems(Vec), UpdateState(SessionStateSnapshot), + Shutdown { ack: oneshot::Sender<()> }, } impl RolloutRecorder { @@ -204,6 +205,21 @@ impl RolloutRecorder { info!("Resumed rollout successfully from {path:?}"); Ok((Self { tx }, saved)) } + + pub async fn shutdown(&self) -> std::io::Result<()> { + let (tx_done, rx_done) = oneshot::channel(); + match self.tx.send(RolloutCmd::Shutdown { ack: tx_done }).await { + Ok(_) => rx_done + .await + .map_err(|e| IoError::other(format!("failed waiting for rollout shutdown: {e}"))), + Err(e) => { + warn!("failed to send rollout shutdown command: {e}"); + Err(IoError::other(format!( + "failed to send rollout shutdown command: {e}" + ))) + } + } + } } struct LogFileInfo { @@ -299,6 +315,9 @@ async fn rollout_writer( let _ = file.flush().await; } } + RolloutCmd::Shutdown { ack } => { + let _ = ack.send(()); + } } } } diff --git a/codex-rs/exec/src/event_processor.rs b/codex-rs/exec/src/event_processor.rs index 56db651a83..a7edb96af2 100644 --- a/codex-rs/exec/src/event_processor.rs +++ b/codex-rs/exec/src/event_processor.rs @@ -1,15 +1,23 @@ +use std::path::Path; + use codex_common::summarize_sandbox_policy; use codex_core::WireApi; use codex_core::config::Config; use codex_core::model_supports_reasoning_summaries; use codex_core::protocol::Event; +pub(crate) enum CodexStatus { + Running, + InitiateShutdown, + Shutdown, +} + pub(crate) trait EventProcessor { /// Print summary of effective configuration and user prompt. fn print_config_summary(&mut self, config: &Config, prompt: &str); /// Handle a single event emitted by the agent. - fn process_event(&mut self, event: Event); + fn process_event(&mut self, event: Event) -> CodexStatus; } pub(crate) fn create_config_summary_entries(config: &Config) -> Vec<(&'static str, String)> { @@ -35,3 +43,28 @@ pub(crate) fn create_config_summary_entries(config: &Config) -> Vec<(&'static st entries } + +pub(crate) fn handle_last_message( + last_agent_message: Option<&str>, + last_message_path: Option<&Path>, +) { + match (last_message_path, last_agent_message) { + (Some(path), Some(msg)) => write_last_message_file(msg, Some(path)), + (Some(path), None) => { + write_last_message_file("", Some(path)); + eprintln!( + "Warning: no last agent message; wrote empty content to {}", + path.display() + ); + } + (None, _) => eprintln!("Warning: no file to write last message to."), + } +} + +fn write_last_message_file(contents: &str, last_message_path: Option<&Path>) { + if let Some(path) = last_message_path { + if let Err(e) = std::fs::write(path, contents) { + eprintln!("Failed to write last message file {path:?}: {e}"); + } + } +} 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 7b39071116..bc647c683e 100644 --- a/codex-rs/exec/src/event_processor_with_human_output.rs +++ b/codex-rs/exec/src/event_processor_with_human_output.rs @@ -15,16 +15,20 @@ use codex_core::protocol::McpToolCallEndEvent; use codex_core::protocol::PatchApplyBeginEvent; use codex_core::protocol::PatchApplyEndEvent; use codex_core::protocol::SessionConfiguredEvent; +use codex_core::protocol::TaskCompleteEvent; use codex_core::protocol::TokenUsage; use owo_colors::OwoColorize; use owo_colors::Style; use shlex::try_join; use std::collections::HashMap; use std::io::Write; +use std::path::PathBuf; use std::time::Instant; +use crate::event_processor::CodexStatus; use crate::event_processor::EventProcessor; use crate::event_processor::create_config_summary_entries; +use crate::event_processor::handle_last_message; /// This should be configurable. When used in CI, users may not want to impose /// a limit so they can see the full transcript. @@ -54,10 +58,15 @@ pub(crate) struct EventProcessorWithHumanOutput { show_agent_reasoning: bool, answer_started: bool, reasoning_started: bool, + last_message_path: Option, } impl EventProcessorWithHumanOutput { - pub(crate) fn create_with_ansi(with_ansi: bool, config: &Config) -> Self { + pub(crate) fn create_with_ansi( + with_ansi: bool, + config: &Config, + last_message_path: Option, + ) -> Self { let call_id_to_command = HashMap::new(); let call_id_to_patch = HashMap::new(); let call_id_to_tool_call = HashMap::new(); @@ -77,6 +86,7 @@ impl EventProcessorWithHumanOutput { show_agent_reasoning: !config.hide_agent_reasoning, answer_started: false, reasoning_started: false, + last_message_path, } } else { Self { @@ -93,6 +103,7 @@ impl EventProcessorWithHumanOutput { show_agent_reasoning: !config.hide_agent_reasoning, answer_started: false, reasoning_started: false, + last_message_path, } } } @@ -158,7 +169,7 @@ impl EventProcessor for EventProcessorWithHumanOutput { ); } - fn process_event(&mut self, event: Event) { + fn process_event(&mut self, event: Event) -> CodexStatus { let Event { id: _, msg } = event; match msg { EventMsg::Error(ErrorEvent { message }) => { @@ -168,9 +179,16 @@ impl EventProcessor for EventProcessorWithHumanOutput { EventMsg::BackgroundEvent(BackgroundEventEvent { message }) => { ts_println!(self, "{}", message.style(self.dimmed)); } - EventMsg::TaskStarted | EventMsg::TaskComplete(_) => { + EventMsg::TaskStarted => { // Ignore. } + EventMsg::TaskComplete(TaskCompleteEvent { last_agent_message }) => { + handle_last_message( + last_agent_message.as_deref(), + self.last_message_path.as_deref(), + ); + return CodexStatus::InitiateShutdown; + } EventMsg::TokenCount(TokenUsage { total_tokens, .. }) => { ts_println!(self, "tokens used: {total_tokens}"); } @@ -185,7 +203,7 @@ impl EventProcessor for EventProcessorWithHumanOutput { } EventMsg::AgentReasoningDelta(AgentReasoningDeltaEvent { delta }) => { if !self.show_agent_reasoning { - return; + return CodexStatus::Running; } if !self.reasoning_started { ts_println!( @@ -498,7 +516,9 @@ impl EventProcessor for EventProcessorWithHumanOutput { EventMsg::GetHistoryEntryResponse(_) => { // Currently ignored in exec output. } + EventMsg::ShutdownComplete => return CodexStatus::Shutdown, } + CodexStatus::Running } } diff --git a/codex-rs/exec/src/event_processor_with_json_output.rs b/codex-rs/exec/src/event_processor_with_json_output.rs index 699460bbed..e7a658b76f 100644 --- a/codex-rs/exec/src/event_processor_with_json_output.rs +++ b/codex-rs/exec/src/event_processor_with_json_output.rs @@ -1,18 +1,24 @@ use std::collections::HashMap; +use std::path::PathBuf; use codex_core::config::Config; use codex_core::protocol::Event; use codex_core::protocol::EventMsg; +use codex_core::protocol::TaskCompleteEvent; use serde_json::json; +use crate::event_processor::CodexStatus; use crate::event_processor::EventProcessor; use crate::event_processor::create_config_summary_entries; +use crate::event_processor::handle_last_message; -pub(crate) struct EventProcessorWithJsonOutput; +pub(crate) struct EventProcessorWithJsonOutput { + last_message_path: Option, +} impl EventProcessorWithJsonOutput { - pub fn new() -> Self { - Self {} + pub fn new(last_message_path: Option) -> Self { + Self { last_message_path } } } @@ -33,15 +39,25 @@ impl EventProcessor for EventProcessorWithJsonOutput { println!("{prompt_json}"); } - fn process_event(&mut self, event: Event) { + fn process_event(&mut self, event: Event) -> CodexStatus { match event.msg { EventMsg::AgentMessageDelta(_) | EventMsg::AgentReasoningDelta(_) => { // Suppress streaming events in JSON mode. + CodexStatus::Running } + EventMsg::TaskComplete(TaskCompleteEvent { last_agent_message }) => { + handle_last_message( + last_agent_message.as_deref(), + self.last_message_path.as_deref(), + ); + CodexStatus::InitiateShutdown + } + EventMsg::ShutdownComplete => CodexStatus::Shutdown, _ => { if let Ok(line) = serde_json::to_string(&event) { println!("{line}"); } + CodexStatus::Running } } } diff --git a/codex-rs/exec/src/lib.rs b/codex-rs/exec/src/lib.rs index 620ab82327..126e92f597 100644 --- a/codex-rs/exec/src/lib.rs +++ b/codex-rs/exec/src/lib.rs @@ -5,7 +5,6 @@ mod event_processor_with_json_output; use std::io::IsTerminal; use std::io::Read; -use std::path::Path; use std::path::PathBuf; use std::sync::Arc; @@ -28,6 +27,7 @@ use tracing::error; use tracing::info; use tracing_subscriber::EnvFilter; +use crate::event_processor::CodexStatus; use crate::event_processor::EventProcessor; pub async fn run_main(cli: Cli, codex_linux_sandbox_exe: Option) -> anyhow::Result<()> { @@ -123,11 +123,12 @@ pub async fn run_main(cli: Cli, codex_linux_sandbox_exe: Option) -> any let config = Config::load_with_cli_overrides(cli_kv_overrides, overrides)?; let mut event_processor: Box = if json_mode { - Box::new(EventProcessorWithJsonOutput::new()) + Box::new(EventProcessorWithJsonOutput::new(last_message_file.clone())) } else { Box::new(EventProcessorWithHumanOutput::create_with_ansi( stdout_with_ansi, &config, + last_message_file.clone(), )) }; @@ -224,40 +225,17 @@ pub async fn run_main(cli: Cli, codex_linux_sandbox_exe: Option) -> any // Run the loop until the task is complete. while let Some(event) = rx.recv().await { - let (is_last_event, last_assistant_message) = match &event.msg { - EventMsg::TaskComplete(TaskCompleteEvent { last_agent_message }) => { - (true, last_agent_message.clone()) + let shutdown: CodexStatus = event_processor.process_event(event); + match shutdown { + CodexStatus::Running => continue, + CodexStatus::InitiateShutdown => { + codex.submit(Op::Shutdown).await?; + } + CodexStatus::Shutdown => { + break; } - _ => (false, None), - }; - event_processor.process_event(event); - if is_last_event { - handle_last_message(last_assistant_message, last_message_file.as_deref())?; - break; } } Ok(()) } - -fn handle_last_message( - last_agent_message: Option, - last_message_file: Option<&Path>, -) -> std::io::Result<()> { - match (last_agent_message, last_message_file) { - (Some(last_agent_message), Some(last_message_file)) => { - // Last message and a file to write to. - std::fs::write(last_message_file, last_agent_message)?; - } - (None, Some(last_message_file)) => { - eprintln!( - "Warning: No last message to write to file: {}", - last_message_file.to_string_lossy() - ); - } - (_, None) => { - // No last message and no file to write to. - } - } - Ok(()) -} diff --git a/codex-rs/mcp-server/src/codex_tool_runner.rs b/codex-rs/mcp-server/src/codex_tool_runner.rs index 4e10d158cf..f2cacf6c8e 100644 --- a/codex-rs/mcp-server/src/codex_tool_runner.rs +++ b/codex-rs/mcp-server/src/codex_tool_runner.rs @@ -246,7 +246,8 @@ async fn run_codex_tool_session_inner( | EventMsg::BackgroundEvent(_) | EventMsg::PatchApplyBegin(_) | EventMsg::PatchApplyEnd(_) - | EventMsg::GetHistoryEntryResponse(_) => { + | EventMsg::GetHistoryEntryResponse(_) + | EventMsg::ShutdownComplete => { // For now, we do not do anything extra for these // events. Note that // send(codex_event_to_notification(&event)) above has diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 37c2616d5b..377b5d6f0b 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -223,9 +223,7 @@ impl App<'_> { } => { match &mut self.app_state { AppState::Chat { widget } => { - if widget.on_ctrl_c() { - self.app_event_tx.send(AppEvent::ExitRequest); - } + widget.on_ctrl_c(); } AppState::Login { .. } | AppState::GitWarning { .. } => { // No-op. diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 3856587010..081a406f29 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -419,6 +419,9 @@ impl ChatWidget<'_> { self.bottom_pane .on_history_entry_response(log_id, offset, entry.map(|e| e.text)); } + EventMsg::ShutdownComplete => { + self.app_event_tx.send(AppEvent::ExitRequest); + } event => { self.conversation_history .add_background_event(format!("{event:?}")); @@ -471,6 +474,7 @@ impl ChatWidget<'_> { self.reasoning_buffer.clear(); false } else if self.bottom_pane.ctrl_c_quit_hint_visible() { + self.submit_op(Op::Shutdown); true } else { self.bottom_pane.show_ctrl_c_quit_hint(); From 6dd62ffa3b6cf754f8e9a7774e036242d0f4083f Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 23 Jul 2025 15:20:47 -0700 Subject: [PATCH 15/58] chore(deps-dev): bump @types/bun from 1.2.18 to 1.2.19 in /.github/actions/codex (#1635) [![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=@types/bun&package-manager=bun&previous-version=1.2.18&new-version=1.2.19)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot merge` will merge this PR after your CI passes on it - `@dependabot squash and merge` will squash and merge this PR after your CI passes on it - `@dependabot cancel merge` will cancel a previously requested merge and block automerging - `@dependabot reopen` will reopen this PR if it is closed - `@dependabot close` will close this PR and stop Dependabot recreating it. You can achieve the same result by closing it manually - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself)
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/actions/codex/bun.lock | 6 +++--- .github/actions/codex/package.json | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/actions/codex/bun.lock b/.github/actions/codex/bun.lock index e7382ff7a2..9360d4d1f6 100644 --- a/.github/actions/codex/bun.lock +++ b/.github/actions/codex/bun.lock @@ -8,7 +8,7 @@ "@actions/github": "^6.0.1", }, "devDependencies": { - "@types/bun": "^1.2.18", + "@types/bun": "^1.2.19", "@types/node": "^24.0.13", "prettier": "^3.6.2", "typescript": "^5.8.3", @@ -48,7 +48,7 @@ "@octokit/types": ["@octokit/types@13.10.0", "", { "dependencies": { "@octokit/openapi-types": "^24.2.0" } }, "sha512-ifLaO34EbbPj0Xgro4G5lP5asESjwHracYJvVaPIyXMuiuXLlhic3S47cBdTb+jfODkTE5YtGCLt3Ay3+J97sA=="], - "@types/bun": ["@types/bun@1.2.18", "", { "dependencies": { "bun-types": "1.2.18" } }, "sha512-Xf6RaWVheyemaThV0kUfaAUvCNokFr+bH8Jxp+tTZfx7dAPA8z9ePnP9S9+Vspzuxxx9JRAXhnyccRj3GyCMdQ=="], + "@types/bun": ["@types/bun@1.2.19", "", { "dependencies": { "bun-types": "1.2.19" } }, "sha512-d9ZCmrH3CJ2uYKXQIUuZ/pUnTqIvLDS0SK7pFmbx8ma+ziH/FRMoAq5bYpRG7y+w1gl+HgyNZbtqgMq4W4e2Lg=="], "@types/node": ["@types/node@24.0.13", "", { "dependencies": { "undici-types": "~7.8.0" } }, "sha512-Qm9OYVOFHFYg3wJoTSrz80hoec5Lia/dPp84do3X7dZvLikQvM1YpmvTBEdIr/e+U8HTkFjLHLnl78K/qjf+jQ=="], @@ -56,7 +56,7 @@ "before-after-hook": ["before-after-hook@2.2.3", "", {}, "sha512-NzUnlZexiaH/46WDhANlyR2bXRopNg4F/zuSA3OpZnllCUgRaOF2znDioDWrmbNVsuZk6l9pMquQB38cfBZwkQ=="], - "bun-types": ["bun-types@1.2.18", "", { "dependencies": { "@types/node": "*" }, "peerDependencies": { "@types/react": "^19" } }, "sha512-04+Eha5NP7Z0A9YgDAzMk5PHR16ZuLVa83b26kH5+cp1qZW4F6FmAURngE7INf4tKOvCE69vYvDEwoNl1tGiWw=="], + "bun-types": ["bun-types@1.2.19", "", { "dependencies": { "@types/node": "*" }, "peerDependencies": { "@types/react": "^19" } }, "sha512-uAOTaZSPuYsWIXRpj7o56Let0g/wjihKCkeRqUBhlLVM/Bt+Fj9xTo+LhC1OV1XDaGkz4hNC80et5xgy+9KTHQ=="], "csstype": ["csstype@3.1.3", "", {}, "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="], diff --git a/.github/actions/codex/package.json b/.github/actions/codex/package.json index 53260f4d58..ded2b7aac5 100644 --- a/.github/actions/codex/package.json +++ b/.github/actions/codex/package.json @@ -13,7 +13,7 @@ "@actions/github": "^6.0.1" }, "devDependencies": { - "@types/bun": "^1.2.18", + "@types/bun": "^1.2.19", "@types/node": "^24.0.13", "prettier": "^3.6.2", "typescript": "^5.8.3" From 4fc4e410bd34b496a33f9e94c9c1abe00ff0dd7c Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 23 Jul 2025 15:32:31 -0700 Subject: [PATCH 16/58] chore(deps-dev): bump @types/node from 24.0.13 to 24.0.15 in /.github/actions/codex (#1636) [![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=@types/node&package-manager=bun&previous-version=24.0.13&new-version=24.0.15)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot merge` will merge this PR after your CI passes on it - `@dependabot squash and merge` will squash and merge this PR after your CI passes on it - `@dependabot cancel merge` will cancel a previously requested merge and block automerging - `@dependabot reopen` will reopen this PR if it is closed - `@dependabot close` will close this PR and stop Dependabot recreating it. You can achieve the same result by closing it manually - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself)
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/actions/codex/bun.lock | 6 ++++-- .github/actions/codex/package.json | 2 +- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/.github/actions/codex/bun.lock b/.github/actions/codex/bun.lock index 9360d4d1f6..8b546a5ac6 100644 --- a/.github/actions/codex/bun.lock +++ b/.github/actions/codex/bun.lock @@ -9,7 +9,7 @@ }, "devDependencies": { "@types/bun": "^1.2.19", - "@types/node": "^24.0.13", + "@types/node": "^24.1.0", "prettier": "^3.6.2", "typescript": "^5.8.3", }, @@ -50,7 +50,7 @@ "@types/bun": ["@types/bun@1.2.19", "", { "dependencies": { "bun-types": "1.2.19" } }, "sha512-d9ZCmrH3CJ2uYKXQIUuZ/pUnTqIvLDS0SK7pFmbx8ma+ziH/FRMoAq5bYpRG7y+w1gl+HgyNZbtqgMq4W4e2Lg=="], - "@types/node": ["@types/node@24.0.13", "", { "dependencies": { "undici-types": "~7.8.0" } }, "sha512-Qm9OYVOFHFYg3wJoTSrz80hoec5Lia/dPp84do3X7dZvLikQvM1YpmvTBEdIr/e+U8HTkFjLHLnl78K/qjf+jQ=="], + "@types/node": ["@types/node@24.1.0", "", { "dependencies": { "undici-types": "~7.8.0" } }, "sha512-ut5FthK5moxFKH2T1CUOC6ctR67rQRvvHdFLCD2Ql6KXmMuCrjsSsRI9UsLCm9M18BMwClv4pn327UvB7eeO1w=="], "@types/react": ["@types/react@19.1.8", "", { "dependencies": { "csstype": "^3.0.2" } }, "sha512-AwAfQ2Wa5bCx9WP8nZL2uMZWod7J7/JSplxbTmBQ5ms6QpqNYm672H0Vu9ZVKVngQ+ii4R/byguVEUZQyeg44g=="], @@ -82,6 +82,8 @@ "@octokit/plugin-rest-endpoint-methods/@octokit/types": ["@octokit/types@12.6.0", "", { "dependencies": { "@octokit/openapi-types": "^20.0.0" } }, "sha512-1rhSOfRa6H9w4YwK0yrf5faDaDTb+yLyBUKOCV4xtCDB5VmIPqd/v9yr9o6SAzOAlRxMiRiCic6JVM1/kunVkw=="], + "bun-types/@types/node": ["@types/node@24.0.13", "", { "dependencies": { "undici-types": "~7.8.0" } }, "sha512-Qm9OYVOFHFYg3wJoTSrz80hoec5Lia/dPp84do3X7dZvLikQvM1YpmvTBEdIr/e+U8HTkFjLHLnl78K/qjf+jQ=="], + "@octokit/plugin-paginate-rest/@octokit/types/@octokit/openapi-types": ["@octokit/openapi-types@20.0.0", "", {}, "sha512-EtqRBEjp1dL/15V7WiX5LJMIxxkdiGJnabzYx5Apx4FkQIFgAfKumXeYAqqJCj1s+BMX4cPFIFC4OLCR6stlnA=="], "@octokit/plugin-rest-endpoint-methods/@octokit/types/@octokit/openapi-types": ["@octokit/openapi-types@20.0.0", "", {}, "sha512-EtqRBEjp1dL/15V7WiX5LJMIxxkdiGJnabzYx5Apx4FkQIFgAfKumXeYAqqJCj1s+BMX4cPFIFC4OLCR6stlnA=="], diff --git a/.github/actions/codex/package.json b/.github/actions/codex/package.json index ded2b7aac5..21817b8a59 100644 --- a/.github/actions/codex/package.json +++ b/.github/actions/codex/package.json @@ -14,7 +14,7 @@ }, "devDependencies": { "@types/bun": "^1.2.19", - "@types/node": "^24.0.13", + "@types/node": "^24.1.0", "prettier": "^3.6.2", "typescript": "^5.8.3" } From 6e1838e0d8bb66312479250cf3c3558a647d1501 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 23 Jul 2025 15:36:08 -0700 Subject: [PATCH 17/58] chore(deps): bump rand from 0.9.1 to 0.9.2 in /codex-rs (#1637) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps [rand](https://github.com/rust-random/rand) from 0.9.1 to 0.9.2.
Changelog

Sourced from rand's changelog.

[0.9.2 — 2025-07-20]

Deprecated

  • Deprecate rand::rngs::mock module and StepRng generator (#1634)

Additions

  • Enable WeightedIndex<usize> (de)serialization (#1646)
Commits

[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=rand&package-manager=cargo&previous-version=0.9.1&new-version=0.9.2)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot merge` will merge this PR after your CI passes on it - `@dependabot squash and merge` will squash and merge this PR after your CI passes on it - `@dependabot cancel merge` will cancel a previously requested merge and block automerging - `@dependabot reopen` will reopen this PR if it is closed - `@dependabot close` will close this PR and stop Dependabot recreating it. You can achieve the same result by closing it manually - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself)
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- codex-rs/Cargo.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 3e4b84a435..e1d64a93b9 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -676,7 +676,7 @@ dependencies = [ "openssl-sys", "predicates", "pretty_assertions", - "rand 0.9.1", + "rand 0.9.2", "reqwest", "seccompiler", "serde", @@ -3279,9 +3279,9 @@ dependencies = [ [[package]] name = "rand" -version = "0.9.1" +version = "0.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9fbfd9d094a40bf3ae768db9361049ace4c0e04a4fd6b359518bd7b73a73dd97" +checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" dependencies = [ "rand_chacha 0.9.0", "rand_core 0.9.3", From db847220801d4065e1b13f5979907db84be5cbed Mon Sep 17 00:00:00 2001 From: Gabriel Peal Date: Wed, 23 Jul 2025 15:40:00 -0700 Subject: [PATCH 18/58] Fix flaky test (#1664) Co-authored-by: aibrahim-oai --- codex-rs/chatgpt/src/apply_command.rs | 24 +++++++++++++++------ codex-rs/chatgpt/tests/apply_command_e2e.rs | 14 ++---------- codex-rs/cli/src/main.rs | 2 +- 3 files changed, 21 insertions(+), 19 deletions(-) diff --git a/codex-rs/chatgpt/src/apply_command.rs b/codex-rs/chatgpt/src/apply_command.rs index 4209d958e1..52ab205a0c 100644 --- a/codex-rs/chatgpt/src/apply_command.rs +++ b/codex-rs/chatgpt/src/apply_command.rs @@ -1,3 +1,5 @@ +use std::path::PathBuf; + use clap::Parser; use codex_common::CliConfigOverrides; use codex_core::config::Config; @@ -17,7 +19,10 @@ pub struct ApplyCommand { #[clap(flatten)] pub config_overrides: CliConfigOverrides, } -pub async fn run_apply_command(apply_cli: ApplyCommand) -> anyhow::Result<()> { +pub async fn run_apply_command( + apply_cli: ApplyCommand, + cwd: Option, +) -> anyhow::Result<()> { let config = Config::load_with_cli_overrides( apply_cli .config_overrides @@ -29,10 +34,13 @@ pub async fn run_apply_command(apply_cli: ApplyCommand) -> anyhow::Result<()> { init_chatgpt_token_from_auth(&config.codex_home).await?; let task_response = get_task(&config, apply_cli.task_id).await?; - apply_diff_from_task(task_response).await + apply_diff_from_task(task_response, cwd).await } -pub async fn apply_diff_from_task(task_response: GetTaskResponse) -> anyhow::Result<()> { +pub async fn apply_diff_from_task( + task_response: GetTaskResponse, + cwd: Option, +) -> anyhow::Result<()> { let diff_turn = match task_response.current_diff_task_turn { Some(turn) => turn, None => anyhow::bail!("No diff turn found"), @@ -42,13 +50,17 @@ pub async fn apply_diff_from_task(task_response: GetTaskResponse) -> anyhow::Res _ => None, }); match output_diff { - Some(output_diff) => apply_diff(&output_diff.diff).await, + Some(output_diff) => apply_diff(&output_diff.diff, cwd).await, None => anyhow::bail!("No PR output item found"), } } -async fn apply_diff(diff: &str) -> anyhow::Result<()> { - let toplevel_output = tokio::process::Command::new("git") +async fn apply_diff(diff: &str, cwd: Option) -> anyhow::Result<()> { + let mut cmd = tokio::process::Command::new("git"); + if let Some(cwd) = cwd { + cmd.current_dir(cwd); + } + let toplevel_output = cmd .args(vec!["rev-parse", "--show-toplevel"]) .output() .await?; diff --git a/codex-rs/chatgpt/tests/apply_command_e2e.rs b/codex-rs/chatgpt/tests/apply_command_e2e.rs index e395e4f155..45c33bedb4 100644 --- a/codex-rs/chatgpt/tests/apply_command_e2e.rs +++ b/codex-rs/chatgpt/tests/apply_command_e2e.rs @@ -78,17 +78,7 @@ async fn test_apply_command_creates_fibonacci_file() { .await .expect("Failed to load fixture"); - let original_dir = std::env::current_dir().expect("Failed to get current dir"); - std::env::set_current_dir(repo_path).expect("Failed to change directory"); - struct DirGuard(std::path::PathBuf); - impl Drop for DirGuard { - fn drop(&mut self) { - let _ = std::env::set_current_dir(&self.0); - } - } - let _guard = DirGuard(original_dir); - - apply_diff_from_task(task_response) + apply_diff_from_task(task_response, Some(repo_path.to_path_buf())) .await .expect("Failed to apply diff from task"); @@ -173,7 +163,7 @@ console.log(fib(10)); .await .expect("Failed to load fixture"); - let apply_result = apply_diff_from_task(task_response).await; + let apply_result = apply_diff_from_task(task_response, Some(repo_path.to_path_buf())).await; assert!( apply_result.is_err(), diff --git a/codex-rs/cli/src/main.rs b/codex-rs/cli/src/main.rs index 7e23782d75..e397b0ca6a 100644 --- a/codex-rs/cli/src/main.rs +++ b/codex-rs/cli/src/main.rs @@ -145,7 +145,7 @@ async fn cli_main(codex_linux_sandbox_exe: Option) -> anyhow::Result<() }, Some(Subcommand::Apply(mut apply_cli)) => { prepend_config_flags(&mut apply_cli.config_overrides, cli.config_overrides); - run_apply_command(apply_cli).await?; + run_apply_command(apply_cli, None).await?; } } From 9f645353e913014692c64b81c027b7e57d3762e7 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 23 Jul 2025 16:07:33 -0700 Subject: [PATCH 19/58] chore(deps): bump strum from 0.27.1 to 0.27.2 in /codex-rs (#1639) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps [strum](https://github.com/Peternator7/strum) from 0.27.1 to 0.27.2.
Release notes

Sourced from strum's releases.

v0.27.2

What's Changed

New Contributors

Full Changelog: https://github.com/Peternator7/strum/compare/v0.27.1...v0.27.2

Changelog

Sourced from strum's changelog.

0.27.2

  • #141: Adding support for doc comments on EnumDiscriminants generated type.

    • The doc comment will be copied from the variant on the type itself.
  • #435:allow discriminants on empty enum.

  • #443: Change enum table callbacks to FnMut.

  • #444: Add #[automatically_derived] to the impls by @​dandedotdev in Peternator7/strum#444

    • This should make the linter less noisy with warnings in generated code.
  • #440: Implement a suffix attribute for serialization of enum variants.

    #[derive(strum::Display)]
    #[strum(suffix=".json")]
    #[strum(serialize_all="snake_case")]
    enum StorageConfiguration {
      PostgresProvider,
      S3StorageProvider,
      AzureStorageProvider,
    }
    

    fn main() { let response = SurveyResponse::Other("It was good".into()); println!("Loading configuration from: {}", StorageConfiguration::PostgresProvider); // prints: Loaded Configuration from: postgres_provider.json }

  • #446: Drop needless rustversion dependency.

Commits

[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=strum&package-manager=cargo&previous-version=0.27.1&new-version=0.27.2)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot merge` will merge this PR after your CI passes on it - `@dependabot squash and merge` will squash and merge this PR after your CI passes on it - `@dependabot cancel merge` will cancel a previously requested merge and block automerging - `@dependabot reopen` will reopen this PR if it is closed - `@dependabot close` will close this PR and stop Dependabot recreating it. You can achieve the same result by closing it manually - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself)
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- codex-rs/Cargo.lock | 6 +++--- codex-rs/tui/Cargo.toml | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index e1d64a93b9..2b7a6ec083 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -840,7 +840,7 @@ dependencies = [ "regex-lite", "serde_json", "shlex", - "strum 0.27.1", + "strum 0.27.2", "strum_macros 0.27.1", "tokio", "tracing", @@ -4263,9 +4263,9 @@ dependencies = [ [[package]] name = "strum" -version = "0.27.1" +version = "0.27.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f64def088c51c9510a8579e3c5d67c65349dcf755e5479ad3d010aa6454e2c32" +checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf" [[package]] name = "strum_macros" diff --git a/codex-rs/tui/Cargo.toml b/codex-rs/tui/Cargo.toml index 74aedfa353..654efd5c9c 100644 --- a/codex-rs/tui/Cargo.toml +++ b/codex-rs/tui/Cargo.toml @@ -42,7 +42,7 @@ ratatui-image = "8.0.0" regex-lite = "0.1" serde_json = { version = "1", features = ["preserve_order"] } shlex = "1.3.0" -strum = "0.27.1" +strum = "0.27.2" strum_macros = "0.27.1" tokio = { version = "1", features = [ "io-std", From 4a57afaaf2647101b015cbc83775def74fe9f96f Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 23 Jul 2025 16:34:16 -0700 Subject: [PATCH 20/58] chore(deps): bump strum_macros from 0.27.1 to 0.27.2 in /codex-rs (#1638) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps [strum_macros](https://github.com/Peternator7/strum) from 0.27.1 to 0.27.2.
Release notes

Sourced from strum_macros's releases.

v0.27.2

What's Changed

New Contributors

Full Changelog: https://github.com/Peternator7/strum/compare/v0.27.1...v0.27.2

Changelog

Sourced from strum_macros's changelog.

0.27.2

  • #141: Adding support for doc comments on EnumDiscriminants generated type.

    • The doc comment will be copied from the variant on the type itself.
  • #435:allow discriminants on empty enum.

  • #443: Change enum table callbacks to FnMut.

  • #444: Add #[automatically_derived] to the impls by @​dandedotdev in Peternator7/strum#444

    • This should make the linter less noisy with warnings in generated code.
  • #440: Implement a suffix attribute for serialization of enum variants.

    #[derive(strum::Display)]
    #[strum(suffix=".json")]
    #[strum(serialize_all="snake_case")]
    enum StorageConfiguration {
      PostgresProvider,
      S3StorageProvider,
      AzureStorageProvider,
    }
    

    fn main() { let response = SurveyResponse::Other("It was good".into()); println!("Loading configuration from: {}", StorageConfiguration::PostgresProvider); // prints: Loaded Configuration from: postgres_provider.json }

  • #446: Drop needless rustversion dependency.

Commits

[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=strum_macros&package-manager=cargo&previous-version=0.27.1&new-version=0.27.2)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot merge` will merge this PR after your CI passes on it - `@dependabot squash and merge` will squash and merge this PR after your CI passes on it - `@dependabot cancel merge` will cancel a previously requested merge and block automerging - `@dependabot reopen` will reopen this PR if it is closed - `@dependabot close` will close this PR and stop Dependabot recreating it. You can achieve the same result by closing it manually - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself)
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- codex-rs/Cargo.lock | 9 ++++----- codex-rs/core/Cargo.toml | 2 +- codex-rs/tui/Cargo.toml | 2 +- 3 files changed, 6 insertions(+), 7 deletions(-) diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 2b7a6ec083..e1c17e1850 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -682,7 +682,7 @@ dependencies = [ "serde", "serde_json", "sha1", - "strum_macros 0.27.1", + "strum_macros 0.27.2", "tempfile", "thiserror 2.0.12", "time", @@ -841,7 +841,7 @@ dependencies = [ "serde_json", "shlex", "strum 0.27.2", - "strum_macros 0.27.1", + "strum_macros 0.27.2", "tokio", "tracing", "tracing-appender", @@ -4282,14 +4282,13 @@ dependencies = [ [[package]] name = "strum_macros" -version = "0.27.1" +version = "0.27.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c77a8c5abcaf0f9ce05d62342b7d298c346515365c36b673df4ebe3ced01fde8" +checksum = "7695ce3845ea4b33927c055a39dc438a45b059f7c1b3d91d38d10355fb8cbca7" dependencies = [ "heck", "proc-macro2", "quote", - "rustversion", "syn 2.0.104", ] diff --git a/codex-rs/core/Cargo.toml b/codex-rs/core/Cargo.toml index a87894bc4d..2d0877633a 100644 --- a/codex-rs/core/Cargo.toml +++ b/codex-rs/core/Cargo.toml @@ -30,7 +30,7 @@ reqwest = { version = "0.12", features = ["json", "stream"] } serde = { version = "1", features = ["derive"] } serde_json = "1" sha1 = "0.10.6" -strum_macros = "0.27.1" +strum_macros = "0.27.2" thiserror = "2.0.12" time = { version = "0.3", features = ["formatting", "local-offset", "macros"] } tokio = { version = "1", features = [ diff --git a/codex-rs/tui/Cargo.toml b/codex-rs/tui/Cargo.toml index 654efd5c9c..b2f2b9b653 100644 --- a/codex-rs/tui/Cargo.toml +++ b/codex-rs/tui/Cargo.toml @@ -43,7 +43,7 @@ regex-lite = "0.1" serde_json = { version = "1", features = ["preserve_order"] } shlex = "1.3.0" strum = "0.27.2" -strum_macros = "0.27.1" +strum_macros = "0.27.2" tokio = { version = "1", features = [ "io-std", "macros", From 173386eeac282275c226b3a292e268140caaeed0 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 23 Jul 2025 16:59:05 -0700 Subject: [PATCH 21/58] chore(deps): bump tree-sitter from 0.25.6 to 0.25.8 in /codex-rs (#1561) Bumps [tree-sitter](https://github.com/tree-sitter/tree-sitter) from 0.25.6 to 0.25.8.
Commits
  • f2f197b 0.25.8
  • 8bb33f7 perf: reorder conditional operands
  • 6f944de fix(generate): propagate node types error
  • c159385 0.25.7
  • 94b55bf perf: reorder expensive conditional operand
  • bcb30f7 fix(generate): use topological sort for subtype map
  • 3bd8f7d perf: More efficient computation of used symbols
  • d7529c3 perf: reserve Vec capacities where appropriate
  • bf4217f fix(web): wasm export paths
  • bb7b339 Fix 'extra' field generation for node-types.json
  • Additional commits viewable in compare view

[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=tree-sitter&package-manager=cargo&previous-version=0.25.6&new-version=0.25.8)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot merge` will merge this PR after your CI passes on it - `@dependabot squash and merge` will squash and merge this PR after your CI passes on it - `@dependabot cancel merge` will cancel a previously requested merge and block automerging - `@dependabot reopen` will reopen this PR if it is closed - `@dependabot close` will close this PR and stop Dependabot recreating it. You can achieve the same result by closing it manually - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself)
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- codex-rs/Cargo.lock | 4 ++-- codex-rs/apply-patch/Cargo.toml | 2 +- codex-rs/core/Cargo.toml | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index e1c17e1850..20b73ade8e 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -4855,9 +4855,9 @@ dependencies = [ [[package]] name = "tree-sitter" -version = "0.25.6" +version = "0.25.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a7cf18d43cbf0bfca51f657132cc616a5097edc4424d538bae6fa60142eaf9f0" +checksum = "6d7b8994f367f16e6fa14b5aebbcb350de5d7cbea82dc5b00ae997dd71680dd2" dependencies = [ "cc", "regex", diff --git a/codex-rs/apply-patch/Cargo.toml b/codex-rs/apply-patch/Cargo.toml index 7848e6e47f..5b95d4fa15 100644 --- a/codex-rs/apply-patch/Cargo.toml +++ b/codex-rs/apply-patch/Cargo.toml @@ -14,7 +14,7 @@ workspace = true anyhow = "1" similar = "2.7.0" thiserror = "2.0.12" -tree-sitter = "0.25.3" +tree-sitter = "0.25.8" tree-sitter-bash = "0.25.0" [dev-dependencies] diff --git a/codex-rs/core/Cargo.toml b/codex-rs/core/Cargo.toml index 2d0877633a..d3933f842c 100644 --- a/codex-rs/core/Cargo.toml +++ b/codex-rs/core/Cargo.toml @@ -43,7 +43,7 @@ tokio = { version = "1", features = [ tokio-util = "0.7.14" toml = "0.9.1" tracing = { version = "0.1.41", features = ["log"] } -tree-sitter = "0.25.3" +tree-sitter = "0.25.8" tree-sitter-bash = "0.25.0" uuid = { version = "1", features = ["serde", "v4"] } wildmatch = "2.4.0" From d2be0720b5fdd2d1fad3e62ffeadbab89a8e65bc Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 23 Jul 2025 17:22:05 -0700 Subject: [PATCH 22/58] chore(deps): bump toml from 0.9.1 to 0.9.2 in /codex-rs (#1562) Bumps [toml](https://github.com/toml-rs/toml) from 0.9.1 to 0.9.2.
Commits

[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=toml&package-manager=cargo&previous-version=0.9.1&new-version=0.9.2)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot merge` will merge this PR after your CI passes on it - `@dependabot squash and merge` will squash and merge this PR after your CI passes on it - `@dependabot cancel merge` will cancel a previously requested merge and block automerging - `@dependabot reopen` will reopen this PR if it is closed - `@dependabot close` will close this PR and stop Dependabot recreating it. You can achieve the same result by closing it manually - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself)
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- codex-rs/Cargo.lock | 14 +++++++------- codex-rs/core/Cargo.toml | 2 +- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 20b73ade8e..d179a142f4 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -649,7 +649,7 @@ dependencies = [ "clap", "codex-core", "serde", - "toml 0.9.1", + "toml 0.9.2", ] [[package]] @@ -689,7 +689,7 @@ dependencies = [ "tokio", "tokio-test", "tokio-util", - "toml 0.9.1", + "toml 0.9.2", "tracing", "tree-sitter", "tree-sitter-bash", @@ -807,7 +807,7 @@ dependencies = [ "tempfile", "tokio", "tokio-test", - "toml 0.9.1", + "toml 0.9.2", "tracing", "tracing-subscriber", "uuid", @@ -4665,9 +4665,9 @@ dependencies = [ [[package]] name = "toml" -version = "0.9.1" +version = "0.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0207d6ed1852c2a124c1fbec61621acb8330d2bf969a5d0643131e9affd985a5" +checksum = "ed0aee96c12fa71097902e0bb061a5e1ebd766a6636bb605ba401c45c1650eac" dependencies = [ "indexmap 2.10.0", "serde", @@ -4711,9 +4711,9 @@ dependencies = [ [[package]] name = "toml_parser" -version = "1.0.0" +version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b5c1c469eda89749d2230d8156a5969a69ffe0d6d01200581cdc6110674d293e" +checksum = "97200572db069e74c512a14117b296ba0a80a30123fbbb5aa1f4a348f639ca30" dependencies = [ "winnow", ] diff --git a/codex-rs/core/Cargo.toml b/codex-rs/core/Cargo.toml index d3933f842c..50049e9629 100644 --- a/codex-rs/core/Cargo.toml +++ b/codex-rs/core/Cargo.toml @@ -41,7 +41,7 @@ tokio = { version = "1", features = [ "signal", ] } tokio-util = "0.7.14" -toml = "0.9.1" +toml = "0.9.2" tracing = { version = "0.1.41", features = ["log"] } tree-sitter = "0.25.8" tree-sitter-bash = "0.25.0" From 2437a8d17a0cf972d1a6e7f303d469b6e2f57eae Mon Sep 17 00:00:00 2001 From: vishnu-oai Date: Thu, 24 Jul 2025 19:35:28 +0100 Subject: [PATCH 23/58] Record Git metadata to rollout (#1598) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Summary - Writing effective evals for codex sessions requires context of the overall repository state at the moment the session began - This change adds this metadata (git repository, branch, commit hash) to the top of the rollout of the session (if available - if not it doesn't add anything) - Currently, this is only effective on a clean working tree, as we can't track uncommitted/untracked changes with the current metadata set. Ideally in the future we may want to track unclean changes somehow, or perhaps prompt the user to stash or commit them. # Testing - Added unit tests - `cargo test && cargo clippy --tests && cargo fmt -- --config imports_granularity=Item` ### Resulting Rollout Screenshot 2025-07-17 at 1 50 00 PM --- codex-rs/core/src/codex.rs | 2 +- codex-rs/core/src/git_info.rs | 307 ++++++++++++++++++++++++++++++ codex-rs/core/src/lib.rs | 1 + codex-rs/core/src/rollout.rs | 55 ++++-- codex-rs/core/tests/cli_stream.rs | 123 ++++++++++++ 5 files changed, 475 insertions(+), 13 deletions(-) create mode 100644 codex-rs/core/src/git_info.rs diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 4cc888b62e..f35348b779 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -594,7 +594,7 @@ async fn submission_loop( let mut restored_items: Option> = None; let rollout_recorder: Option = if let Some(path) = resume_path.as_ref() { - match RolloutRecorder::resume(path).await { + match RolloutRecorder::resume(path, cwd.clone()).await { Ok((rec, saved)) => { session_id = saved.session_id; if !saved.items.is_empty() { diff --git a/codex-rs/core/src/git_info.rs b/codex-rs/core/src/git_info.rs new file mode 100644 index 0000000000..cf959d32d1 --- /dev/null +++ b/codex-rs/core/src/git_info.rs @@ -0,0 +1,307 @@ +use std::path::Path; + +use serde::Deserialize; +use serde::Serialize; +use tokio::process::Command; +use tokio::time::Duration as TokioDuration; +use tokio::time::timeout; + +/// Timeout for git commands to prevent freezing on large repositories +const GIT_COMMAND_TIMEOUT: TokioDuration = TokioDuration::from_secs(5); + +#[derive(Serialize, Deserialize, Clone)] +pub struct GitInfo { + /// Current commit hash (SHA) + #[serde(skip_serializing_if = "Option::is_none")] + pub commit_hash: Option, + /// Current branch name + #[serde(skip_serializing_if = "Option::is_none")] + pub branch: Option, + /// Repository URL (if available from remote) + #[serde(skip_serializing_if = "Option::is_none")] + pub repository_url: Option, +} + +/// Collect git repository information from the given working directory using command-line git. +/// Returns None if no git repository is found or if git operations fail. +/// Uses timeouts to prevent freezing on large repositories. +/// All git commands (except the initial repo check) run in parallel for better performance. +pub async fn collect_git_info(cwd: &Path) -> Option { + // Check if we're in a git repository first + let is_git_repo = run_git_command_with_timeout(&["rev-parse", "--git-dir"], cwd) + .await? + .status + .success(); + + if !is_git_repo { + return None; + } + + // Run all git info collection commands in parallel + let (commit_result, branch_result, url_result) = tokio::join!( + run_git_command_with_timeout(&["rev-parse", "HEAD"], cwd), + run_git_command_with_timeout(&["rev-parse", "--abbrev-ref", "HEAD"], cwd), + run_git_command_with_timeout(&["remote", "get-url", "origin"], cwd) + ); + + let mut git_info = GitInfo { + commit_hash: None, + branch: None, + repository_url: None, + }; + + // Process commit hash + if let Some(output) = commit_result { + if output.status.success() { + if let Ok(hash) = String::from_utf8(output.stdout) { + git_info.commit_hash = Some(hash.trim().to_string()); + } + } + } + + // Process branch name + if let Some(output) = branch_result { + if output.status.success() { + if let Ok(branch) = String::from_utf8(output.stdout) { + let branch = branch.trim(); + if branch != "HEAD" { + git_info.branch = Some(branch.to_string()); + } + } + } + } + + // Process repository URL + if let Some(output) = url_result { + if output.status.success() { + if let Ok(url) = String::from_utf8(output.stdout) { + git_info.repository_url = Some(url.trim().to_string()); + } + } + } + + Some(git_info) +} + +/// Run a git command with a timeout to prevent blocking on large repositories +async fn run_git_command_with_timeout(args: &[&str], cwd: &Path) -> Option { + let result = timeout( + GIT_COMMAND_TIMEOUT, + Command::new("git").args(args).current_dir(cwd).output(), + ) + .await; + + match result { + Ok(Ok(output)) => Some(output), + _ => None, // Timeout or error + } +} + +#[cfg(test)] +mod tests { + #![allow(clippy::expect_used)] + #![allow(clippy::unwrap_used)] + + use super::*; + + use std::fs; + use std::path::PathBuf; + use tempfile::TempDir; + + // Helper function to create a test git repository + async fn create_test_git_repo(temp_dir: &TempDir) -> PathBuf { + let repo_path = temp_dir.path().to_path_buf(); + + // Initialize git repo + Command::new("git") + .args(["init"]) + .current_dir(&repo_path) + .output() + .await + .expect("Failed to init git repo"); + + // Configure git user (required for commits) + Command::new("git") + .args(["config", "user.name", "Test User"]) + .current_dir(&repo_path) + .output() + .await + .expect("Failed to set git user name"); + + Command::new("git") + .args(["config", "user.email", "test@example.com"]) + .current_dir(&repo_path) + .output() + .await + .expect("Failed to set git user email"); + + // Create a test file and commit it + let test_file = repo_path.join("test.txt"); + fs::write(&test_file, "test content").expect("Failed to write test file"); + + Command::new("git") + .args(["add", "."]) + .current_dir(&repo_path) + .output() + .await + .expect("Failed to add files"); + + Command::new("git") + .args(["commit", "-m", "Initial commit"]) + .current_dir(&repo_path) + .output() + .await + .expect("Failed to commit"); + + repo_path + } + + #[tokio::test] + async fn test_collect_git_info_non_git_directory() { + let temp_dir = TempDir::new().expect("Failed to create temp dir"); + let result = collect_git_info(temp_dir.path()).await; + assert!(result.is_none()); + } + + #[tokio::test] + async fn test_collect_git_info_git_repository() { + let temp_dir = TempDir::new().expect("Failed to create temp dir"); + let repo_path = create_test_git_repo(&temp_dir).await; + + let git_info = collect_git_info(&repo_path) + .await + .expect("Should collect git info from repo"); + + // Should have commit hash + assert!(git_info.commit_hash.is_some()); + let commit_hash = git_info.commit_hash.unwrap(); + assert_eq!(commit_hash.len(), 40); // SHA-1 hash should be 40 characters + assert!(commit_hash.chars().all(|c| c.is_ascii_hexdigit())); + + // Should have branch (likely "main" or "master") + assert!(git_info.branch.is_some()); + let branch = git_info.branch.unwrap(); + assert!(branch == "main" || branch == "master"); + + // Repository URL might be None for local repos without remote + // This is acceptable behavior + } + + #[tokio::test] + async fn test_collect_git_info_with_remote() { + let temp_dir = TempDir::new().expect("Failed to create temp dir"); + let repo_path = create_test_git_repo(&temp_dir).await; + + // Add a remote origin + Command::new("git") + .args([ + "remote", + "add", + "origin", + "https://github.com/example/repo.git", + ]) + .current_dir(&repo_path) + .output() + .await + .expect("Failed to add remote"); + + let git_info = collect_git_info(&repo_path) + .await + .expect("Should collect git info from repo"); + + // Should have repository URL + assert_eq!( + git_info.repository_url, + Some("https://github.com/example/repo.git".to_string()) + ); + } + + #[tokio::test] + async fn test_collect_git_info_detached_head() { + let temp_dir = TempDir::new().expect("Failed to create temp dir"); + let repo_path = create_test_git_repo(&temp_dir).await; + + // Get the current commit hash + let output = Command::new("git") + .args(["rev-parse", "HEAD"]) + .current_dir(&repo_path) + .output() + .await + .expect("Failed to get HEAD"); + let commit_hash = String::from_utf8(output.stdout).unwrap().trim().to_string(); + + // Checkout the commit directly (detached HEAD) + Command::new("git") + .args(["checkout", &commit_hash]) + .current_dir(&repo_path) + .output() + .await + .expect("Failed to checkout commit"); + + let git_info = collect_git_info(&repo_path) + .await + .expect("Should collect git info from repo"); + + // Should have commit hash + assert!(git_info.commit_hash.is_some()); + // Branch should be None for detached HEAD (since rev-parse --abbrev-ref HEAD returns "HEAD") + assert!(git_info.branch.is_none()); + } + + #[tokio::test] + async fn test_collect_git_info_with_branch() { + let temp_dir = TempDir::new().expect("Failed to create temp dir"); + let repo_path = create_test_git_repo(&temp_dir).await; + + // Create and checkout a new branch + Command::new("git") + .args(["checkout", "-b", "feature-branch"]) + .current_dir(&repo_path) + .output() + .await + .expect("Failed to create branch"); + + let git_info = collect_git_info(&repo_path) + .await + .expect("Should collect git info from repo"); + + // Should have the new branch name + assert_eq!(git_info.branch, Some("feature-branch".to_string())); + } + + #[test] + fn test_git_info_serialization() { + let git_info = GitInfo { + commit_hash: Some("abc123def456".to_string()), + branch: Some("main".to_string()), + repository_url: Some("https://github.com/example/repo.git".to_string()), + }; + + let json = serde_json::to_string(&git_info).expect("Should serialize GitInfo"); + let parsed: serde_json::Value = serde_json::from_str(&json).expect("Should parse JSON"); + + assert_eq!(parsed["commit_hash"], "abc123def456"); + assert_eq!(parsed["branch"], "main"); + assert_eq!( + parsed["repository_url"], + "https://github.com/example/repo.git" + ); + } + + #[test] + fn test_git_info_serialization_with_nones() { + let git_info = GitInfo { + commit_hash: None, + branch: None, + repository_url: None, + }; + + let json = serde_json::to_string(&git_info).expect("Should serialize GitInfo"); + let parsed: serde_json::Value = serde_json::from_str(&json).expect("Should parse JSON"); + + // Fields with None values should be omitted due to skip_serializing_if + assert!(!parsed.as_object().unwrap().contains_key("commit_hash")); + assert!(!parsed.as_object().unwrap().contains_key("branch")); + assert!(!parsed.as_object().unwrap().contains_key("repository_url")); + } +} diff --git a/codex-rs/core/src/lib.rs b/codex-rs/core/src/lib.rs index 6812260c97..4e69e94b55 100644 --- a/codex-rs/core/src/lib.rs +++ b/codex-rs/core/src/lib.rs @@ -19,6 +19,7 @@ pub mod error; pub mod exec; pub mod exec_env; mod flags; +pub mod git_info; mod is_safe_command; mod mcp_connection_manager; mod mcp_tool_call; diff --git a/codex-rs/core/src/rollout.rs b/codex-rs/core/src/rollout.rs index 7f0f61b9eb..3e6de34d96 100644 --- a/codex-rs/core/src/rollout.rs +++ b/codex-rs/core/src/rollout.rs @@ -20,6 +20,8 @@ use tracing::warn; use uuid::Uuid; use crate::config::Config; +use crate::git_info::GitInfo; +use crate::git_info::collect_git_info; use crate::models::ResponseItem; const SESSIONS_SUBDIR: &str = "sessions"; @@ -31,6 +33,14 @@ pub struct SessionMeta { pub instructions: Option, } +#[derive(Serialize)] +struct SessionMetaWithGit { + #[serde(flatten)] + meta: SessionMeta, + #[serde(skip_serializing_if = "Option::is_none")] + git: Option, +} + #[derive(Serialize, Deserialize, Default, Clone)] pub struct SessionStateSnapshot {} @@ -86,15 +96,12 @@ impl RolloutRecorder { .format(timestamp_format) .map_err(|e| IoError::other(format!("failed to format timestamp: {e}")))?; - let meta = SessionMeta { - timestamp, - id: session_id, - instructions, - }; + // Clone the cwd for the spawned task to collect git info asynchronously + let cwd = config.cwd.clone(); // A reasonably-sized bounded channel. If the buffer fills up the send // future will yield, which is fine – we only need to ensure we do not - // perform *blocking* I/O on the caller’s thread. + // perform *blocking* I/O on the caller's thread. let (tx, rx) = mpsc::channel::(256); // Spawn a Tokio task that owns the file handle and performs async @@ -103,7 +110,12 @@ impl RolloutRecorder { tokio::task::spawn(rollout_writer( tokio::fs::File::from_std(file), rx, - Some(meta), + Some(SessionMeta { + timestamp, + id: session_id, + instructions, + }), + cwd, )); Ok(Self { tx }) @@ -143,7 +155,10 @@ impl RolloutRecorder { .map_err(|e| IoError::other(format!("failed to queue rollout state: {e}"))) } - pub async fn resume(path: &Path) -> std::io::Result<(Self, SavedSession)> { + pub async fn resume( + path: &Path, + cwd: std::path::PathBuf, + ) -> std::io::Result<(Self, SavedSession)> { info!("Resuming rollout from {path:?}"); let text = tokio::fs::read_to_string(path).await?; let mut lines = text.lines(); @@ -201,7 +216,12 @@ impl RolloutRecorder { .open(path)?; let (tx, rx) = mpsc::channel::(256); - tokio::task::spawn(rollout_writer(tokio::fs::File::from_std(file), rx, None)); + tokio::task::spawn(rollout_writer( + tokio::fs::File::from_std(file), + rx, + None, + cwd, + )); info!("Resumed rollout successfully from {path:?}"); Ok((Self { tx }, saved)) } @@ -270,15 +290,26 @@ fn create_log_file(config: &Config, session_id: Uuid) -> std::io::Result, - meta: Option, + mut meta: Option, + cwd: std::path::PathBuf, ) { - if let Some(meta) = meta { - if let Ok(json) = serde_json::to_string(&meta) { + // If we have a meta, collect git info asynchronously and write meta first + if let Some(session_meta) = meta.take() { + let git_info = collect_git_info(&cwd).await; + let session_meta_with_git = SessionMetaWithGit { + meta: session_meta, + git: git_info, + }; + + // Write the SessionMeta as the first item in the file + if let Ok(json) = serde_json::to_string(&session_meta_with_git) { let _ = file.write_all(json.as_bytes()).await; let _ = file.write_all(b"\n").await; let _ = file.flush().await; } } + + // Process rollout commands while let Some(cmd) = rx.recv().await { match cmd { RolloutCmd::AddItems(items) => { diff --git a/codex-rs/core/tests/cli_stream.rs b/codex-rs/core/tests/cli_stream.rs index 567279ebd0..4694ba85ed 100644 --- a/codex-rs/core/tests/cli_stream.rs +++ b/codex-rs/core/tests/cli_stream.rs @@ -329,6 +329,7 @@ async fn integration_creates_and_checks_session_file() { .env("OPENAI_API_KEY", "dummy") .env("CODEX_RS_SSE_FIXTURE", &fixture) .env("OPENAI_BASE_URL", "http://unused.local"); + let output2 = cmd2.output().unwrap(); assert!(output2.status.success(), "resume codex-cli run failed"); @@ -359,3 +360,125 @@ async fn integration_creates_and_checks_session_file() { "rollout missing resumed marker" ); } + +/// Integration test to verify git info is collected and recorded in session files. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn integration_git_info_unit_test() { + // This test verifies git info collection works independently + // without depending on the full CLI integration + + // 1. Create temp directory for git repo + let temp_dir = TempDir::new().unwrap(); + let git_repo = temp_dir.path().to_path_buf(); + + // 2. Initialize a git repository with some content + let init_output = std::process::Command::new("git") + .args(["init"]) + .current_dir(&git_repo) + .output() + .unwrap(); + assert!(init_output.status.success(), "git init failed"); + + // Configure git user (required for commits) + std::process::Command::new("git") + .args(["config", "user.name", "Integration Test"]) + .current_dir(&git_repo) + .output() + .unwrap(); + + std::process::Command::new("git") + .args(["config", "user.email", "test@example.com"]) + .current_dir(&git_repo) + .output() + .unwrap(); + + // Create a test file and commit it + let test_file = git_repo.join("test.txt"); + std::fs::write(&test_file, "integration test content").unwrap(); + + std::process::Command::new("git") + .args(["add", "."]) + .current_dir(&git_repo) + .output() + .unwrap(); + + let commit_output = std::process::Command::new("git") + .args(["commit", "-m", "Integration test commit"]) + .current_dir(&git_repo) + .output() + .unwrap(); + assert!(commit_output.status.success(), "git commit failed"); + + // Create a branch to test branch detection + std::process::Command::new("git") + .args(["checkout", "-b", "integration-test-branch"]) + .current_dir(&git_repo) + .output() + .unwrap(); + + // Add a remote to test repository URL detection + std::process::Command::new("git") + .args([ + "remote", + "add", + "origin", + "https://github.com/example/integration-test.git", + ]) + .current_dir(&git_repo) + .output() + .unwrap(); + + // 3. Test git info collection directly + let git_info = codex_core::git_info::collect_git_info(&git_repo).await; + + // 4. Verify git info is present and contains expected data + assert!(git_info.is_some(), "Git info should be collected"); + + let git_info = git_info.unwrap(); + + // Check that we have a commit hash + assert!( + git_info.commit_hash.is_some(), + "Git info should contain commit_hash" + ); + let commit_hash = git_info.commit_hash.as_ref().unwrap(); + assert_eq!(commit_hash.len(), 40, "Commit hash should be 40 characters"); + assert!( + commit_hash.chars().all(|c| c.is_ascii_hexdigit()), + "Commit hash should be hexadecimal" + ); + + // Check that we have the correct branch + assert!(git_info.branch.is_some(), "Git info should contain branch"); + let branch = git_info.branch.as_ref().unwrap(); + assert_eq!( + branch, "integration-test-branch", + "Branch should match what we created" + ); + + // Check that we have the repository URL + assert!( + git_info.repository_url.is_some(), + "Git info should contain repository_url" + ); + let repo_url = git_info.repository_url.as_ref().unwrap(); + assert_eq!( + repo_url, "https://github.com/example/integration-test.git", + "Repository URL should match what we configured" + ); + + println!("✅ Git info collection test passed!"); + println!(" Commit: {commit_hash}"); + println!(" Branch: {branch}"); + println!(" Repo: {repo_url}"); + + // 5. Test serialization to ensure it works in SessionMeta + let serialized = serde_json::to_string(&git_info).unwrap(); + let deserialized: codex_core::git_info::GitInfo = serde_json::from_str(&serialized).unwrap(); + + assert_eq!(git_info.commit_hash, deserialized.commit_hash); + assert_eq!(git_info.branch, deserialized.branch); + assert_eq!(git_info.repository_url, deserialized.repository_url); + + println!("✅ Git info serialization test passed!"); +} From 7af9cedbd7a257966f66a283fcdbb9256a2e0320 Mon Sep 17 00:00:00 2001 From: Michael Bolin Date: Thu, 24 Jul 2025 12:19:46 -0700 Subject: [PATCH 24/58] fix: create separate test_support crates to eliminate #[allow(dead_code)] (#1667) Because of a quirk of how implementation tests work in Rust, we had a number of `#[allow(dead_code)]` annotations that were misleading because the functions _were_ being used, just not by all integration tests in a `tests/` folder, so when compiling the test that did not use the function, clippy would complain that it was unused. This fixes things by create a "test_support" crate under the `tests/` folder that is imported as a dev dependency for the respective crate. --- codex-rs/Cargo.lock | 28 +++++++++++++++++++ codex-rs/core/Cargo.toml | 1 + codex-rs/core/tests/client.rs | 13 ++++----- codex-rs/core/tests/common/Cargo.toml | 13 +++++++++ .../tests/{test_support.rs => common/lib.rs} | 7 ----- codex-rs/core/tests/live_agent.rs | 3 +- codex-rs/core/tests/stream_no_completed.rs | 7 ++--- codex-rs/mcp-server/Cargo.toml | 1 + codex-rs/mcp-server/tests/codex_tool.rs | 12 ++++---- codex-rs/mcp-server/tests/common/Cargo.toml | 24 ++++++++++++++++ .../tests/common/{mod.rs => lib.rs} | 2 -- .../mcp-server/tests/common/mcp_process.rs | 8 ------ codex-rs/mcp-server/tests/common/responses.rs | 4 --- codex-rs/mcp-server/tests/interrupt.rs | 8 +++--- 14 files changed, 86 insertions(+), 45 deletions(-) create mode 100644 codex-rs/core/tests/common/Cargo.toml rename codex-rs/core/tests/{test_support.rs => common/lib.rs} (93%) create mode 100644 codex-rs/mcp-server/tests/common/Cargo.toml rename codex-rs/mcp-server/tests/common/{mod.rs => lib.rs} (86%) diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index d179a142f4..df1b0235a7 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -663,6 +663,7 @@ dependencies = [ "bytes", "codex-apply-patch", "codex-mcp-client", + "core_test_support", "dirs", "env-flags", "eventsource-stream", @@ -799,6 +800,7 @@ dependencies = [ "codex-core", "codex-linux-sandbox", "mcp-types", + "mcp_test_support", "pretty_assertions", "schemars 0.8.22", "serde", @@ -952,6 +954,16 @@ version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" +[[package]] +name = "core_test_support" +version = "0.0.0" +dependencies = [ + "codex-core", + "serde_json", + "tempfile", + "tokio", +] + [[package]] name = "cpufeatures" version = "0.2.17" @@ -2604,6 +2616,22 @@ dependencies = [ "serde_json", ] +[[package]] +name = "mcp_test_support" +version = "0.0.0" +dependencies = [ + "anyhow", + "assert_cmd", + "codex-mcp-server", + "mcp-types", + "pretty_assertions", + "serde_json", + "shlex", + "tempfile", + "tokio", + "wiremock", +] + [[package]] name = "memchr" version = "2.7.5" diff --git a/codex-rs/core/Cargo.toml b/codex-rs/core/Cargo.toml index 50049e9629..62e462bf97 100644 --- a/codex-rs/core/Cargo.toml +++ b/codex-rs/core/Cargo.toml @@ -62,6 +62,7 @@ openssl-sys = { version = "*", features = ["vendored"] } [dev-dependencies] assert_cmd = "2" +core_test_support = { path = "tests/common" } maplit = "1.0.2" predicates = "3" pretty_assertions = "1.4.1" diff --git a/codex-rs/core/tests/client.rs b/codex-rs/core/tests/client.rs index 5a6b6100eb..cb60fb216f 100644 --- a/codex-rs/core/tests/client.rs +++ b/codex-rs/core/tests/client.rs @@ -5,10 +5,10 @@ use codex_core::protocol::EventMsg; use codex_core::protocol::InputItem; use codex_core::protocol::Op; use codex_core::protocol::SessionConfiguredEvent; -mod test_support; +use core_test_support::load_default_config_for_test; +use core_test_support::load_sse_fixture_with_id; +use core_test_support::wait_for_event; use tempfile::TempDir; -use test_support::load_default_config_for_test; -use test_support::load_sse_fixture_with_id; use wiremock::Mock; use wiremock::MockServer; use wiremock::ResponseTemplate; @@ -84,14 +84,13 @@ async fn includes_session_id_and_model_headers_in_request() { .unwrap(); let EventMsg::SessionConfigured(SessionConfiguredEvent { session_id, .. }) = - test_support::wait_for_event(&codex, |ev| matches!(ev, EventMsg::SessionConfigured(_))) - .await + wait_for_event(&codex, |ev| matches!(ev, EventMsg::SessionConfigured(_))).await else { unreachable!() }; let current_session_id = Some(session_id.to_string()); - test_support::wait_for_event(&codex, |ev| matches!(ev, EventMsg::TaskComplete(_))).await; + wait_for_event(&codex, |ev| matches!(ev, EventMsg::TaskComplete(_))).await; // get request from the server let request = &server.received_requests().await.unwrap()[0]; @@ -160,7 +159,7 @@ async fn includes_base_instructions_override_in_request() { .await .unwrap(); - test_support::wait_for_event(&codex, |ev| matches!(ev, EventMsg::TaskComplete(_))).await; + wait_for_event(&codex, |ev| matches!(ev, EventMsg::TaskComplete(_))).await; let request = &server.received_requests().await.unwrap()[0]; let request_body = request.body_json::().unwrap(); diff --git a/codex-rs/core/tests/common/Cargo.toml b/codex-rs/core/tests/common/Cargo.toml new file mode 100644 index 0000000000..9cfd20cdb4 --- /dev/null +++ b/codex-rs/core/tests/common/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "core_test_support" +version = { workspace = true } +edition = "2024" + +[lib] +path = "lib.rs" + +[dependencies] +codex-core = { path = "../.." } +serde_json = "1" +tempfile = "3" +tokio = { version = "1", features = ["time"] } diff --git a/codex-rs/core/tests/test_support.rs b/codex-rs/core/tests/common/lib.rs similarity index 93% rename from codex-rs/core/tests/test_support.rs rename to codex-rs/core/tests/common/lib.rs index 83b8a14793..2577679f65 100644 --- a/codex-rs/core/tests/test_support.rs +++ b/codex-rs/core/tests/common/lib.rs @@ -1,9 +1,5 @@ #![allow(clippy::expect_used)] -// Helpers shared by the integration tests. These are located inside the -// `tests/` tree on purpose so they never become part of the public API surface -// of the `codex-core` crate. - use tempfile::TempDir; use codex_core::config::Config; @@ -30,7 +26,6 @@ pub fn load_default_config_for_test(codex_home: &TempDir) -> Config { /// with only a `type` field results in an event with no `data:` section. This /// makes it trivial to extend the fixtures as OpenAI adds new event kinds or /// fields. -#[allow(dead_code)] pub fn load_sse_fixture(path: impl AsRef) -> String { let events: Vec = serde_json::from_reader(std::fs::File::open(path).expect("read fixture")) @@ -55,7 +50,6 @@ pub fn load_sse_fixture(path: impl AsRef) -> String { /// fixture template with the supplied identifier before parsing. This lets a /// single JSON template be reused by multiple tests that each need a unique /// `response_id`. -#[allow(dead_code)] pub fn load_sse_fixture_with_id(path: impl AsRef, id: &str) -> String { let raw = std::fs::read_to_string(path).expect("read fixture template"); let replaced = raw.replace("__ID__", id); @@ -77,7 +71,6 @@ pub fn load_sse_fixture_with_id(path: impl AsRef, id: &str) -> .collect() } -#[allow(dead_code)] pub async fn wait_for_event( codex: &codex_core::Codex, mut predicate: F, diff --git a/codex-rs/core/tests/live_agent.rs b/codex-rs/core/tests/live_agent.rs index 0be6110571..9d81225b9f 100644 --- a/codex-rs/core/tests/live_agent.rs +++ b/codex-rs/core/tests/live_agent.rs @@ -26,9 +26,8 @@ use codex_core::protocol::ErrorEvent; use codex_core::protocol::EventMsg; use codex_core::protocol::InputItem; use codex_core::protocol::Op; -mod test_support; +use core_test_support::load_default_config_for_test; use tempfile::TempDir; -use test_support::load_default_config_for_test; use tokio::sync::Notify; use tokio::time::timeout; diff --git a/codex-rs/core/tests/stream_no_completed.rs b/codex-rs/core/tests/stream_no_completed.rs index 1a0455be7c..153330bf10 100644 --- a/codex-rs/core/tests/stream_no_completed.rs +++ b/codex-rs/core/tests/stream_no_completed.rs @@ -9,11 +9,10 @@ use codex_core::exec::CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR; use codex_core::protocol::EventMsg; use codex_core::protocol::InputItem; use codex_core::protocol::Op; -mod test_support; +use core_test_support::load_default_config_for_test; +use core_test_support::load_sse_fixture; +use core_test_support::load_sse_fixture_with_id; use tempfile::TempDir; -use test_support::load_default_config_for_test; -use test_support::load_sse_fixture; -use test_support::load_sse_fixture_with_id; use tokio::time::timeout; use wiremock::Mock; use wiremock::MockServer; diff --git a/codex-rs/mcp-server/Cargo.toml b/codex-rs/mcp-server/Cargo.toml index e524576a88..1088b92481 100644 --- a/codex-rs/mcp-server/Cargo.toml +++ b/codex-rs/mcp-server/Cargo.toml @@ -37,6 +37,7 @@ uuid = { version = "1", features = ["serde", "v4"] } [dev-dependencies] assert_cmd = "2" +mcp_test_support = { path = "tests/common" } pretty_assertions = "1.4.1" tempfile = "3" tokio-test = "0.4" diff --git a/codex-rs/mcp-server/tests/codex_tool.rs b/codex-rs/mcp-server/tests/codex_tool.rs index 0e31eea46e..0f06483f24 100644 --- a/codex-rs/mcp-server/tests/codex_tool.rs +++ b/codex-rs/mcp-server/tests/codex_tool.rs @@ -1,5 +1,3 @@ -mod common; - use std::collections::HashMap; use std::env; use std::path::Path; @@ -26,11 +24,11 @@ use tempfile::TempDir; use tokio::time::timeout; use wiremock::MockServer; -use crate::common::McpProcess; -use crate::common::create_apply_patch_sse_response; -use crate::common::create_final_assistant_message_sse_response; -use crate::common::create_mock_chat_completions_server; -use crate::common::create_shell_sse_response; +use mcp_test_support::McpProcess; +use mcp_test_support::create_apply_patch_sse_response; +use mcp_test_support::create_final_assistant_message_sse_response; +use mcp_test_support::create_mock_chat_completions_server; +use mcp_test_support::create_shell_sse_response; const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); diff --git a/codex-rs/mcp-server/tests/common/Cargo.toml b/codex-rs/mcp-server/tests/common/Cargo.toml new file mode 100644 index 0000000000..3aa246f154 --- /dev/null +++ b/codex-rs/mcp-server/tests/common/Cargo.toml @@ -0,0 +1,24 @@ +[package] +name = "mcp_test_support" +version = { workspace = true } +edition = "2024" + +[lib] +path = "lib.rs" + +[dependencies] +anyhow = "1" +assert_cmd = "2" +codex-mcp-server = { path = "../.." } +mcp-types = { path = "../../../mcp-types" } +pretty_assertions = "1.4.1" +serde_json = "1" +shlex = "1.3.0" +tempfile = "3" +tokio = { version = "1", features = [ + "io-std", + "macros", + "process", + "rt-multi-thread", +] } +wiremock = "0.6" diff --git a/codex-rs/mcp-server/tests/common/mod.rs b/codex-rs/mcp-server/tests/common/lib.rs similarity index 86% rename from codex-rs/mcp-server/tests/common/mod.rs rename to codex-rs/mcp-server/tests/common/lib.rs index a9593e399a..b338e2e8ce 100644 --- a/codex-rs/mcp-server/tests/common/mod.rs +++ b/codex-rs/mcp-server/tests/common/lib.rs @@ -4,8 +4,6 @@ mod responses; pub use mcp_process::McpProcess; pub use mock_model_server::create_mock_chat_completions_server; -#[allow(unused_imports)] pub use responses::create_apply_patch_sse_response; -#[allow(unused_imports)] pub use responses::create_final_assistant_message_sse_response; pub use responses::create_shell_sse_response; diff --git a/codex-rs/mcp-server/tests/common/mcp_process.rs b/codex-rs/mcp-server/tests/common/mcp_process.rs index 8f1f7a9e36..b27a96eb89 100644 --- a/codex-rs/mcp-server/tests/common/mcp_process.rs +++ b/codex-rs/mcp-server/tests/common/mcp_process.rs @@ -191,8 +191,6 @@ impl McpProcess { Ok(request_id) } - // allow dead code - #[allow(dead_code)] pub async fn send_response( &mut self, id: RequestId, @@ -220,8 +218,6 @@ impl McpProcess { let message = serde_json::from_str::(&line)?; Ok(message) } - // allow dead code - #[allow(dead_code)] pub async fn read_stream_until_request_message(&mut self) -> anyhow::Result { loop { let message = self.read_jsonrpc_message().await?; @@ -244,8 +240,6 @@ impl McpProcess { } } - // allow dead code - #[allow(dead_code)] pub async fn read_stream_until_response_message( &mut self, request_id: RequestId, @@ -312,8 +306,6 @@ impl McpProcess { } } - // allow dead code - #[allow(dead_code)] pub async fn send_notification( &mut self, method: &str, diff --git a/codex-rs/mcp-server/tests/common/responses.rs b/codex-rs/mcp-server/tests/common/responses.rs index f47952a5ac..9a827fb986 100644 --- a/codex-rs/mcp-server/tests/common/responses.rs +++ b/codex-rs/mcp-server/tests/common/responses.rs @@ -39,8 +39,6 @@ pub fn create_shell_sse_response( Ok(sse) } -// allow dead code -#[allow(dead_code)] pub fn create_final_assistant_message_sse_response(message: &str) -> anyhow::Result { let assistant_message = json!({ "choices": [ @@ -60,8 +58,6 @@ pub fn create_final_assistant_message_sse_response(message: &str) -> anyhow::Res Ok(sse) } -// allow dead code -#[allow(dead_code)] pub fn create_apply_patch_sse_response( patch_content: &str, call_id: &str, diff --git a/codex-rs/mcp-server/tests/interrupt.rs b/codex-rs/mcp-server/tests/interrupt.rs index 64cf8b477e..cd163ea06b 100644 --- a/codex-rs/mcp-server/tests/interrupt.rs +++ b/codex-rs/mcp-server/tests/interrupt.rs @@ -1,5 +1,5 @@ #![cfg(unix)] -mod common; +// Support code lives in the `mcp_test_support` crate under tests/common. use std::path::Path; @@ -11,9 +11,9 @@ use serde_json::json; use tempfile::TempDir; use tokio::time::timeout; -use crate::common::McpProcess; -use crate::common::create_mock_chat_completions_server; -use crate::common::create_shell_sse_response; +use mcp_test_support::McpProcess; +use mcp_test_support::create_mock_chat_completions_server; +use mcp_test_support::create_shell_sse_response; const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); From c9e2def49487585cfe6f8bb7b2be442e8c0b5e1b Mon Sep 17 00:00:00 2001 From: Michael Bolin Date: Thu, 24 Jul 2025 12:59:36 -0700 Subject: [PATCH 25/58] fix: add true,false,nl to the list of trusted commands (#1676) `nl` is a line-numbering tool that should be on the _trusted _ list, as there is nothing concerning on https://gtfobins.github.io/gtfobins/nl/ that would merit exclusion. `true` and `false` are also safe, though not particularly useful given how `is_known_safe_command()` works today, but that will change with https://github.com/openai/codex/pull/1668. --- codex-rs/core/src/is_safe_command.rs | 23 +++++++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/codex-rs/core/src/is_safe_command.rs b/codex-rs/core/src/is_safe_command.rs index 237123c581..493650a4b5 100644 --- a/codex-rs/core/src/is_safe_command.rs +++ b/codex-rs/core/src/is_safe_command.rs @@ -23,9 +23,23 @@ fn is_safe_to_call_with_exec(command: &[String]) -> bool { let cmd0 = command.first().map(String::as_str); match cmd0 { - Some("cat" | "cd" | "echo" | "grep" | "head" | "ls" | "pwd" | "tail" | "wc" | "which") => { + #[rustfmt::skip] + Some( + "cat" | + "cd" | + "echo" | + "false" | + "grep" | + "head" | + "ls" | + "nl" | + "pwd" | + "tail" | + "true" | + "wc" | + "which") => { true - } + }, Some("find") => { // Certain options to `find` can delete files, write to files, or @@ -232,6 +246,11 @@ mod tests { assert!(is_safe_to_call_with_exec(&vec_str(&[ "sed", "-n", "1,5p", "file.txt" ]))); + assert!(is_safe_to_call_with_exec(&vec_str(&[ + "nl", + "-nrz", + "Cargo.toml" + ]))); // Safe `find` command (no unsafe options). assert!(is_safe_to_call_with_exec(&vec_str(&[ From a1641743a8d43af1d0a75c252bab07fab8de57a7 Mon Sep 17 00:00:00 2001 From: Michael Bolin Date: Thu, 24 Jul 2025 14:13:30 -0700 Subject: [PATCH 26/58] feat: expand the set of commands that can be safely identified as "trusted" (#1668) This PR updates `is_known_safe_command()` to account for "safe operators" to expand the set of commands that can be run without approval. This concept existed in the TypeScript CLI, and we are [finally!] porting it to the Rust one: https://github.com/openai/codex/blob/c9e2def49487585cfe6f8bb7b2be442e8c0b5e1b/codex-cli/src/approvals.ts#L531-L541 The idea is that if we have `EXPR1 SAFE_OP EXPR2` and `EXPR1` and `EXPR2` are considered safe independently, then `EXPR1 SAFE_OP EXPR2` should be considered safe. Currently, `SAFE_OP` includes `&&`, `||`, `;`, and `|`. In the TypeScript implementation, we relied on https://www.npmjs.com/package/shell-quote to parse the string of Bash, as it could provide a "lightweight" parse tree, parsing `'beep || boop > /byte'` as: ``` [ 'beep', { op: '||' }, 'boop', { op: '>' }, '/byte' ] ``` Though in this PR, we introduce the use of https://crates.io/crates/tree-sitter-bash for parsing (which incidentally we were already using in [`codex-apply-patch`](https://github.com/openai/codex/blob/c9e2def49487585cfe6f8bb7b2be442e8c0b5e1b/codex-rs/apply-patch/Cargo.toml#L18)), which gives us a richer parse tree. (Incidentally, if you have never played with tree-sitter, try the [playground](https://tree-sitter.github.io/tree-sitter/7-playground.html) and select **Bash** from the dropdown to see how it parses various expressions.) As a concrete example, prior to this change, our implementation of `is_known_safe_command()` could verify things like: ``` ["bash", "-lc", "grep -R \"Cargo.toml\" -n"] ``` but not: ``` ["bash", "-lc", "grep -R \"Cargo.toml\" -n || true"] ``` With this change, the version with `|| true` is also accepted. Admittedly, this PR does not expand the safety check to support subshells, so it would reject, e.g. `bash -lc 'ls || (pwd && echo hi)'`, but that can be addressed in a subsequent PR. --- codex-rs/core/src/bash.rs | 219 +++++++++++++++++++++++++++ codex-rs/core/src/is_safe_command.rs | 205 +++++++++---------------- codex-rs/core/src/lib.rs | 1 + 3 files changed, 292 insertions(+), 133 deletions(-) create mode 100644 codex-rs/core/src/bash.rs diff --git a/codex-rs/core/src/bash.rs b/codex-rs/core/src/bash.rs new file mode 100644 index 0000000000..b9cd444356 --- /dev/null +++ b/codex-rs/core/src/bash.rs @@ -0,0 +1,219 @@ +use tree_sitter::Parser; +use tree_sitter::Tree; +use tree_sitter_bash::LANGUAGE as BASH; + +/// Parse the provided bash source using tree-sitter-bash, returning a Tree on +/// success or None if parsing failed. +pub fn try_parse_bash(bash_lc_arg: &str) -> Option { + let lang = BASH.into(); + let mut parser = Parser::new(); + #[expect(clippy::expect_used)] + parser.set_language(&lang).expect("load bash grammar"); + let old_tree: Option<&Tree> = None; + parser.parse(bash_lc_arg, old_tree) +} + +/// Parse a script which may contain multiple simple commands joined only by +/// the safe logical/pipe/sequencing operators: `&&`, `||`, `;`, `|`. +/// +/// Returns `Some(Vec)` if every command is a plain word‑only +/// command and the parse tree does not contain disallowed constructs +/// (parentheses, redirections, substitutions, control flow, etc.). Otherwise +/// returns `None`. +pub fn try_parse_word_only_commands_sequence(tree: &Tree, src: &str) -> Option>> { + if tree.root_node().has_error() { + return None; + } + + // List of allowed (named) node kinds for a "word only commands sequence". + // If we encounter a named node that is not in this list we reject. + const ALLOWED_KINDS: &[&str] = &[ + // top level containers + "program", + "list", + "pipeline", + // commands & words + "command", + "command_name", + "word", + "string", + "string_content", + "raw_string", + "number", + ]; + // Allow only safe punctuation / operator tokens; anything else causes reject. + const ALLOWED_PUNCT_TOKENS: &[&str] = &["&&", "||", ";", "|", "\"", "'"]; + + let root = tree.root_node(); + let mut cursor = root.walk(); + let mut stack = vec![root]; + let mut command_nodes = Vec::new(); + while let Some(node) = stack.pop() { + let kind = node.kind(); + if node.is_named() { + if !ALLOWED_KINDS.contains(&kind) { + return None; + } + if kind == "command" { + command_nodes.push(node); + } + } else { + // Reject any punctuation / operator tokens that are not explicitly allowed. + if kind.chars().any(|c| "&;|".contains(c)) && !ALLOWED_PUNCT_TOKENS.contains(&kind) { + return None; + } + if !(ALLOWED_PUNCT_TOKENS.contains(&kind) || kind.trim().is_empty()) { + // If it's a quote token or operator it's allowed above; we also allow whitespace tokens. + // Any other punctuation like parentheses, braces, redirects, backticks, etc are rejected. + return None; + } + } + for child in node.children(&mut cursor) { + stack.push(child); + } + } + + let mut commands = Vec::new(); + for node in command_nodes { + if let Some(words) = parse_plain_command_from_node(node, src) { + commands.push(words); + } else { + return None; + } + } + Some(commands) +} + +fn parse_plain_command_from_node(cmd: tree_sitter::Node, src: &str) -> Option> { + if cmd.kind() != "command" { + return None; + } + let mut words = Vec::new(); + let mut cursor = cmd.walk(); + for child in cmd.named_children(&mut cursor) { + match child.kind() { + "command_name" => { + let word_node = child.named_child(0)?; + if word_node.kind() != "word" { + return None; + } + words.push(word_node.utf8_text(src.as_bytes()).ok()?.to_owned()); + } + "word" | "number" => { + words.push(child.utf8_text(src.as_bytes()).ok()?.to_owned()); + } + "string" => { + if child.child_count() == 3 + && child.child(0)?.kind() == "\"" + && child.child(1)?.kind() == "string_content" + && child.child(2)?.kind() == "\"" + { + words.push(child.child(1)?.utf8_text(src.as_bytes()).ok()?.to_owned()); + } else { + return None; + } + } + "raw_string" => { + let raw_string = child.utf8_text(src.as_bytes()).ok()?; + let stripped = raw_string + .strip_prefix('\'') + .and_then(|s| s.strip_suffix('\'')); + if let Some(s) = stripped { + words.push(s.to_owned()); + } else { + return None; + } + } + _ => return None, + } + } + Some(words) +} + +#[cfg(test)] +mod tests { + #![allow(clippy::unwrap_used)] + use super::*; + + fn parse_seq(src: &str) -> Option>> { + let tree = try_parse_bash(src)?; + try_parse_word_only_commands_sequence(&tree, src) + } + + #[test] + fn accepts_single_simple_command() { + let cmds = parse_seq("ls -1").unwrap(); + assert_eq!(cmds, vec![vec!["ls".to_string(), "-1".to_string()]]); + } + + #[test] + fn accepts_multiple_commands_with_allowed_operators() { + let src = "ls && pwd; echo 'hi there' | wc -l"; + let cmds = parse_seq(src).unwrap(); + let expected: Vec> = vec![ + vec!["wc".to_string(), "-l".to_string()], + vec!["echo".to_string(), "hi there".to_string()], + vec!["pwd".to_string()], + vec!["ls".to_string()], + ]; + assert_eq!(cmds, expected); + } + + #[test] + fn extracts_double_and_single_quoted_strings() { + let cmds = parse_seq("echo \"hello world\"").unwrap(); + assert_eq!( + cmds, + vec![vec!["echo".to_string(), "hello world".to_string()]] + ); + + let cmds2 = parse_seq("echo 'hi there'").unwrap(); + assert_eq!( + cmds2, + vec![vec!["echo".to_string(), "hi there".to_string()]] + ); + } + + #[test] + fn accepts_numbers_as_words() { + let cmds = parse_seq("echo 123 456").unwrap(); + assert_eq!( + cmds, + vec![vec![ + "echo".to_string(), + "123".to_string(), + "456".to_string() + ]] + ); + } + + #[test] + fn rejects_parentheses_and_subshells() { + assert!(parse_seq("(ls)").is_none()); + assert!(parse_seq("ls || (pwd && echo hi)").is_none()); + } + + #[test] + fn rejects_redirections_and_unsupported_operators() { + assert!(parse_seq("ls > out.txt").is_none()); + assert!(parse_seq("echo hi & echo bye").is_none()); + } + + #[test] + fn rejects_command_and_process_substitutions_and_expansions() { + assert!(parse_seq("echo $(pwd)").is_none()); + assert!(parse_seq("echo `pwd`").is_none()); + assert!(parse_seq("echo $HOME").is_none()); + assert!(parse_seq("echo \"hi $USER\"").is_none()); + } + + #[test] + fn rejects_variable_assignment_prefix() { + assert!(parse_seq("FOO=bar ls").is_none()); + } + + #[test] + fn rejects_trailing_operator_parse_error() { + assert!(parse_seq("ls &&").is_none()); + } +} diff --git a/codex-rs/core/src/is_safe_command.rs b/codex-rs/core/src/is_safe_command.rs index 493650a4b5..f5f453f8d8 100644 --- a/codex-rs/core/src/is_safe_command.rs +++ b/codex-rs/core/src/is_safe_command.rs @@ -1,22 +1,34 @@ -use tree_sitter::Parser; -use tree_sitter::Tree; -use tree_sitter_bash::LANGUAGE as BASH; +use crate::bash::try_parse_bash; +use crate::bash::try_parse_word_only_commands_sequence; pub fn is_known_safe_command(command: &[String]) -> bool { if is_safe_to_call_with_exec(command) { return true; } - // TODO(mbolin): Also support safe commands that are piped together such - // as `cat foo | wc -l`. - matches!( - command, - [bash, flag, script] - if bash == "bash" - && flag == "-lc" - && try_parse_bash(script).and_then(|tree| - try_parse_single_word_only_command(&tree, script)).is_some_and(|parsed_bash_command| is_safe_to_call_with_exec(&parsed_bash_command)) - ) + // Support `bash -lc "..."` where the script consists solely of one or + // more "plain" commands (only bare words / quoted strings) combined with + // a conservative allow‑list of shell operators that themselves do not + // introduce side effects ( "&&", "||", ";", and "|" ). If every + // individual command in the script is itself a known‑safe command, then + // the composite expression is considered safe. + if let [bash, flag, script] = command { + if bash == "bash" && flag == "-lc" { + if let Some(tree) = try_parse_bash(script) { + if let Some(all_commands) = try_parse_word_only_commands_sequence(&tree, script) { + if !all_commands.is_empty() + && all_commands + .iter() + .all(|cmd| is_safe_to_call_with_exec(cmd)) + { + return true; + } + } + } + } + } + + false } fn is_safe_to_call_with_exec(command: &[String]) -> bool { @@ -109,90 +121,7 @@ fn is_safe_to_call_with_exec(command: &[String]) -> bool { } } -fn try_parse_bash(bash_lc_arg: &str) -> Option { - let lang = BASH.into(); - let mut parser = Parser::new(); - #[expect(clippy::expect_used)] - parser.set_language(&lang).expect("load bash grammar"); - - let old_tree: Option<&Tree> = None; - parser.parse(bash_lc_arg, old_tree) -} - -/// If `tree` represents a single Bash command whose name and every argument is -/// an ordinary `word`, return those words in order; otherwise, return `None`. -/// -/// `src` must be the exact source string that was parsed into `tree`, so we can -/// extract the text for every node. -pub fn try_parse_single_word_only_command(tree: &Tree, src: &str) -> Option> { - // Any parse error is an immediate rejection. - if tree.root_node().has_error() { - return None; - } - - // (program …) with exactly one statement - let root = tree.root_node(); - if root.kind() != "program" || root.named_child_count() != 1 { - return None; - } - - let cmd = root.named_child(0)?; // (command …) - if cmd.kind() != "command" { - return None; - } - - let mut words = Vec::new(); - let mut cursor = cmd.walk(); - - for child in cmd.named_children(&mut cursor) { - match child.kind() { - // The command name node wraps one `word` child. - "command_name" => { - let word_node = child.named_child(0)?; // make sure it's only a word - if word_node.kind() != "word" { - return None; - } - words.push(word_node.utf8_text(src.as_bytes()).ok()?.to_owned()); - } - // Positional‑argument word (allowed). - "word" | "number" => { - words.push(child.utf8_text(src.as_bytes()).ok()?.to_owned()); - } - "string" => { - if child.child_count() == 3 - && child.child(0)?.kind() == "\"" - && child.child(1)?.kind() == "string_content" - && child.child(2)?.kind() == "\"" - { - words.push(child.child(1)?.utf8_text(src.as_bytes()).ok()?.to_owned()); - } else { - // Anything else means the command is *not* plain words. - return None; - } - } - "concatenation" => { - // TODO: Consider things like `'ab\'a'`. - return None; - } - "raw_string" => { - // Raw string is a single word, but we need to strip the quotes. - let raw_string = child.utf8_text(src.as_bytes()).ok()?; - let stripped = raw_string - .strip_prefix('\'') - .and_then(|s| s.strip_suffix('\'')); - if let Some(stripped) = stripped { - words.push(stripped.to_owned()); - } else { - return None; - } - } - // Anything else means the command is *not* plain words. - _ => return None, - } - } - - Some(words) -} +// (bash parsing helpers implemented in crate::bash) /* ---------------------------------------------------------- Example @@ -230,6 +159,7 @@ fn is_valid_sed_n_arg(arg: Option<&str>) -> bool { _ => false, } } + #[cfg(test)] mod tests { #![allow(clippy::unwrap_used)] @@ -353,6 +283,30 @@ mod tests { ]))); } + #[test] + fn bash_lc_safe_examples_with_operators() { + assert!(is_known_safe_command(&vec_str(&[ + "bash", + "-lc", + "grep -R \"Cargo.toml\" -n || true" + ]))); + assert!(is_known_safe_command(&vec_str(&[ + "bash", + "-lc", + "ls && pwd" + ]))); + assert!(is_known_safe_command(&vec_str(&[ + "bash", + "-lc", + "echo 'hi' ; ls" + ]))); + assert!(is_known_safe_command(&vec_str(&[ + "bash", + "-lc", + "ls | wc -l" + ]))); + } + #[test] fn bash_lc_unsafe_examples() { assert!( @@ -366,44 +320,29 @@ mod tests { assert!( !is_known_safe_command(&vec_str(&["bash", "-lc", "find . -name file.txt -delete"])), - "Unsafe find option should not be auto‑approved." - ); - } - - #[test] - fn test_try_parse_single_word_only_command() { - let script_with_single_quoted_string = "sed -n '1,5p' file.txt"; - let parsed_words = try_parse_bash(script_with_single_quoted_string) - .and_then(|tree| { - try_parse_single_word_only_command(&tree, script_with_single_quoted_string) - }) - .unwrap(); - assert_eq!( - vec![ - "sed".to_string(), - "-n".to_string(), - // Ensure the single quotes are properly removed. - "1,5p".to_string(), - "file.txt".to_string() - ], - parsed_words, + "Unsafe find option should not be auto-approved." ); - let script_with_number_arg = "ls -1"; - let parsed_words = try_parse_bash(script_with_number_arg) - .and_then(|tree| try_parse_single_word_only_command(&tree, script_with_number_arg)) - .unwrap(); - assert_eq!(vec!["ls", "-1"], parsed_words,); + // Disallowed because of unsafe command in sequence. + assert!( + !is_known_safe_command(&vec_str(&["bash", "-lc", "ls && rm -rf /"])), + "Sequence containing unsafe command must be rejected" + ); - let script_with_double_quoted_string_with_no_funny_stuff_arg = "grep -R \"Cargo.toml\" -n"; - let parsed_words = try_parse_bash(script_with_double_quoted_string_with_no_funny_stuff_arg) - .and_then(|tree| { - try_parse_single_word_only_command( - &tree, - script_with_double_quoted_string_with_no_funny_stuff_arg, - ) - }) - .unwrap(); - assert_eq!(vec!["grep", "-R", "Cargo.toml", "-n"], parsed_words); + // Disallowed because of parentheses / subshell. + assert!( + !is_known_safe_command(&vec_str(&["bash", "-lc", "(ls)"])), + "Parentheses (subshell) are not provably safe with the current parser" + ); + assert!( + !is_known_safe_command(&vec_str(&["bash", "-lc", "ls || (pwd && echo hi)"])), + "Nested parentheses are not provably safe with the current parser" + ); + + // Disallowed redirection. + assert!( + !is_known_safe_command(&vec_str(&["bash", "-lc", "ls > out.txt"])), + "> redirection should be rejected" + ); } } diff --git a/codex-rs/core/src/lib.rs b/codex-rs/core/src/lib.rs index 4e69e94b55..12321e0abc 100644 --- a/codex-rs/core/src/lib.rs +++ b/codex-rs/core/src/lib.rs @@ -5,6 +5,7 @@ // the TUI or the tracing stack). #![deny(clippy::print_stdout, clippy::print_stderr)] +mod bash; mod chat_completions; mod client; mod client_common; From 508abbe99074a7d6126c91b432e0c81639de329b Mon Sep 17 00:00:00 2001 From: Pavel Bezglasny Date: Thu, 24 Jul 2025 23:17:57 +0200 Subject: [PATCH 27/58] Update render name in tui for approval_policy to match with config values (#1675) Currently, codex on start shows the value for the approval policy as name of [AskForApproval](https://github.com/openai/codex/blob/2437a8d17a0cf972d1a6e7f303d469b6e2f57eae/codex-rs/core/src/protocol.rs#L128) enum, which differs from [approval_policy](https://github.com/openai/codex/blob/2437a8d17a0cf972d1a6e7f303d469b6e2f57eae/codex-rs/config.md#approval_policy) config values. E.g. "untrusted" becomes "UnlessTrusted", "on-failure" -> "OnFailure", "never" -> "Never". This PR changes render names of the approval policy to match with configuration values. --- codex-rs/core/src/protocol.rs | 5 ++++- codex-rs/exec/src/event_processor.rs | 2 +- codex-rs/tui/src/history_cell.rs | 2 +- 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/codex-rs/core/src/protocol.rs b/codex-rs/core/src/protocol.rs index 0c375e455d..3111b42292 100644 --- a/codex-rs/core/src/protocol.rs +++ b/codex-rs/core/src/protocol.rs @@ -11,6 +11,7 @@ use std::str::FromStr; use mcp_types::CallToolResult; use serde::Deserialize; use serde::Serialize; +use strum_macros::Display; use uuid::Uuid; use crate::config_types::ReasoningEffort as ReasoningEffortConfig; @@ -123,14 +124,16 @@ pub enum Op { /// Determines the conditions under which the user is consulted to approve /// running the command proposed by Codex. -#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, Serialize, Deserialize, Display)] #[serde(rename_all = "kebab-case")] +#[strum(serialize_all = "kebab-case")] pub enum AskForApproval { /// Under this policy, only "known safe" commands—as determined by /// `is_safe_command()`—that **only read files** are auto‑approved. /// Everything else will ask the user to approve. #[default] #[serde(rename = "untrusted")] + #[strum(serialize = "untrusted")] UnlessTrusted, /// *All* commands are auto‑approved, but they are expected to run inside a diff --git a/codex-rs/exec/src/event_processor.rs b/codex-rs/exec/src/event_processor.rs index a7edb96af2..741f89d7cb 100644 --- a/codex-rs/exec/src/event_processor.rs +++ b/codex-rs/exec/src/event_processor.rs @@ -25,7 +25,7 @@ pub(crate) fn create_config_summary_entries(config: &Config) -> Vec<(&'static st ("workdir", config.cwd.display().to_string()), ("model", config.model.clone()), ("provider", config.model_provider_id.clone()), - ("approval", format!("{:?}", config.approval_policy)), + ("approval", config.approval_policy.to_string()), ("sandbox", summarize_sandbox_policy(&config.sandbox_policy)), ]; if config.model_provider.wire_api == WireApi::Responses diff --git a/codex-rs/tui/src/history_cell.rs b/codex-rs/tui/src/history_cell.rs index b481313405..13bec71b46 100644 --- a/codex-rs/tui/src/history_cell.rs +++ b/codex-rs/tui/src/history_cell.rs @@ -156,7 +156,7 @@ impl HistoryCell { ("workdir", config.cwd.display().to_string()), ("model", config.model.clone()), ("provider", config.model_provider_id.clone()), - ("approval", format!("{:?}", config.approval_policy)), + ("approval", config.approval_policy.to_string()), ("sandbox", summarize_sandbox_policy(&config.sandbox_policy)), ]; if config.model_provider.wire_api == WireApi::Responses From 480e82b00daaf038afdd2e3304ee3b801f3661cf Mon Sep 17 00:00:00 2001 From: easong-openai Date: Fri, 25 Jul 2025 01:56:40 -0700 Subject: [PATCH 28/58] Easily Selectable History (#1672) This update replaces the previous ratatui history widget with an append-only log so that the terminal can handle text selection and scrolling. It also disables streaming responses, which we'll do our best to bring back in a later PR. It also adds a small summary of token use after the TUI exits. --- codex-rs/Cargo.lock | 1 + codex-rs/cli/src/main.rs | 3 +- codex-rs/config.md | 11 +- codex-rs/core/src/config_types.rs | 15 +- codex-rs/core/src/protocol.rs | 33 +++- codex-rs/tui/Cargo.toml | 1 + codex-rs/tui/src/app.rs | 25 +-- codex-rs/tui/src/app_event.rs | 3 + .../src/bottom_pane/approval_modal_view.rs | 4 - .../tui/src/bottom_pane/bottom_pane_view.rs | 3 - codex-rs/tui/src/bottom_pane/chat_composer.rs | 16 -- codex-rs/tui/src/bottom_pane/mod.rs | 12 -- .../src/bottom_pane/status_indicator_view.rs | 11 +- codex-rs/tui/src/chatwidget.rs | 113 ++++++----- .../tui/src/conversation_history_widget.rs | 70 +------ codex-rs/tui/src/history_cell.rs | 24 +++ codex-rs/tui/src/insert_history.rs | 178 ++++++++++++++++++ codex-rs/tui/src/lib.rs | 42 ++--- codex-rs/tui/src/main.rs | 3 +- codex-rs/tui/src/mouse_capture.rs | 69 ------- codex-rs/tui/src/slash_command.rs | 4 - codex-rs/tui/src/status_indicator_widget.rs | 13 +- codex-rs/tui/src/tui.rs | 37 ++-- codex-rs/tui/src/user_approval_widget.rs | 56 +++--- 24 files changed, 394 insertions(+), 353 deletions(-) create mode 100644 codex-rs/tui/src/insert_history.rs delete mode 100644 codex-rs/tui/src/mouse_capture.rs diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index df1b0235a7..7c1b075011 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -852,6 +852,7 @@ dependencies = [ "tui-markdown", "tui-textarea", "unicode-segmentation", + "unicode-width 0.1.14", "uuid", ] diff --git a/codex-rs/cli/src/main.rs b/codex-rs/cli/src/main.rs index e397b0ca6a..7916a7dc79 100644 --- a/codex-rs/cli/src/main.rs +++ b/codex-rs/cli/src/main.rs @@ -105,7 +105,8 @@ async fn cli_main(codex_linux_sandbox_exe: Option) -> anyhow::Result<() None => { let mut tui_cli = cli.interactive; prepend_config_flags(&mut tui_cli.config_overrides, cli.config_overrides); - codex_tui::run_main(tui_cli, codex_linux_sandbox_exe)?; + let usage = codex_tui::run_main(tui_cli, codex_linux_sandbox_exe)?; + println!("{}", codex_core::protocol::FinalOutput::from(usage)); } Some(Subcommand::Exec(mut exec_cli)) => { prepend_config_flags(&mut exec_cli.config_overrides, cli.config_overrides); diff --git a/codex-rs/config.md b/codex-rs/config.md index 3d38ded1a5..c45d81180d 100644 --- a/codex-rs/config.md +++ b/codex-rs/config.md @@ -498,14 +498,5 @@ Options that are specific to the TUI. ```toml [tui] -# This will make it so that Codex does not try to process mouse events, which -# means your Terminal's native drag-to-text to text selection and copy/paste -# should work. The tradeoff is that Codex will not receive any mouse events, so -# it will not be possible to use the mouse to scroll conversation history. -# -# Note that most terminals support holding down a modifier key when using the -# mouse to support text selection. For example, even if Codex mouse capture is -# enabled (i.e., this is set to `false`), you can still hold down alt while -# dragging the mouse to select text. -disable_mouse_capture = true # defaults to `false` +# More to come here ``` diff --git a/codex-rs/core/src/config_types.rs b/codex-rs/core/src/config_types.rs index 83fe613c86..cba5dcfbb2 100644 --- a/codex-rs/core/src/config_types.rs +++ b/codex-rs/core/src/config_types.rs @@ -76,20 +76,7 @@ pub enum HistoryPersistence { /// Collection of settings that are specific to the TUI. #[derive(Deserialize, Debug, Clone, PartialEq, Default)] -pub struct Tui { - /// By default, mouse capture is enabled in the TUI so that it is possible - /// to scroll the conversation history with a mouse. This comes at the cost - /// of not being able to use the mouse to select text in the TUI. - /// (Most terminals support a modifier key to allow this. For example, - /// text selection works in iTerm if you hold down the `Option` key while - /// clicking and dragging.) - /// - /// Setting this option to `true` disables mouse capture, so scrolling with - /// the mouse is not possible, though the keyboard shortcuts e.g. `b` and - /// `space` still work. This allows the user to select text in the TUI - /// using the mouse without needing to hold down a modifier key. - pub disable_mouse_capture: bool, -} +pub struct Tui {} #[derive(Deserialize, Debug, Clone, Copy, PartialEq, Default)] #[serde(rename_all = "kebab-case")] diff --git a/codex-rs/core/src/protocol.rs b/codex-rs/core/src/protocol.rs index 3111b42292..cf6e8b5191 100644 --- a/codex-rs/core/src/protocol.rs +++ b/codex-rs/core/src/protocol.rs @@ -4,9 +4,10 @@ //! between user and agent. use std::collections::HashMap; +use std::fmt; use std::path::Path; use std::path::PathBuf; -use std::str::FromStr; +use std::str::FromStr; // Added for FinalOutput Display implementation use mcp_types::CallToolResult; use serde::Deserialize; @@ -358,6 +359,36 @@ pub struct TokenUsage { pub total_tokens: u64, } +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct FinalOutput { + pub token_usage: TokenUsage, +} + +impl From for FinalOutput { + fn from(token_usage: TokenUsage) -> Self { + Self { token_usage } + } +} + +impl fmt::Display for FinalOutput { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let u = &self.token_usage; + write!( + f, + "Token usage: total={} input={}{} output={}{}", + u.total_tokens, + u.input_tokens, + u.cached_input_tokens + .map(|c| format!(" (cached {c})")) + .unwrap_or_default(), + u.output_tokens, + u.reasoning_output_tokens + .map(|r| format!(" (reasoning {r})")) + .unwrap_or_default() + ) + } +} + #[derive(Debug, Clone, Deserialize, Serialize)] pub struct AgentMessageEvent { pub message: String, diff --git a/codex-rs/tui/Cargo.toml b/codex-rs/tui/Cargo.toml index b2f2b9b653..9d73e3b386 100644 --- a/codex-rs/tui/Cargo.toml +++ b/codex-rs/tui/Cargo.toml @@ -58,6 +58,7 @@ tui-input = "0.14.0" tui-markdown = "0.3.3" tui-textarea = "0.7.0" unicode-segmentation = "1.12.0" +unicode-width = "0.1" uuid = "1" [dev-dependencies] diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 377b5d6f0b..ee14e7bb37 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -6,7 +6,6 @@ use crate::get_git_diff::get_git_diff; use crate::git_warning_screen::GitWarningOutcome; use crate::git_warning_screen::GitWarningScreen; use crate::login_screen::LoginScreen; -use crate::mouse_capture::MouseCapture; use crate::scroll_event_helper::ScrollEventHelper; use crate::slash_command::SlashCommand; use crate::tui; @@ -197,17 +196,17 @@ impl App<'_> { }); } - pub(crate) fn run( - &mut self, - terminal: &mut tui::Tui, - mouse_capture: &mut MouseCapture, - ) -> Result<()> { + pub(crate) fn run(&mut self, terminal: &mut tui::Tui) -> Result<()> { // Insert an event to trigger the first render. let app_event_tx = self.app_event_tx.clone(); app_event_tx.send(AppEvent::RequestRedraw); while let Ok(event) = self.app_event_rx.recv() { match event { + AppEvent::InsertHistory(lines) => { + crate::insert_history::insert_history_lines(terminal, lines); + self.app_event_tx.send(AppEvent::RequestRedraw); + } AppEvent::RequestRedraw => { self.schedule_redraw(); } @@ -287,11 +286,6 @@ impl App<'_> { self.app_state = AppState::Chat { widget: new_widget }; self.app_event_tx.send(AppEvent::RequestRedraw); } - SlashCommand::ToggleMouseMode => { - if let Err(e) = mouse_capture.toggle() { - tracing::error!("Failed to toggle mouse mode: {e}"); - } - } SlashCommand::Quit => { break; } @@ -332,6 +326,15 @@ impl App<'_> { Ok(()) } + pub(crate) fn token_usage(&self) -> codex_core::protocol::TokenUsage { + match &self.app_state { + AppState::Chat { widget } => widget.token_usage().clone(), + AppState::Login { .. } | AppState::GitWarning { .. } => { + codex_core::protocol::TokenUsage::default() + } + } + } + fn draw_next_frame(&mut self, terminal: &mut tui::Tui) -> Result<()> { // TODO: add a throttle to avoid redrawing too often diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs index 3aaa789760..a1f304fe42 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -1,6 +1,7 @@ use codex_core::protocol::Event; use codex_file_search::FileMatch; use crossterm::event::KeyEvent; +use ratatui::text::Line; use crate::slash_command::SlashCommand; @@ -49,4 +50,6 @@ pub(crate) enum AppEvent { query: String, matches: Vec, }, + + InsertHistory(Vec>), } diff --git a/codex-rs/tui/src/bottom_pane/approval_modal_view.rs b/codex-rs/tui/src/bottom_pane/approval_modal_view.rs index ca33047b1f..ba5b07b93c 100644 --- a/codex-rs/tui/src/bottom_pane/approval_modal_view.rs +++ b/codex-rs/tui/src/bottom_pane/approval_modal_view.rs @@ -50,10 +50,6 @@ impl<'a> BottomPaneView<'a> for ApprovalModalView<'a> { self.current.is_complete() && self.queue.is_empty() } - fn calculate_required_height(&self, area: &Rect) -> u16 { - self.current.get_height(area) - } - fn render(&self, area: Rect, buf: &mut Buffer) { (&self.current).render_ref(area, buf); } diff --git a/codex-rs/tui/src/bottom_pane/bottom_pane_view.rs b/codex-rs/tui/src/bottom_pane/bottom_pane_view.rs index 6abf5399f5..677d6db95b 100644 --- a/codex-rs/tui/src/bottom_pane/bottom_pane_view.rs +++ b/codex-rs/tui/src/bottom_pane/bottom_pane_view.rs @@ -22,9 +22,6 @@ pub(crate) trait BottomPaneView<'a> { false } - /// Height required to render the view. - fn calculate_required_height(&self, area: &Rect) -> u16; - /// Render the view: this will be displayed in place of the composer. fn render(&self, area: Rect, buf: &mut Buffer); diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index b49bce4046..bdfb6a23e2 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -22,11 +22,6 @@ use crate::app_event::AppEvent; use crate::app_event_sender::AppEventSender; use codex_file_search::FileMatch; -/// Minimum number of visible text rows inside the textarea. -const MIN_TEXTAREA_ROWS: usize = 1; -/// Rows consumed by the border. -const BORDER_LINES: u16 = 2; - const BASE_PLACEHOLDER_TEXT: &str = "send a message"; /// If the pasted content exceeds this number of characters, replace it with a /// placeholder in the UI. @@ -609,17 +604,6 @@ impl ChatComposer<'_> { self.dismissed_file_popup_token = None; } - pub fn calculate_required_height(&self, area: &Rect) -> u16 { - let rows = self.textarea.lines().len().max(MIN_TEXTAREA_ROWS); - let num_popup_rows = match &self.active_popup { - ActivePopup::Command(popup) => popup.calculate_required_height(area), - ActivePopup::File(popup) => popup.calculate_required_height(area), - ActivePopup::None => 0, - }; - - rows as u16 + BORDER_LINES + num_popup_rows - } - fn update_border(&mut self, has_focus: bool) { struct BlockState { right_title: Line<'static>, diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index 2a91655cc5..ebec534f21 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -65,10 +65,8 @@ impl BottomPane<'_> { if !view.is_complete() { self.active_view = Some(view); } else if self.is_task_running { - let height = self.composer.calculate_required_height(&Rect::default()); self.active_view = Some(Box::new(StatusIndicatorView::new( self.app_event_tx.clone(), - height, ))); } self.request_redraw(); @@ -138,10 +136,8 @@ impl BottomPane<'_> { match (running, self.active_view.is_some()) { (true, false) => { // Show status indicator overlay. - let height = self.composer.calculate_required_height(&Rect::default()); self.active_view = Some(Box::new(StatusIndicatorView::new( self.app_event_tx.clone(), - height, ))); self.request_redraw(); } @@ -203,14 +199,6 @@ impl BottomPane<'_> { } /// Height (terminal rows) required by the current bottom pane. - pub fn calculate_required_height(&self, area: &Rect) -> u16 { - if let Some(view) = &self.active_view { - view.calculate_required_height(area) - } else { - self.composer.calculate_required_height(area) - } - } - pub(crate) fn request_redraw(&self) { self.app_event_tx.send(AppEvent::RequestRedraw) } diff --git a/codex-rs/tui/src/bottom_pane/status_indicator_view.rs b/codex-rs/tui/src/bottom_pane/status_indicator_view.rs index de46ac2709..f8c06ec5e5 100644 --- a/codex-rs/tui/src/bottom_pane/status_indicator_view.rs +++ b/codex-rs/tui/src/bottom_pane/status_indicator_view.rs @@ -1,5 +1,4 @@ use ratatui::buffer::Buffer; -use ratatui::layout::Rect; use ratatui::widgets::WidgetRef; use crate::app_event_sender::AppEventSender; @@ -13,9 +12,9 @@ pub(crate) struct StatusIndicatorView { } impl StatusIndicatorView { - pub fn new(app_event_tx: AppEventSender, height: u16) -> Self { + pub fn new(app_event_tx: AppEventSender) -> Self { Self { - view: StatusIndicatorWidget::new(app_event_tx, height), + view: StatusIndicatorWidget::new(app_event_tx), } } @@ -34,11 +33,7 @@ impl BottomPaneView<'_> for StatusIndicatorView { true } - fn calculate_required_height(&self, _area: &Rect) -> u16 { - self.view.get_height() - } - - fn render(&self, area: Rect, buf: &mut Buffer) { + fn render(&self, area: ratatui::layout::Rect, buf: &mut Buffer) { self.view.render_ref(area, buf); } } diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 081a406f29..6744707319 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -23,9 +23,6 @@ use codex_core::protocol::TaskCompleteEvent; use codex_core::protocol::TokenUsage; use crossterm::event::KeyEvent; use ratatui::buffer::Buffer; -use ratatui::layout::Constraint; -use ratatui::layout::Direction; -use ratatui::layout::Layout; use ratatui::layout::Rect; use ratatui::widgets::Widget; use ratatui::widgets::WidgetRef; @@ -52,6 +49,9 @@ pub(crate) struct ChatWidget<'a> { initial_user_message: Option, token_usage: TokenUsage, reasoning_buffer: String, + // Buffer for streaming assistant answer text; we do not surface partial + // We wait for the final AgentMessage event and then emit the full text + // at once into scrollback so the history contains a single message. answer_buffer: String, } @@ -187,6 +187,13 @@ impl ChatWidget<'_> { } } + /// Emits the last entry's plain lines from conversation_history, if any. + fn emit_last_history_entry(&mut self) { + if let Some(lines) = self.conversation_history.last_entry_plain_lines() { + self.app_event_tx.send(AppEvent::InsertHistory(lines)); + } + } + fn submit_user_message(&mut self, user_message: UserMessage) { let UserMessage { text, image_paths } = user_message; let mut items: Vec = Vec::new(); @@ -220,7 +227,8 @@ impl ChatWidget<'_> { // Only show text portion in conversation history for now. if !text.is_empty() { - self.conversation_history.add_user_message(text); + self.conversation_history.add_user_message(text.clone()); + self.emit_last_history_entry(); } self.conversation_history.scroll_to_bottom(); } @@ -232,6 +240,10 @@ impl ChatWidget<'_> { // Record session information at the top of the conversation. self.conversation_history .add_session_info(&self.config, event.clone()); + // Immediately surface the session banner / settings summary in + // scrollback so the user can review configuration (model, + // sandbox, approvals, etc.) before interacting. + self.emit_last_history_entry(); // Forward history metadata to the bottom pane so the chat // composer can navigate through past messages. @@ -247,50 +259,50 @@ impl ChatWidget<'_> { self.request_redraw(); } EventMsg::AgentMessage(AgentMessageEvent { message }) => { - // if the answer buffer is empty, this means we haven't received any - // delta. Thus, we need to print the message as a new answer. - if self.answer_buffer.is_empty() { - self.conversation_history - .add_agent_message(&self.config, message); + // Final assistant answer. Prefer the fully provided message + // from the event; if it is empty fall back to any accumulated + // delta buffer (some providers may only stream deltas and send + // an empty final message). + let full = if message.is_empty() { + std::mem::take(&mut self.answer_buffer) } else { + self.answer_buffer.clear(); + message + }; + if !full.is_empty() { self.conversation_history - .replace_prev_agent_message(&self.config, message); + .add_agent_message(&self.config, full); + self.emit_last_history_entry(); } - self.answer_buffer.clear(); self.request_redraw(); } EventMsg::AgentMessageDelta(AgentMessageDeltaEvent { delta }) => { - if self.answer_buffer.is_empty() { - self.conversation_history - .add_agent_message(&self.config, "".to_string()); - } - self.answer_buffer.push_str(&delta.clone()); - self.conversation_history - .replace_prev_agent_message(&self.config, self.answer_buffer.clone()); - self.request_redraw(); + // Buffer only – do not emit partial lines. This avoids cases + // where long responses appear truncated if the terminal + // wrapped early. The full message is emitted on + // AgentMessage. + self.answer_buffer.push_str(&delta); } EventMsg::AgentReasoningDelta(AgentReasoningDeltaEvent { delta }) => { - if self.reasoning_buffer.is_empty() { - self.conversation_history - .add_agent_reasoning(&self.config, "".to_string()); - } - self.reasoning_buffer.push_str(&delta.clone()); - self.conversation_history - .replace_prev_agent_reasoning(&self.config, self.reasoning_buffer.clone()); - self.request_redraw(); + // Buffer only – disable incremental reasoning streaming so we + // avoid truncated intermediate lines. Full text emitted on + // AgentReasoning. + self.reasoning_buffer.push_str(&delta); } EventMsg::AgentReasoning(AgentReasoningEvent { text }) => { - // if the reasoning buffer is empty, this means we haven't received any - // delta. Thus, we need to print the message as a new reasoning. - if self.reasoning_buffer.is_empty() { - self.conversation_history - .add_agent_reasoning(&self.config, "".to_string()); + // Emit full reasoning text once. Some providers might send + // final event with empty text if only deltas were used. + let full = if text.is_empty() { + std::mem::take(&mut self.reasoning_buffer) } else { - // else, we rerender one last time. + self.reasoning_buffer.clear(); + text + }; + if !full.is_empty() { self.conversation_history - .replace_prev_agent_reasoning(&self.config, text); + .add_agent_reasoning(&self.config, full); + self.emit_last_history_entry(); } - self.reasoning_buffer.clear(); self.request_redraw(); } EventMsg::TaskStarted => { @@ -310,7 +322,8 @@ impl ChatWidget<'_> { .set_token_usage(self.token_usage.clone(), self.config.model_context_window); } EventMsg::Error(ErrorEvent { message }) => { - self.conversation_history.add_error(message); + self.conversation_history.add_error(message.clone()); + self.emit_last_history_entry(); self.bottom_pane.set_task_running(false); } EventMsg::ExecApprovalRequest(ExecApprovalRequestEvent { @@ -346,6 +359,7 @@ impl ChatWidget<'_> { self.conversation_history .add_patch_event(PatchEventType::ApprovalRequest, changes); + self.emit_last_history_entry(); self.conversation_history.scroll_to_bottom(); @@ -364,7 +378,8 @@ impl ChatWidget<'_> { cwd: _, }) => { self.conversation_history - .reset_or_add_active_exec_command(call_id, command); + .add_active_exec_command(call_id, command); + self.emit_last_history_entry(); self.request_redraw(); } EventMsg::PatchApplyBegin(PatchApplyBeginEvent { @@ -376,6 +391,7 @@ impl ChatWidget<'_> { // summary so the user can follow along. self.conversation_history .add_patch_event(PatchEventType::ApplyBegin { auto_approved }, changes); + self.emit_last_history_entry(); if !auto_approved { self.conversation_history.scroll_to_bottom(); } @@ -399,6 +415,7 @@ impl ChatWidget<'_> { }) => { self.conversation_history .add_active_mcp_tool_call(call_id, server, tool, arguments); + self.emit_last_history_entry(); self.request_redraw(); } EventMsg::McpToolCallEnd(mcp_tool_call_end_event) => { @@ -425,6 +442,7 @@ impl ChatWidget<'_> { event => { self.conversation_history .add_background_event(format!("{event:?}")); + self.emit_last_history_entry(); self.request_redraw(); } } @@ -441,7 +459,9 @@ impl ChatWidget<'_> { } pub(crate) fn add_diff_output(&mut self, diff_output: String) { - self.conversation_history.add_diff_output(diff_output); + self.conversation_history + .add_diff_output(diff_output.clone()); + self.emit_last_history_entry(); self.request_redraw(); } @@ -492,19 +512,18 @@ impl ChatWidget<'_> { tracing::error!("failed to submit op: {e}"); } } + + pub(crate) fn token_usage(&self) -> &TokenUsage { + &self.token_usage + } } impl WidgetRef for &ChatWidget<'_> { fn render_ref(&self, area: Rect, buf: &mut Buffer) { - let bottom_height = self.bottom_pane.calculate_required_height(&area); - - let chunks = Layout::default() - .direction(Direction::Vertical) - .constraints([Constraint::Min(0), Constraint::Length(bottom_height)]) - .split(area); - - self.conversation_history.render(chunks[0], buf); - (&self.bottom_pane).render(chunks[1], buf); + // In the hybrid inline viewport mode we only draw the interactive + // bottom pane; history entries are injected directly into scrollback + // via `Terminal::insert_before`. + (&self.bottom_pane).render(area, buf); } } diff --git a/codex-rs/tui/src/conversation_history_widget.rs b/codex-rs/tui/src/conversation_history_widget.rs index ceaf115f33..d8035eff64 100644 --- a/codex-rs/tui/src/conversation_history_widget.rs +++ b/codex-rs/tui/src/conversation_history_widget.rs @@ -202,14 +202,6 @@ impl ConversationHistoryWidget { self.add_to_history(HistoryCell::new_agent_reasoning(config, text)); } - pub fn replace_prev_agent_reasoning(&mut self, config: &Config, text: String) { - self.replace_last_agent_reasoning(config, text); - } - - pub fn replace_prev_agent_message(&mut self, config: &Config, text: String) { - self.replace_last_agent_message(config, text); - } - pub fn add_background_event(&mut self, message: String) { self.add_to_history(HistoryCell::new_background_event(message)); } @@ -235,30 +227,6 @@ impl ConversationHistoryWidget { self.add_to_history(HistoryCell::new_active_exec_command(call_id, command)); } - /// If an ActiveExecCommand with the same call_id already exists, replace - /// it with a fresh one (resetting start time and view). Otherwise, add a new entry. - pub fn reset_or_add_active_exec_command(&mut self, call_id: String, command: Vec) { - // Find the most recent matching ActiveExecCommand. - let maybe_idx = self.entries.iter().rposition(|entry| { - if let HistoryCell::ActiveExecCommand { call_id: id, .. } = &entry.cell { - id == &call_id - } else { - false - } - }); - - if let Some(idx) = maybe_idx { - let width = self.cached_width.get(); - self.entries[idx].cell = HistoryCell::new_active_exec_command(call_id.clone(), command); - if width > 0 { - let height = self.entries[idx].cell.height(width); - self.entries[idx].line_count.set(height); - } - } else { - self.add_active_exec_command(call_id, command); - } - } - pub fn add_active_mcp_tool_call( &mut self, call_id: String, @@ -281,40 +249,10 @@ impl ConversationHistoryWidget { }); } - pub fn replace_last_agent_reasoning(&mut self, config: &Config, text: String) { - if let Some(idx) = self - .entries - .iter() - .rposition(|entry| matches!(entry.cell, HistoryCell::AgentReasoning { .. })) - { - let width = self.cached_width.get(); - let entry = &mut self.entries[idx]; - entry.cell = HistoryCell::new_agent_reasoning(config, text); - let height = if width > 0 { - entry.cell.height(width) - } else { - 0 - }; - entry.line_count.set(height); - } - } - - pub fn replace_last_agent_message(&mut self, config: &Config, text: String) { - if let Some(idx) = self - .entries - .iter() - .rposition(|entry| matches!(entry.cell, HistoryCell::AgentMessage { .. })) - { - let width = self.cached_width.get(); - let entry = &mut self.entries[idx]; - entry.cell = HistoryCell::new_agent_message(config, text); - let height = if width > 0 { - entry.cell.height(width) - } else { - 0 - }; - entry.line_count.set(height); - } + /// Return the lines for the most recently appended entry (if any) so the + /// parent widget can surface them via the new scrollback insertion path. + pub(crate) fn last_entry_plain_lines(&self) -> Option>> { + self.entries.last().map(|e| e.cell.plain_lines()) } pub fn record_completed_exec_command( diff --git a/codex-rs/tui/src/history_cell.rs b/codex-rs/tui/src/history_cell.rs index 13bec71b46..ab657163ad 100644 --- a/codex-rs/tui/src/history_cell.rs +++ b/codex-rs/tui/src/history_cell.rs @@ -123,6 +123,30 @@ pub(crate) enum HistoryCell { const TOOL_CALL_MAX_LINES: usize = 5; impl HistoryCell { + /// Return a cloned, plain representation of the cell's lines suitable for + /// one‑shot insertion into the terminal scrollback. Image cells are + /// represented with a simple placeholder for now. + pub(crate) fn plain_lines(&self) -> Vec> { + match self { + HistoryCell::WelcomeMessage { view } + | HistoryCell::UserPrompt { view } + | HistoryCell::AgentMessage { view } + | HistoryCell::AgentReasoning { view } + | HistoryCell::BackgroundEvent { view } + | HistoryCell::GitDiffOutput { view } + | HistoryCell::ErrorEvent { view } + | HistoryCell::SessionInfo { view } + | HistoryCell::CompletedExecCommand { view } + | HistoryCell::CompletedMcpToolCall { view } + | HistoryCell::PendingPatch { view } + | HistoryCell::ActiveExecCommand { view, .. } + | HistoryCell::ActiveMcpToolCall { view, .. } => view.lines.clone(), + HistoryCell::CompletedMcpToolCallWithImageOutput { .. } => vec![ + Line::from("tool result (image output omitted)"), + Line::from(""), + ], + } + } pub(crate) fn new_session_info( config: &Config, event: SessionConfiguredEvent, diff --git a/codex-rs/tui/src/insert_history.rs b/codex-rs/tui/src/insert_history.rs new file mode 100644 index 0000000000..247e024cb0 --- /dev/null +++ b/codex-rs/tui/src/insert_history.rs @@ -0,0 +1,178 @@ +use crate::tui; +use ratatui::layout::Rect; +use ratatui::style::Style; +use ratatui::text::Line; +use ratatui::text::Span; +use ratatui::widgets::Paragraph; +use ratatui::widgets::Widget; +use unicode_width::UnicodeWidthChar; + +/// Insert a batch of history lines into the terminal scrollback above the +/// inline viewport. +/// +/// The incoming `lines` are the logical lines supplied by the +/// `ConversationHistory`. They may contain embedded newlines and arbitrary +/// runs of whitespace inside individual [`Span`]s. All of that must be +/// normalised before writing to the backing terminal buffer because the +/// ratatui [`Paragraph`] widget does not perform soft‑wrapping when used in +/// conjunction with [`Terminal::insert_before`]. +/// +/// This function performs a minimal wrapping / normalisation pass: +/// +/// * A terminal width is determined via `Terminal::size()` (falling back to +/// 80 columns if the size probe fails). +/// * Each logical line is broken into words and whitespace. Consecutive +/// whitespace is collapsed to a single space; leading whitespace is +/// discarded. +/// * Words that do not fit on the current line cause a soft wrap. Extremely +/// long words (longer than the terminal width) are split character by +/// character so they still populate the display instead of overflowing the +/// line. +/// * Explicit `\n` characters inside a span force a hard line break. +/// * Empty lines (including a trailing newline at the end of the batch) are +/// preserved so vertical spacing remains faithful to the logical history. +/// +/// Finally the physical lines are rendered directly into the terminal's +/// scrollback region using [`Terminal::insert_before`]. Any backend error is +/// ignored: failing to insert history is non‑fatal and a subsequent redraw +/// will eventually repaint a consistent view. +fn display_width(s: &str) -> usize { + s.chars() + .map(|c| UnicodeWidthChar::width(c).unwrap_or(0)) + .sum() +} + +struct LineBuilder { + term_width: usize, + spans: Vec>, + width: usize, +} + +impl LineBuilder { + fn new(term_width: usize) -> Self { + Self { + term_width, + spans: Vec::new(), + width: 0, + } + } + + fn flush_line(&mut self, out: &mut Vec>) { + out.push(Line::from(std::mem::take(&mut self.spans))); + self.width = 0; + } + + fn push_segment(&mut self, text: String, style: Style) { + self.width += display_width(&text); + self.spans.push(Span::styled(text, style)); + } + + fn push_word(&mut self, word: &mut String, style: Style, out: &mut Vec>) { + if word.is_empty() { + return; + } + let w_len = display_width(word); + if self.width > 0 && self.width + w_len > self.term_width { + self.flush_line(out); + } + if w_len > self.term_width && self.width == 0 { + // Split an overlong word across multiple lines. + let mut cur = String::new(); + let mut cur_w = 0; + for ch in word.chars() { + let ch_w = UnicodeWidthChar::width(ch).unwrap_or(0); + if cur_w + ch_w > self.term_width && cur_w > 0 { + self.push_segment(cur.clone(), style); + self.flush_line(out); + cur.clear(); + cur_w = 0; + } + cur.push(ch); + cur_w += ch_w; + } + if !cur.is_empty() { + self.push_segment(cur, style); + } + } else { + self.push_segment(word.clone(), style); + } + word.clear(); + } + + fn consume_whitespace(&mut self, ws: &mut String, style: Style, out: &mut Vec>) { + if ws.is_empty() { + return; + } + let space_w = display_width(ws); + if self.width > 0 && self.width + space_w > self.term_width { + self.flush_line(out); + } + if self.width > 0 { + self.push_segment(" ".to_string(), style); + } + ws.clear(); + } +} + +pub(crate) fn insert_history_lines(terminal: &mut tui::Tui, lines: Vec>) { + let term_width = terminal.size().map(|a| a.width).unwrap_or(80) as usize; + let mut physical: Vec> = Vec::new(); + + for logical in lines.into_iter() { + if logical.spans.is_empty() { + physical.push(logical); + continue; + } + + let mut builder = LineBuilder::new(term_width); + let mut buf_space = String::new(); + + for span in logical.spans.into_iter() { + let style = span.style; + let mut buf_word = String::new(); + + for ch in span.content.chars() { + if ch == '\n' { + builder.push_word(&mut buf_word, style, &mut physical); + buf_space.clear(); + builder.flush_line(&mut physical); + continue; + } + if ch.is_whitespace() { + builder.push_word(&mut buf_word, style, &mut physical); + buf_space.push(ch); + } else { + builder.consume_whitespace(&mut buf_space, style, &mut physical); + buf_word.push(ch); + } + if builder.width >= term_width { + builder.flush_line(&mut physical); + } + } + builder.push_word(&mut buf_word, style, &mut physical); + // whitespace intentionally left to allow collapsing across spans + } + if !builder.spans.is_empty() { + physical.push(Line::from(std::mem::take(&mut builder.spans))); + } else { + // Preserve explicit blank line (e.g. due to a trailing newline). + physical.push(Line::from(Vec::>::new())); + } + } + + let total = physical.len() as u16; + terminal + .insert_before(total, |buf| { + let width = buf.area.width; + for (i, line) in physical.into_iter().enumerate() { + let area = Rect { + x: 0, + y: i as u16, + width, + height: 1, + }; + Paragraph::new(line).render(area, buf); + } + }) + .ok(); +} diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index 05a55edc7b..905f0aaf0b 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -33,10 +33,10 @@ mod file_search; mod get_git_diff; mod git_warning_screen; mod history_cell; +mod insert_history; mod log_layer; mod login_screen; mod markdown; -mod mouse_capture; mod scroll_event_helper; mod slash_command; mod status_indicator_widget; @@ -47,7 +47,10 @@ mod user_approval_widget; pub use cli::Cli; -pub fn run_main(cli: Cli, codex_linux_sandbox_exe: Option) -> std::io::Result<()> { +pub fn run_main( + cli: Cli, + codex_linux_sandbox_exe: Option, +) -> std::io::Result { let (sandbox_mode, approval_policy) = if cli.full_auto { ( Some(SandboxMode::WorkspaceWrite), @@ -147,24 +150,8 @@ pub fn run_main(cli: Cli, codex_linux_sandbox_exe: Option) -> std::io:: // `--allow-no-git-exec` flag. let show_git_warning = !cli.skip_git_repo_check && !is_inside_git_repo(&config); - try_run_ratatui_app(cli, config, show_login_screen, show_git_warning, log_rx); - Ok(()) -} - -#[expect( - clippy::print_stderr, - reason = "Resort to stderr in exceptional situations." -)] -fn try_run_ratatui_app( - cli: Cli, - config: Config, - show_login_screen: bool, - show_git_warning: bool, - log_rx: tokio::sync::mpsc::UnboundedReceiver, -) { - if let Err(report) = run_ratatui_app(cli, config, show_login_screen, show_git_warning, log_rx) { - eprintln!("Error: {report:?}"); - } + run_ratatui_app(cli, config, show_login_screen, show_git_warning, log_rx) + .map_err(|err| std::io::Error::other(err.to_string())) } fn run_ratatui_app( @@ -173,16 +160,15 @@ fn run_ratatui_app( show_login_screen: bool, show_git_warning: bool, mut log_rx: tokio::sync::mpsc::UnboundedReceiver, -) -> color_eyre::Result<()> { +) -> color_eyre::Result { color_eyre::install()?; - // Forward panic reports through the tracing stack so that they appear in - // the status indicator instead of breaking the alternate screen – the - // normal colour‑eyre hook writes to stderr which would corrupt the UI. + // Forward panic reports through tracing so they appear in the UI status + // line instead of interleaving raw panic output with the interface. std::panic::set_hook(Box::new(|info| { tracing::error!("panic: {info}"); })); - let (mut terminal, mut mouse_capture) = tui::init(&config)?; + let mut terminal = tui::init(&config)?; terminal.clear()?; let Cli { prompt, images, .. } = cli; @@ -204,10 +190,12 @@ fn run_ratatui_app( }); } - let app_result = app.run(&mut terminal, &mut mouse_capture); + let app_result = app.run(&mut terminal); + let usage = app.token_usage(); restore(); - app_result + // ignore error when collecting usage – report underlying error instead + app_result.map(|_| usage) } #[expect( diff --git a/codex-rs/tui/src/main.rs b/codex-rs/tui/src/main.rs index 7fcc944504..fdb3cdaf82 100644 --- a/codex-rs/tui/src/main.rs +++ b/codex-rs/tui/src/main.rs @@ -20,7 +20,8 @@ fn main() -> anyhow::Result<()> { .config_overrides .raw_overrides .splice(0..0, top_cli.config_overrides.raw_overrides); - run_main(inner, codex_linux_sandbox_exe)?; + let usage = run_main(inner, codex_linux_sandbox_exe)?; + println!("{}", codex_core::protocol::FinalOutput::from(usage)); Ok(()) }) } diff --git a/codex-rs/tui/src/mouse_capture.rs b/codex-rs/tui/src/mouse_capture.rs deleted file mode 100644 index cff1296f6d..0000000000 --- a/codex-rs/tui/src/mouse_capture.rs +++ /dev/null @@ -1,69 +0,0 @@ -use crossterm::event::DisableMouseCapture; -use crossterm::event::EnableMouseCapture; -use ratatui::crossterm::execute; -use std::io::Result; -use std::io::stdout; - -pub(crate) struct MouseCapture { - mouse_capture_is_active: bool, -} - -impl MouseCapture { - pub(crate) fn new_with_capture(mouse_capture_is_active: bool) -> Result { - if mouse_capture_is_active { - enable_capture()?; - } - - Ok(Self { - mouse_capture_is_active, - }) - } -} - -impl MouseCapture { - /// Idempotent method to set the mouse capture state. - pub fn set_active(&mut self, is_active: bool) -> Result<()> { - match (self.mouse_capture_is_active, is_active) { - (true, true) => {} - (false, false) => {} - (true, false) => { - disable_capture()?; - self.mouse_capture_is_active = false; - } - (false, true) => { - enable_capture()?; - self.mouse_capture_is_active = true; - } - } - Ok(()) - } - - pub(crate) fn toggle(&mut self) -> Result<()> { - self.set_active(!self.mouse_capture_is_active) - } - - pub(crate) fn disable(&mut self) -> Result<()> { - if self.mouse_capture_is_active { - disable_capture()?; - self.mouse_capture_is_active = false; - } - Ok(()) - } -} - -impl Drop for MouseCapture { - fn drop(&mut self) { - if self.disable().is_err() { - // The user is likely shutting down, so ignore any errors so the - // shutdown process can complete. - } - } -} - -fn enable_capture() -> Result<()> { - execute!(stdout(), EnableMouseCapture) -} - -fn disable_capture() -> Result<()> { - execute!(stdout(), DisableMouseCapture) -} diff --git a/codex-rs/tui/src/slash_command.rs b/codex-rs/tui/src/slash_command.rs index bb72ce561c..603eb721cd 100644 --- a/codex-rs/tui/src/slash_command.rs +++ b/codex-rs/tui/src/slash_command.rs @@ -15,7 +15,6 @@ pub enum SlashCommand { New, Diff, Quit, - ToggleMouseMode, } impl SlashCommand { @@ -23,9 +22,6 @@ impl SlashCommand { pub fn description(self) -> &'static str { match self { SlashCommand::New => "Start a new chat.", - SlashCommand::ToggleMouseMode => { - "Toggle mouse mode (enable for scrolling, disable for text selection)" - } SlashCommand::Quit => "Exit the application.", SlashCommand::Diff => { "Show git diff of the working directory (including untracked files)" diff --git a/codex-rs/tui/src/status_indicator_widget.rs b/codex-rs/tui/src/status_indicator_widget.rs index dda61d0bd0..973ef09818 100644 --- a/codex-rs/tui/src/status_indicator_widget.rs +++ b/codex-rs/tui/src/status_indicator_widget.rs @@ -34,11 +34,6 @@ pub(crate) struct StatusIndicatorWidget { /// time). text: String, - /// Height in terminal rows – matches the height of the textarea at the - /// moment the task started so the UI does not jump when we toggle between - /// input mode and loading mode. - height: u16, - frame_idx: Arc, running: Arc, // Keep one sender alive to prevent the channel from closing while the @@ -50,7 +45,7 @@ pub(crate) struct StatusIndicatorWidget { impl StatusIndicatorWidget { /// Create a new status indicator and start the animation timer. - pub(crate) fn new(app_event_tx: AppEventSender, height: u16) -> Self { + pub(crate) fn new(app_event_tx: AppEventSender) -> Self { let frame_idx = Arc::new(AtomicUsize::new(0)); let running = Arc::new(AtomicBool::new(true)); @@ -72,18 +67,12 @@ impl StatusIndicatorWidget { Self { text: String::from("waiting for logs…"), - height: height.max(3), frame_idx, running, _app_event_tx: app_event_tx, } } - /// Preferred height in terminal rows. - pub(crate) fn get_height(&self) -> u16 { - self.height - } - /// Update the line that is displayed in the widget. pub(crate) fn update_text(&mut self, text: String) { self.text = text.replace(['\n', '\r'], " "); diff --git a/codex-rs/tui/src/tui.rs b/codex-rs/tui/src/tui.rs index 99ff034361..66ae1cfb96 100644 --- a/codex-rs/tui/src/tui.rs +++ b/codex-rs/tui/src/tui.rs @@ -4,31 +4,39 @@ use std::io::stdout; use codex_core::config::Config; use crossterm::event::DisableBracketedPaste; -use crossterm::event::DisableMouseCapture; use crossterm::event::EnableBracketedPaste; use ratatui::Terminal; +use ratatui::TerminalOptions; +use ratatui::Viewport; use ratatui::backend::CrosstermBackend; use ratatui::crossterm::execute; -use ratatui::crossterm::terminal::EnterAlternateScreen; -use ratatui::crossterm::terminal::LeaveAlternateScreen; use ratatui::crossterm::terminal::disable_raw_mode; use ratatui::crossterm::terminal::enable_raw_mode; -use crate::mouse_capture::MouseCapture; - /// A type alias for the terminal type used in this application pub type Tui = Terminal>; -/// Initialize the terminal -pub fn init(config: &Config) -> Result<(Tui, MouseCapture)> { - execute!(stdout(), EnterAlternateScreen)?; +/// Initialize the terminal (inline viewport; history stays in normal scrollback) +pub fn init(_config: &Config) -> Result { execute!(stdout(), EnableBracketedPaste)?; - let mouse_capture = MouseCapture::new_with_capture(!config.tui.disable_mouse_capture)?; enable_raw_mode()?; set_panic_hook(); - let tui = Terminal::new(CrosstermBackend::new(stdout()))?; - Ok((tui, mouse_capture)) + + // Reserve a fixed number of lines for the interactive viewport (composer, + // status, popups). History is injected above using `insert_before`. This + // is an initial step of the refactor – later the height can become + // dynamic. For now a conservative default keeps enough room for the + // multi‑line composer while not occupying the whole screen. + const BOTTOM_VIEWPORT_HEIGHT: u16 = 8; + let backend = CrosstermBackend::new(stdout()); + let tui = Terminal::with_options( + backend, + TerminalOptions { + viewport: Viewport::Inline(BOTTOM_VIEWPORT_HEIGHT), + }, + )?; + Ok(tui) } fn set_panic_hook() { @@ -41,14 +49,7 @@ fn set_panic_hook() { /// Restore the terminal to its original state pub fn restore() -> Result<()> { - // We are shutting down, and we cannot reference the `MouseCapture`, so we - // categorically disable mouse capture just to be safe. - if execute!(stdout(), DisableMouseCapture).is_err() { - // It is possible that `DisableMouseCapture` is written more than once - // on shutdown, so ignore the error in this case. - } execute!(stdout(), DisableBracketedPaste)?; - execute!(stdout(), LeaveAlternateScreen)?; disable_raw_mode()?; Ok(()) } diff --git a/codex-rs/tui/src/user_approval_widget.rs b/codex-rs/tui/src/user_approval_widget.rs index 6604daace8..431f85a268 100644 --- a/codex-rs/tui/src/user_approval_widget.rs +++ b/codex-rs/tui/src/user_approval_widget.rs @@ -116,10 +116,6 @@ pub(crate) struct UserApprovalWidget<'a> { done: bool, } -// Number of lines automatically added by ratatui’s [`Block`] when -// borders are enabled (one at the top, one at the bottom). -const BORDER_LINES: u16 = 2; - impl UserApprovalWidget<'_> { pub(crate) fn new(approval_request: ApprovalRequest, app_event_tx: AppEventSender) -> Self { let input = Input::default(); @@ -190,28 +186,6 @@ impl UserApprovalWidget<'_> { } } - pub(crate) fn get_height(&self, area: &Rect) -> u16 { - let confirmation_prompt_height = - self.get_confirmation_prompt_height(area.width - BORDER_LINES); - - match self.mode { - Mode::Select => { - let num_option_lines = SELECT_OPTIONS.len() as u16; - confirmation_prompt_height + num_option_lines + BORDER_LINES - } - Mode::Input => { - // 1. "Give the model feedback ..." prompt - // 2. A single‑line input field (we allocate exactly one row; - // the `tui-input` widget will scroll horizontally if the - // text exceeds the width). - const INPUT_PROMPT_LINES: u16 = 1; - const INPUT_FIELD_LINES: u16 = 1; - - confirmation_prompt_height + INPUT_PROMPT_LINES + INPUT_FIELD_LINES + BORDER_LINES - } - } - } - fn get_confirmation_prompt_height(&self, width: u16) -> u16 { // Should cache this for last value of width. self.confirmation_prompt.line_count(width) as u16 @@ -333,7 +307,32 @@ impl WidgetRef for &UserApprovalWidget<'_> { .borders(Borders::ALL) .border_type(BorderType::Rounded); let inner = outer.inner(area); - let prompt_height = self.get_confirmation_prompt_height(inner.width); + + // Determine how many rows we can allocate for the static confirmation + // prompt while *always* keeping enough space for the interactive + // response area (select list or input field). When the full prompt + // would exceed the available height we truncate it so the response + // options never get pushed out of view. This keeps the approval modal + // usable even when the overall bottom viewport is small. + + // Full height of the prompt (may be larger than the available area). + let full_prompt_height = self.get_confirmation_prompt_height(inner.width); + + // Minimum rows that must remain for the interactive section. + let min_response_rows = match self.mode { + Mode::Select => SELECT_OPTIONS.len() as u16, + // In input mode we need exactly two rows: one for the guidance + // prompt and one for the single-line input field. + Mode::Input => 2, + }; + + // Clamp prompt height so confirmation + response never exceed the + // available space. `saturating_sub` avoids underflow when the area is + // too small even for the minimal layout – in this unlikely case we + // fall back to zero-height prompt so at least the options are + // visible. + let prompt_height = full_prompt_height.min(inner.height.saturating_sub(min_response_rows)); + let chunks = Layout::default() .direction(Direction::Vertical) .constraints([Constraint::Length(prompt_height), Constraint::Min(0)]) @@ -342,8 +341,7 @@ impl WidgetRef for &UserApprovalWidget<'_> { let response_chunk = chunks[1]; // Build the inner lines based on the mode. Collect them into a List of - // non-wrapping lines rather than a Paragraph because get_height(Rect) - // depends on this behavior for its calculation. + // non-wrapping lines rather than a Paragraph for predictable layout. let lines = match self.mode { Mode::Select => SELECT_OPTIONS .iter() From 994c9a874def12a9fcb3d32c4b7cf7eb40f4d841 Mon Sep 17 00:00:00 2001 From: Michael Bolin Date: Fri, 25 Jul 2025 10:43:36 -0700 Subject: [PATCH 29/58] chore: use one write call per item in rollout_writer() (#1679) Most of the time, we expect the `String` returned by `serde_json::to_string()` to have extra capacity, so `push('\n')` is unlikely to allocate, which seems cheaper than an extra `write(2)` call, on average? --- codex-rs/core/src/rollout.rs | 48 +++++++++++++++++++++--------------- 1 file changed, 28 insertions(+), 20 deletions(-) diff --git a/codex-rs/core/src/rollout.rs b/codex-rs/core/src/rollout.rs index 3e6de34d96..0ccd8e891b 100644 --- a/codex-rs/core/src/rollout.rs +++ b/codex-rs/core/src/rollout.rs @@ -288,11 +288,13 @@ fn create_log_file(config: &Config, session_id: Uuid) -> std::io::Result, mut meta: Option, cwd: std::path::PathBuf, -) { +) -> std::io::Result<()> { + let mut writer = JsonlWriter { file }; + // If we have a meta, collect git info asynchronously and write meta first if let Some(session_meta) = meta.take() { let git_info = collect_git_info(&cwd).await; @@ -302,11 +304,7 @@ async fn rollout_writer( }; // Write the SessionMeta as the first item in the file - if let Ok(json) = serde_json::to_string(&session_meta_with_git) { - let _ = file.write_all(json.as_bytes()).await; - let _ = file.write_all(b"\n").await; - let _ = file.flush().await; - } + writer.write_line(&session_meta_with_git).await?; } // Process rollout commands @@ -320,15 +318,11 @@ async fn rollout_writer( | ResponseItem::FunctionCall { .. } | ResponseItem::FunctionCallOutput { .. } | ResponseItem::Reasoning { .. } => { - if let Ok(json) = serde_json::to_string(&item) { - let _ = file.write_all(json.as_bytes()).await; - let _ = file.write_all(b"\n").await; - } + writer.write_line(&item).await?; } ResponseItem::Other => {} } } - let _ = file.flush().await; } RolloutCmd::UpdateState(state) => { #[derive(Serialize)] @@ -337,18 +331,32 @@ async fn rollout_writer( #[serde(flatten)] state: &'a SessionStateSnapshot, } - if let Ok(json) = serde_json::to_string(&StateLine { - record_type: "state", - state: &state, - }) { - let _ = file.write_all(json.as_bytes()).await; - let _ = file.write_all(b"\n").await; - let _ = file.flush().await; - } + writer + .write_line(&StateLine { + record_type: "state", + state: &state, + }) + .await?; } RolloutCmd::Shutdown { ack } => { let _ = ack.send(()); } } } + + Ok(()) +} + +struct JsonlWriter { + file: tokio::fs::File, +} + +impl JsonlWriter { + async fn write_line(&mut self, item: &impl serde::Serialize) -> std::io::Result<()> { + let mut json = serde_json::to_string(item)?; + json.push('\n'); + let _ = self.file.write_all(json.as_bytes()).await; + self.file.flush().await?; + Ok(()) + } } From 7ee87123a6de716507700eb18050ad934c621e18 Mon Sep 17 00:00:00 2001 From: pakrym-oai Date: Fri, 25 Jul 2025 11:45:23 -0700 Subject: [PATCH 30/58] Optionally run using user profile (#1678) --- codex-rs/Cargo.lock | 19 +++ codex-rs/core/Cargo.toml | 3 + codex-rs/core/src/codex.rs | 17 +++ codex-rs/core/src/config_types.rs | 7 + codex-rs/core/src/exec.rs | 8 +- codex-rs/core/src/lib.rs | 1 + codex-rs/core/src/shell.rs | 204 ++++++++++++++++++++++++++++++ codex-rs/mcp-server/src/lib.rs | 2 + 8 files changed, 260 insertions(+), 1 deletion(-) create mode 100644 codex-rs/core/src/shell.rs diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 7c1b075011..ba71596ecd 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -683,6 +683,7 @@ dependencies = [ "serde", "serde_json", "sha1", + "shlex", "strum_macros 0.27.2", "tempfile", "thiserror 2.0.12", @@ -696,6 +697,7 @@ dependencies = [ "tree-sitter-bash", "uuid", "walkdir", + "whoami", "wildmatch", "wiremock", ] @@ -5128,6 +5130,12 @@ dependencies = [ "wit-bindgen-rt", ] +[[package]] +name = "wasite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8dad83b4f25e74f184f64c43b150b91efe7647395b42289f38e50566d82855b" + [[package]] name = "wasm-bindgen" version = "0.2.100" @@ -5228,6 +5236,17 @@ version = "0.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a751b3277700db47d3e574514de2eced5e54dc8a5436a3bf7a0b248b2cee16f3" +[[package]] +name = "whoami" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6994d13118ab492c3c80c1f81928718159254c53c472bf9ce36f8dae4add02a7" +dependencies = [ + "redox_syscall", + "wasite", + "web-sys", +] + [[package]] name = "wildmatch" version = "2.4.0" diff --git a/codex-rs/core/Cargo.toml b/codex-rs/core/Cargo.toml index 62e462bf97..2e0489c9b5 100644 --- a/codex-rs/core/Cargo.toml +++ b/codex-rs/core/Cargo.toml @@ -30,6 +30,7 @@ reqwest = { version = "0.12", features = ["json", "stream"] } serde = { version = "1", features = ["derive"] } serde_json = "1" sha1 = "0.10.6" +shlex = "1.3.0" strum_macros = "0.27.2" thiserror = "2.0.12" time = { version = "0.3", features = ["formatting", "local-offset", "macros"] } @@ -47,6 +48,8 @@ tree-sitter = "0.25.8" tree-sitter-bash = "0.25.0" uuid = { version = "1", features = ["serde", "v4"] } wildmatch = "2.4.0" +whoami = "1.6.0" + [target.'cfg(target_os = "linux")'.dependencies] landlock = "0.4.1" diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index f35348b779..d9d40a8a47 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -85,6 +85,7 @@ use crate::rollout::RolloutRecorder; use crate::safety::SafetyCheck; use crate::safety::assess_command_safety; use crate::safety::assess_patch_safety; +use crate::shell; use crate::user_notification::UserNotification; use crate::util::backoff; @@ -204,6 +205,7 @@ pub(crate) struct Session { rollout: Mutex>, state: Mutex, codex_linux_sandbox_exe: Option, + user_shell: shell::Shell, } impl Session { @@ -676,6 +678,7 @@ async fn submission_loop( }); } } + let default_shell = shell::default_user_shell().await; sess = Some(Arc::new(Session { client, tx_event: tx_event.clone(), @@ -693,6 +696,7 @@ async fn submission_loop( rollout: Mutex::new(rollout_recorder), codex_linux_sandbox_exe: config.codex_linux_sandbox_exe.clone(), disable_response_storage, + user_shell: default_shell, })); // Patch restored state into the newly created session. @@ -1383,6 +1387,18 @@ fn parse_container_exec_arguments( } } +fn maybe_run_with_user_profile(params: ExecParams, sess: &Session) -> ExecParams { + if sess.shell_environment_policy.use_profile { + let command = sess + .user_shell + .format_default_shell_invocation(params.command.clone()); + if let Some(command) = command { + return ExecParams { command, ..params }; + } + } + params +} + async fn handle_container_exec_with_params( params: ExecParams, sess: &Session, @@ -1469,6 +1485,7 @@ async fn handle_container_exec_with_params( sess.notify_exec_command_begin(&sub_id, &call_id, ¶ms) .await; + let params = maybe_run_with_user_profile(params, sess); let output_result = process_exec_tool_call( params.clone(), sandbox_type, diff --git a/codex-rs/core/src/config_types.rs b/codex-rs/core/src/config_types.rs index cba5dcfbb2..735a571edc 100644 --- a/codex-rs/core/src/config_types.rs +++ b/codex-rs/core/src/config_types.rs @@ -130,6 +130,8 @@ pub struct ShellEnvironmentPolicyToml { /// List of regular expressions. pub include_only: Option>, + + pub experimental_use_profile: Option, } pub type EnvironmentVariablePattern = WildMatchPattern<'*', '?'>; @@ -158,6 +160,9 @@ pub struct ShellEnvironmentPolicy { /// Environment variable names to retain in the environment. pub include_only: Vec, + + /// If true, the shell profile will be used to run the command. + pub use_profile: bool, } impl From for ShellEnvironmentPolicy { @@ -177,6 +182,7 @@ impl From for ShellEnvironmentPolicy { .into_iter() .map(|s| EnvironmentVariablePattern::new_case_insensitive(&s)) .collect(); + let use_profile = toml.experimental_use_profile.unwrap_or(false); Self { inherit, @@ -184,6 +190,7 @@ impl From for ShellEnvironmentPolicy { exclude, r#set, include_only, + use_profile, } } } diff --git a/codex-rs/core/src/exec.rs b/codex-rs/core/src/exec.rs index 4b33b0b3b5..230c4ec134 100644 --- a/codex-rs/core/src/exec.rs +++ b/codex-rs/core/src/exec.rs @@ -17,6 +17,7 @@ use tokio::io::BufReader; use tokio::process::Child; use tokio::process::Command; use tokio::sync::Notify; +use tracing::trace; use crate::error::CodexErr; use crate::error::Result; @@ -82,7 +83,8 @@ pub async fn process_exec_tool_call( ) -> Result { let start = Instant::now(); - let raw_output_result = match sandbox_type { + let raw_output_result: std::result::Result = match sandbox_type + { SandboxType::None => exec(params, sandbox_policy, ctrl_c).await, SandboxType::MacosSeatbelt => { let ExecParams { @@ -372,6 +374,10 @@ async fn spawn_child_async( stdio_policy: StdioPolicy, env: HashMap, ) -> std::io::Result { + trace!( + "spawn_child_async: {program:?} {args:?} {arg0:?} {cwd:?} {sandbox_policy:?} {stdio_policy:?} {env:?}" + ); + let mut cmd = Command::new(&program); #[cfg(unix)] cmd.arg0(arg0.map_or_else(|| program.to_string_lossy().to_string(), String::from)); diff --git a/codex-rs/core/src/lib.rs b/codex-rs/core/src/lib.rs index 12321e0abc..2b82a3f045 100644 --- a/codex-rs/core/src/lib.rs +++ b/codex-rs/core/src/lib.rs @@ -36,6 +36,7 @@ mod project_doc; pub mod protocol; mod rollout; mod safety; +pub mod shell; mod user_notification; pub mod util; diff --git a/codex-rs/core/src/shell.rs b/codex-rs/core/src/shell.rs new file mode 100644 index 0000000000..463651234c --- /dev/null +++ b/codex-rs/core/src/shell.rs @@ -0,0 +1,204 @@ +use shlex; + +#[derive(Debug, PartialEq, Eq)] +pub struct ZshShell { + shell_path: String, + zshrc_path: String, +} + +#[derive(Debug, PartialEq, Eq)] +pub enum Shell { + Zsh(ZshShell), + Unknown, +} + +impl Shell { + pub fn format_default_shell_invocation(&self, command: Vec) -> Option> { + match self { + Shell::Zsh(zsh) => { + if !std::path::Path::new(&zsh.zshrc_path).exists() { + return None; + } + + let mut result = vec![zsh.shell_path.clone(), "-c".to_string()]; + if let Ok(joined) = shlex::try_join(command.iter().map(|s| s.as_str())) { + result.push(format!("source {} && ({joined})", zsh.zshrc_path)); + } else { + return None; + } + Some(result) + } + Shell::Unknown => None, + } + } +} + +#[cfg(target_os = "macos")] +pub async fn default_user_shell() -> Shell { + use tokio::process::Command; + use whoami; + + let user = whoami::username(); + let home = format!("/Users/{user}"); + let output = Command::new("dscl") + .args([".", "-read", &home, "UserShell"]) + .output() + .await + .ok(); + match output { + Some(o) => { + if !o.status.success() { + return Shell::Unknown; + } + let stdout = String::from_utf8_lossy(&o.stdout); + for line in stdout.lines() { + if let Some(shell_path) = line.strip_prefix("UserShell: ") { + if shell_path.ends_with("/zsh") { + return Shell::Zsh(ZshShell { + shell_path: shell_path.to_string(), + zshrc_path: format!("{home}/.zshrc"), + }); + } + } + } + + Shell::Unknown + } + _ => Shell::Unknown, + } +} + +#[cfg(not(target_os = "macos"))] +pub async fn default_user_shell() -> Shell { + Shell::Unknown +} + +#[cfg(test)] +#[cfg(target_os = "macos")] +mod tests { + use super::*; + use std::process::Command; + + #[tokio::test] + #[expect(clippy::unwrap_used)] + async fn test_current_shell_detects_zsh() { + let shell = Command::new("sh") + .arg("-c") + .arg("echo $SHELL") + .output() + .unwrap(); + + let home = std::env::var("HOME").unwrap(); + let shell_path = String::from_utf8_lossy(&shell.stdout).trim().to_string(); + if shell_path.ends_with("/zsh") { + assert_eq!( + default_user_shell().await, + Shell::Zsh(ZshShell { + shell_path: shell_path.to_string(), + zshrc_path: format!("{home}/.zshrc",), + }) + ); + } + } + + #[tokio::test] + async fn test_run_with_profile_zshrc_not_exists() { + let shell = Shell::Zsh(ZshShell { + shell_path: "/bin/zsh".to_string(), + zshrc_path: "/does/not/exist/.zshrc".to_string(), + }); + let actual_cmd = shell.format_default_shell_invocation(vec!["myecho".to_string()]); + assert_eq!(actual_cmd, None); + } + + #[expect(clippy::unwrap_used)] + #[tokio::test] + async fn test_run_with_profile_escaping_and_execution() { + let shell_path = "/bin/zsh"; + + let cases = vec![ + ( + vec!["myecho"], + vec![shell_path, "-c", "source ZSHRC_PATH && (myecho)"], + Some("It works!\n"), + ), + ( + vec!["bash", "-lc", "echo 'single' \"double\""], + vec![ + shell_path, + "-c", + "source ZSHRC_PATH && (bash -lc \"echo 'single' \\\"double\\\"\")", + ], + Some("single double\n"), + ), + ]; + for (input, expected_cmd, expected_output) in cases { + use std::collections::HashMap; + use std::path::PathBuf; + use std::sync::Arc; + + use tokio::sync::Notify; + + use crate::exec::ExecParams; + use crate::exec::SandboxType; + use crate::exec::process_exec_tool_call; + use crate::protocol::SandboxPolicy; + + // create a temp directory with a zshrc file in it + let temp_home = tempfile::tempdir().unwrap(); + let zshrc_path = temp_home.path().join(".zshrc"); + std::fs::write( + &zshrc_path, + r#" + set -x + function myecho { + echo 'It works!' + } + "#, + ) + .unwrap(); + let shell = Shell::Zsh(ZshShell { + shell_path: shell_path.to_string(), + zshrc_path: zshrc_path.to_str().unwrap().to_string(), + }); + + let actual_cmd = shell + .format_default_shell_invocation(input.iter().map(|s| s.to_string()).collect()); + let expected_cmd = expected_cmd + .iter() + .map(|s| { + s.replace("ZSHRC_PATH", zshrc_path.to_str().unwrap()) + .to_string() + }) + .collect(); + + assert_eq!(actual_cmd, Some(expected_cmd)); + // Actually run the command and check output/exit code + let output = process_exec_tool_call( + ExecParams { + command: actual_cmd.unwrap(), + cwd: PathBuf::from(temp_home.path()), + timeout_ms: None, + env: HashMap::from([( + "HOME".to_string(), + temp_home.path().to_str().unwrap().to_string(), + )]), + }, + SandboxType::None, + Arc::new(Notify::new()), + &SandboxPolicy::DangerFullAccess, + &None, + ) + .await + .unwrap(); + + assert_eq!(output.exit_code, 0, "input: {input:?} output: {output:?}"); + if let Some(expected) = expected_output { + assert_eq!( + output.stdout, expected, + "input: {input:?} output: {output:?}" + ); + } + } + } +} diff --git a/codex-rs/mcp-server/src/lib.rs b/codex-rs/mcp-server/src/lib.rs index 79981e4992..aaf67571b4 100644 --- a/codex-rs/mcp-server/src/lib.rs +++ b/codex-rs/mcp-server/src/lib.rs @@ -13,6 +13,7 @@ use tokio::sync::mpsc; use tracing::debug; use tracing::error; use tracing::info; +use tracing_subscriber::EnvFilter; mod codex_tool_config; mod codex_tool_runner; @@ -43,6 +44,7 @@ pub async fn run_main(codex_linux_sandbox_exe: Option) -> IoResult<()> // control the log level with `RUST_LOG`. tracing_subscriber::fmt() .with_writer(std::io::stderr) + .with_env_filter(EnvFilter::from_default_env()) .init(); // Set up channels. From 75b40080947b8c142853a391e73d7a99fa089a39 Mon Sep 17 00:00:00 2001 From: Jeremy Rose <172423086+nornagon-openai@users.noreply.github.com> Date: Fri, 25 Jul 2025 12:26:40 -0700 Subject: [PATCH 31/58] fix: paste with newlines (#1682) This fixes an issue where pasting multi-line content would break the composer. --- codex-rs/tui/src/app.rs | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index ee14e7bb37..4e2133a6be 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -109,6 +109,13 @@ impl App<'_> { scroll_event_helper.scroll_down(); } crossterm::event::Event::Paste(pasted) => { + // Many terminals convert newlines to \r when + // pasting, e.g. [iTerm2][]. But [tui-textarea + // expects \n][tui-textarea]. This seems like a bug + // in tui-textarea IMO, but work around it for now. + // [tui-textarea]: https://github.com/rhysd/tui-textarea/blob/4d18622eeac13b309e0ff6a55a46ac6706da68cf/src/textarea.rs#L782-L783 + // [iTerm2]: https://github.com/gnachman/iTerm2/blob/5d0c0d9f68523cbd0494dad5422998964a2ecd8d/sources/iTermPasteHelper.m#L206-L216 + let pasted = pasted.replace("\r", "\n"); app_event_tx.send(AppEvent::Paste(pasted)); } _ => { From c66c99c5b544a3334a3ee84c3cf28187438e7715 Mon Sep 17 00:00:00 2001 From: Jeremy Rose <172423086+nornagon-openai@users.noreply.github.com> Date: Fri, 25 Jul 2025 14:23:38 -0700 Subject: [PATCH 32/58] fix: crash on resize (#1683) Without this, resizing the terminal prints "Error: The cursor position could not be read within a normal duration" and quits the app. --- codex-rs/tui/src/app.rs | 76 ++++++++++++++++++++++++----------------- 1 file changed, 44 insertions(+), 32 deletions(-) diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 4e2133a6be..e7097e6af0 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -88,39 +88,51 @@ impl App<'_> { { let app_event_tx = app_event_tx.clone(); std::thread::spawn(move || { - while let Ok(event) = crossterm::event::read() { - match event { - crossterm::event::Event::Key(key_event) => { - app_event_tx.send(AppEvent::KeyEvent(key_event)); - } - crossterm::event::Event::Resize(_, _) => { - app_event_tx.send(AppEvent::RequestRedraw); - } - crossterm::event::Event::Mouse(MouseEvent { - kind: MouseEventKind::ScrollUp, - .. - }) => { - scroll_event_helper.scroll_up(); - } - crossterm::event::Event::Mouse(MouseEvent { - kind: MouseEventKind::ScrollDown, - .. - }) => { - scroll_event_helper.scroll_down(); - } - crossterm::event::Event::Paste(pasted) => { - // Many terminals convert newlines to \r when - // pasting, e.g. [iTerm2][]. But [tui-textarea - // expects \n][tui-textarea]. This seems like a bug - // in tui-textarea IMO, but work around it for now. - // [tui-textarea]: https://github.com/rhysd/tui-textarea/blob/4d18622eeac13b309e0ff6a55a46ac6706da68cf/src/textarea.rs#L782-L783 - // [iTerm2]: https://github.com/gnachman/iTerm2/blob/5d0c0d9f68523cbd0494dad5422998964a2ecd8d/sources/iTermPasteHelper.m#L206-L216 - let pasted = pasted.replace("\r", "\n"); - app_event_tx.send(AppEvent::Paste(pasted)); - } - _ => { - // Ignore any other events. + loop { + // This timeout is necessary to avoid holding the event lock + // that crossterm::event::read() acquires. In particular, + // reading the cursor position (crossterm::cursor::position()) + // needs to acquire the event lock, and so will fail if it + // can't acquire it within 2 sec. Resizing the terminal + // crashes the app if the cursor position can't be read. + if let Ok(true) = crossterm::event::poll(Duration::from_millis(100)) { + if let Ok(event) = crossterm::event::read() { + match event { + crossterm::event::Event::Key(key_event) => { + app_event_tx.send(AppEvent::KeyEvent(key_event)); + } + crossterm::event::Event::Resize(_, _) => { + app_event_tx.send(AppEvent::RequestRedraw); + } + crossterm::event::Event::Mouse(MouseEvent { + kind: MouseEventKind::ScrollUp, + .. + }) => { + scroll_event_helper.scroll_up(); + } + crossterm::event::Event::Mouse(MouseEvent { + kind: MouseEventKind::ScrollDown, + .. + }) => { + scroll_event_helper.scroll_down(); + } + crossterm::event::Event::Paste(pasted) => { + // Many terminals convert newlines to \r when + // pasting, e.g. [iTerm2][]. But [tui-textarea + // expects \n][tui-textarea]. This seems like a bug + // in tui-textarea IMO, but work around it for now. + // [tui-textarea]: https://github.com/rhysd/tui-textarea/blob/4d18622eeac13b309e0ff6a55a46ac6706da68cf/src/textarea.rs#L782-L783 + // [iTerm2]: https://github.com/gnachman/iTerm2/blob/5d0c0d9f68523cbd0494dad5422998964a2ecd8d/sources/iTermPasteHelper.m#L206-L216 + let pasted = pasted.replace("\r", "\n"); + app_event_tx.send(AppEvent::Paste(pasted)); + } + _ => { + // Ignore any other events. + } + } } + } else { + // Timeout expired, no `Event` is available } } }); From 5a0079fea2d325d2638e2b1857cba0871fba6402 Mon Sep 17 00:00:00 2001 From: aibrahim-oai Date: Sat, 26 Jul 2025 10:35:49 -0700 Subject: [PATCH 33/58] Changing method in MCP notifications (#1684) - Changing the codex/event type --- codex-rs/core/src/protocol.rs | 3 +- codex-rs/mcp-server/src/outgoing_message.rs | 17 +++++++- .../mcp-server/tests/common/mcp_process.rs | 42 ++++++++++++++----- 3 files changed, 50 insertions(+), 12 deletions(-) diff --git a/codex-rs/core/src/protocol.rs b/codex-rs/core/src/protocol.rs index cf6e8b5191..1a6313db92 100644 --- a/codex-rs/core/src/protocol.rs +++ b/codex-rs/core/src/protocol.rs @@ -278,8 +278,9 @@ pub struct Event { } /// Response event from the agent -#[derive(Debug, Clone, Deserialize, Serialize)] +#[derive(Debug, Clone, Deserialize, Serialize, Display)] #[serde(tag = "type", rename_all = "snake_case")] +#[strum(serialize_all = "lowercase")] pub enum EventMsg { /// Error while executing a submission Error(ErrorEvent), diff --git a/codex-rs/mcp-server/src/outgoing_message.rs b/codex-rs/mcp-server/src/outgoing_message.rs index a1eea65f25..e4af1f78cd 100644 --- a/codex-rs/mcp-server/src/outgoing_message.rs +++ b/codex-rs/mcp-server/src/outgoing_message.rs @@ -83,11 +83,26 @@ impl OutgoingMessageSender { let params = Some(serde_json::to_value(event).expect("Event must serialize")); let outgoing_message = OutgoingMessage::Notification(OutgoingNotification { method: "codex/event".to_string(), + params: params.clone(), + }); + let _ = self.sender.send(outgoing_message).await; + + self.send_event_as_notification_new_schema(event, params) + .await; + } + // should be backwards compatible. + // it will replace send_event_as_notification eventually. + async fn send_event_as_notification_new_schema( + &self, + event: &Event, + params: Option, + ) { + let outgoing_message = OutgoingMessage::Notification(OutgoingNotification { + method: event.msg.to_string(), params, }); let _ = self.sender.send(outgoing_message).await; } - pub(crate) async fn send_error(&self, id: RequestId, error: JSONRPCErrorError) { let outgoing_message = OutgoingMessage::Error(OutgoingError { id, error }); let _ = self.sender.send(outgoing_message).await; diff --git a/codex-rs/mcp-server/tests/common/mcp_process.rs b/codex-rs/mcp-server/tests/common/mcp_process.rs index b27a96eb89..528a40152f 100644 --- a/codex-rs/mcp-server/tests/common/mcp_process.rs +++ b/codex-rs/mcp-server/tests/common/mcp_process.rs @@ -270,27 +270,49 @@ impl McpProcess { pub async fn read_stream_until_configured_response_message( &mut self, ) -> anyhow::Result { + let mut sid_old: Option = None; + let mut sid_new: Option = None; loop { let message = self.read_jsonrpc_message().await?; eprint!("message: {message:?}"); match message { JSONRPCMessage::Notification(notification) => { - if notification.method == "codex/event" { - if let Some(params) = notification.params { + if let Some(params) = notification.params { + // Back-compat schema: method == "codex/event" and msg.type == "session_configured" + if notification.method == "codex/event" { if let Some(msg) = params.get("msg") { - if let Some(msg_type) = msg.get("type") { - if msg_type == "session_configured" { - if let Some(session_id) = msg.get("session_id") { - return Ok(session_id - .to_string() - .trim_matches('"') - .to_string()); - } + if msg.get("type").and_then(|v| v.as_str()) + == Some("session_configured") + { + if let Some(session_id) = + msg.get("session_id").and_then(|v| v.as_str()) + { + sid_old = Some(session_id.to_string()); } } } } + // New schema: method is the Display of EventMsg::SessionConfigured => "SessionConfigured" + if notification.method == "sessionconfigured" { + if let Some(msg) = params.get("msg") { + if let Some(session_id) = + msg.get("session_id").and_then(|v| v.as_str()) + { + sid_new = Some(session_id.to_string()); + } + } + } + } + + if sid_old.is_some() && sid_new.is_some() { + // Both seen, they must match + assert_eq!( + sid_old.as_ref().unwrap(), + sid_new.as_ref().unwrap(), + "session_id mismatch between old and new schema" + ); + return Ok(sid_old.unwrap()); } } JSONRPCMessage::Request(_) => { From 58bed77ba75518c4b097a7e802fdd84f5d53a749 Mon Sep 17 00:00:00 2001 From: easong-openai Date: Sun, 27 Jul 2025 11:04:09 -0700 Subject: [PATCH 34/58] Remove tab focus switching (#1694) Previously pressing tab would switch TUI focus to the history scrollbox - no longer necessary. --- codex-rs/tui/src/bottom_pane/chat_composer.rs | 11 --- codex-rs/tui/src/bottom_pane/mod.rs | 11 --- codex-rs/tui/src/chatwidget.rs | 45 ++--------- .../tui/src/conversation_history_widget.rs | 76 ------------------- 4 files changed, 5 insertions(+), 138 deletions(-) diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index bdfb6a23e2..6a1bb526ce 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -127,10 +127,6 @@ impl ChatComposer<'_> { .on_entry_response(log_id, offset, entry, &mut self.textarea) } - pub fn set_input_focus(&mut self, has_focus: bool) { - self.update_border(has_focus); - } - pub fn handle_paste(&mut self, pasted: String) -> bool { let char_count = pasted.chars().count(); if char_count > LARGE_PASTE_CHAR_THRESHOLD { @@ -638,13 +634,6 @@ impl ChatComposer<'_> { .border_style(bs.border_style), ); } - - pub(crate) fn is_popup_visible(&self) -> bool { - match self.active_popup { - ActivePopup::Command(_) | ActivePopup::File(_) => true, - ActivePopup::None => false, - } - } } impl WidgetRef for &ChatComposer<'_> { diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index ebec534f21..0ddb36f635 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -104,12 +104,6 @@ impl BottomPane<'_> { } } - /// Update the UI to reflect whether this `BottomPane` has input focus. - pub(crate) fn set_input_focus(&mut self, has_focus: bool) { - self.has_input_focus = has_focus; - self.composer.set_input_focus(has_focus); - } - pub(crate) fn show_ctrl_c_quit_hint(&mut self) { self.ctrl_c_quit_hint = true; self.composer @@ -203,11 +197,6 @@ impl BottomPane<'_> { self.app_event_tx.send(AppEvent::RequestRedraw) } - /// Returns true when a popup inside the composer is visible. - pub(crate) fn is_popup_visible(&self) -> bool { - self.active_view.is_none() && self.composer.is_popup_visible() - } - // --- History helpers --- pub(crate) fn set_history_metadata(&mut self, log_id: u64, entry_count: usize) { diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 6744707319..01285c02c9 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -44,7 +44,6 @@ pub(crate) struct ChatWidget<'a> { codex_op_tx: UnboundedSender, conversation_history: ConversationHistoryWidget, bottom_pane: BottomPane<'a>, - input_focus: InputFocus, config: Config, initial_user_message: Option, token_usage: TokenUsage, @@ -55,12 +54,6 @@ pub(crate) struct ChatWidget<'a> { answer_buffer: String, } -#[derive(Clone, Copy, Eq, PartialEq)] -enum InputFocus { - HistoryPane, - BottomPane, -} - struct UserMessage { text: String, image_paths: Vec, @@ -133,7 +126,6 @@ impl ChatWidget<'_> { app_event_tx, has_input_focus: true, }), - input_focus: InputFocus::BottomPane, config, initial_user_message: create_initial_user_message( initial_prompt.unwrap_or_default(), @@ -147,44 +139,17 @@ impl ChatWidget<'_> { pub(crate) fn handle_key_event(&mut self, key_event: KeyEvent) { self.bottom_pane.clear_ctrl_c_quit_hint(); - // Special-case : normally toggles focus between history and bottom panes. - // However, when the slash-command popup is visible we forward the key - // to the bottom pane so it can handle auto-completion. - if matches!(key_event.code, crossterm::event::KeyCode::Tab) - && !self.bottom_pane.is_popup_visible() - { - self.input_focus = match self.input_focus { - InputFocus::HistoryPane => InputFocus::BottomPane, - InputFocus::BottomPane => InputFocus::HistoryPane, - }; - self.conversation_history - .set_input_focus(self.input_focus == InputFocus::HistoryPane); - self.bottom_pane - .set_input_focus(self.input_focus == InputFocus::BottomPane); - self.request_redraw(); - return; - } - match self.input_focus { - InputFocus::HistoryPane => { - let needs_redraw = self.conversation_history.handle_key_event(key_event); - if needs_redraw { - self.request_redraw(); - } + match self.bottom_pane.handle_key_event(key_event) { + InputResult::Submitted(text) => { + self.submit_user_message(text.into()); } - InputFocus::BottomPane => match self.bottom_pane.handle_key_event(key_event) { - InputResult::Submitted(text) => { - self.submit_user_message(text.into()); - } - InputResult::None => {} - }, + InputResult::None => {} } } pub(crate) fn handle_paste(&mut self, text: String) { - if matches!(self.input_focus, InputFocus::BottomPane) { - self.bottom_pane.handle_paste(text); - } + self.bottom_pane.handle_paste(text); } /// Emits the last entry's plain lines from conversation_history, if any. diff --git a/codex-rs/tui/src/conversation_history_widget.rs b/codex-rs/tui/src/conversation_history_widget.rs index d8035eff64..dede0caf5f 100644 --- a/codex-rs/tui/src/conversation_history_widget.rs +++ b/codex-rs/tui/src/conversation_history_widget.rs @@ -5,8 +5,6 @@ use crate::history_cell::PatchEventType; use codex_core::config::Config; use codex_core::protocol::FileChange; use codex_core::protocol::SessionConfiguredEvent; -use crossterm::event::KeyCode; -use crossterm::event::KeyEvent; use ratatui::prelude::*; use ratatui::style::Style; use ratatui::widgets::*; @@ -47,33 +45,6 @@ impl ConversationHistoryWidget { } } - pub(crate) fn set_input_focus(&mut self, has_input_focus: bool) { - self.has_input_focus = has_input_focus; - } - - /// Returns true if it needs a redraw. - pub(crate) fn handle_key_event(&mut self, key_event: KeyEvent) -> bool { - match key_event.code { - KeyCode::Up | KeyCode::Char('k') => { - self.scroll_up(1); - true - } - KeyCode::Down | KeyCode::Char('j') => { - self.scroll_down(1); - true - } - KeyCode::PageUp | KeyCode::Char('b') => { - self.scroll_page_up(); - true - } - KeyCode::PageDown | KeyCode::Char(' ') => { - self.scroll_page_down(); - true - } - _ => false, - } - } - /// Negative delta scrolls up; positive delta scrolls down. pub(crate) fn scroll(&mut self, delta: i32) { match delta.cmp(&0) { @@ -122,53 +93,6 @@ impl ConversationHistoryWidget { } } - /// Scroll up by one full viewport height (Page Up). - fn scroll_page_up(&mut self) { - let viewport_height = self.last_viewport_height.get().max(1); - - // If we are currently in the "stick to bottom" mode, first convert the - // implicit scroll position (`usize::MAX`) into an explicit offset that - // represents the very bottom of the scroll region. This mirrors the - // logic from `scroll_up()`. - if self.scroll_position == usize::MAX { - self.scroll_position = self - .num_rendered_lines - .get() - .saturating_sub(viewport_height); - } - - // Move up by a full page. - self.scroll_position = self.scroll_position.saturating_sub(viewport_height); - } - - /// Scroll down by one full viewport height (Page Down). - fn scroll_page_down(&mut self) { - // Nothing to do if we're already stuck to the bottom. - if self.scroll_position == usize::MAX { - return; - } - - let viewport_height = self.last_viewport_height.get().max(1); - let num_lines = self.num_rendered_lines.get(); - - // Calculate the maximum explicit scroll offset that is still within - // range. This matches the logic in `scroll_down()` and the render - // method. - let max_scroll = num_lines.saturating_sub(viewport_height); - - // Attempt to move down by a full page. - let new_pos = self.scroll_position.saturating_add(viewport_height); - - if new_pos >= max_scroll { - // We have reached (or passed) the bottom – switch back to - // automatic stick‑to‑bottom mode so that subsequent output keeps - // the viewport pinned. - self.scroll_position = usize::MAX; - } else { - self.scroll_position = new_pos; - } - } - pub fn scroll_to_bottom(&mut self) { self.scroll_position = usize::MAX; } From 2405c4002643efed9db402fd2ebe6ddf28fc7ecd Mon Sep 17 00:00:00 2001 From: Michael Bolin Date: Sun, 27 Jul 2025 20:01:35 -0700 Subject: [PATCH 35/58] chore: update Codex::spawn() to return a struct instead of a tuple (#1677) Also update `init_codex()` to return a `struct` instead of a tuple, as well. --- codex-rs/cli/src/proto.rs | 3 ++- codex-rs/core/src/codex.rs | 21 ++++++++++++---- codex-rs/core/src/codex_wrapper.rs | 25 +++++++++++++++++--- codex-rs/core/src/lib.rs | 1 + codex-rs/core/tests/client.rs | 5 ++-- codex-rs/core/tests/live_agent.rs | 3 ++- codex-rs/core/tests/stream_no_completed.rs | 3 ++- codex-rs/exec/src/lib.rs | 12 +++++++--- codex-rs/mcp-server/src/codex_tool_runner.rs | 12 ++++++++-- codex-rs/tui/src/chatwidget.rs | 24 +++++++++++-------- 10 files changed, 81 insertions(+), 28 deletions(-) diff --git a/codex-rs/cli/src/proto.rs b/codex-rs/cli/src/proto.rs index ec395dd108..64b292d50b 100644 --- a/codex-rs/cli/src/proto.rs +++ b/codex-rs/cli/src/proto.rs @@ -4,6 +4,7 @@ use std::sync::Arc; use clap::Parser; use codex_common::CliConfigOverrides; use codex_core::Codex; +use codex_core::CodexSpawnOk; use codex_core::config::Config; use codex_core::config::ConfigOverrides; use codex_core::protocol::Submission; @@ -35,7 +36,7 @@ pub async fn run_main(opts: ProtoCli) -> anyhow::Result<()> { let config = Config::load_with_cli_overrides(overrides_vec, ConfigOverrides::default())?; let ctrl_c = notify_on_sigint(); - let (codex, _init_id, _session_id) = Codex::spawn(config, ctrl_c.clone()).await?; + let CodexSpawnOk { codex, .. } = Codex::spawn(config, ctrl_c.clone()).await?; let codex = Arc::new(codex); // Task that reads JSON lines from stdin and forwards to Submission Queue diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index d9d40a8a47..5764440e79 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -97,11 +97,18 @@ pub struct Codex { rx_event: Receiver, } +/// Wrapper returned by [`Codex::spawn`] containing the spawned [`Codex`], +/// the submission id for the initial `ConfigureSession` request and the +/// unique session id. +pub struct CodexSpawnOk { + pub codex: Codex, + pub init_id: String, + pub session_id: Uuid, +} + impl Codex { - /// Spawn a new [`Codex`] and initialize the session. Returns the instance - /// of `Codex` and the ID of the `SessionInitialized` event that was - /// submitted to start the session. - pub async fn spawn(config: Config, ctrl_c: Arc) -> CodexResult<(Codex, String, Uuid)> { + /// Spawn a new [`Codex`] and initialize the session. + pub async fn spawn(config: Config, ctrl_c: Arc) -> CodexResult { // experimental resume path (undocumented) let resume_path = config.experimental_resume.clone(); info!("resume_path: {resume_path:?}"); @@ -139,7 +146,11 @@ impl Codex { }; let init_id = codex.submit(configure_session).await?; - Ok((codex, init_id, session_id)) + Ok(CodexSpawnOk { + codex, + init_id, + session_id, + }) } /// Submit the `op` wrapped in a `Submission` with a unique ID. diff --git a/codex-rs/core/src/codex_wrapper.rs b/codex-rs/core/src/codex_wrapper.rs index 31f8295ed4..b80579297a 100644 --- a/codex-rs/core/src/codex_wrapper.rs +++ b/codex-rs/core/src/codex_wrapper.rs @@ -1,6 +1,7 @@ use std::sync::Arc; use crate::Codex; +use crate::CodexSpawnOk; use crate::config::Config; use crate::protocol::Event; use crate::protocol::EventMsg; @@ -8,14 +9,27 @@ use crate::util::notify_on_sigint; use tokio::sync::Notify; use uuid::Uuid; +/// Represents an active Codex conversation, including the first event +/// (which is [`EventMsg::SessionConfigured`]). +pub struct CodexConversation { + pub codex: Codex, + pub session_id: Uuid, + pub session_configured: Event, + pub ctrl_c: Arc, +} + /// Spawn a new [`Codex`] and initialize the session. /// /// Returns the wrapped [`Codex`] **and** the `SessionInitialized` event that /// is received as a response to the initial `ConfigureSession` submission so /// that callers can surface the information to the UI. -pub async fn init_codex(config: Config) -> anyhow::Result<(Codex, Event, Arc, Uuid)> { +pub async fn init_codex(config: Config) -> anyhow::Result { let ctrl_c = notify_on_sigint(); - let (codex, init_id, session_id) = Codex::spawn(config, ctrl_c.clone()).await?; + let CodexSpawnOk { + codex, + init_id, + session_id, + } = Codex::spawn(config, ctrl_c.clone()).await?; // The first event must be `SessionInitialized`. Validate and forward it to // the caller so that they can display it in the conversation history. @@ -34,5 +48,10 @@ pub async fn init_codex(config: Config) -> anyhow::Result<(Codex, Event, Arc Result { let mut config = load_default_config_for_test(&codex_home); config.model_provider.request_max_retries = Some(2); config.model_provider.stream_max_retries = Some(2); - let (agent, _init_id, _session_id) = + let CodexSpawnOk { codex: agent, .. } = Codex::spawn(config, std::sync::Arc::new(Notify::new())).await?; Ok(agent) diff --git a/codex-rs/core/tests/stream_no_completed.rs b/codex-rs/core/tests/stream_no_completed.rs index 153330bf10..8e5d83a03e 100644 --- a/codex-rs/core/tests/stream_no_completed.rs +++ b/codex-rs/core/tests/stream_no_completed.rs @@ -4,6 +4,7 @@ use std::time::Duration; use codex_core::Codex; +use codex_core::CodexSpawnOk; use codex_core::ModelProviderInfo; use codex_core::exec::CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR; use codex_core::protocol::EventMsg; @@ -94,7 +95,7 @@ async fn retries_on_early_close() { let codex_home = TempDir::new().unwrap(); let mut config = load_default_config_for_test(&codex_home); config.model_provider = model_provider; - let (codex, _init_id, _session_id) = Codex::spawn(config, ctrl_c).await.unwrap(); + let CodexSpawnOk { codex, .. } = Codex::spawn(config, ctrl_c).await.unwrap(); codex .submit(Op::UserInput { diff --git a/codex-rs/exec/src/lib.rs b/codex-rs/exec/src/lib.rs index 126e92f597..f966d200a1 100644 --- a/codex-rs/exec/src/lib.rs +++ b/codex-rs/exec/src/lib.rs @@ -9,7 +9,8 @@ use std::path::PathBuf; use std::sync::Arc; pub use cli::Cli; -use codex_core::codex_wrapper; +use codex_core::codex_wrapper::CodexConversation; +use codex_core::codex_wrapper::{self}; use codex_core::config::Config; use codex_core::config::ConfigOverrides; use codex_core::config_types::SandboxMode; @@ -155,9 +156,14 @@ pub async fn run_main(cli: Cli, codex_linux_sandbox_exe: Option) -> any .with_writer(std::io::stderr) .try_init(); - let (codex_wrapper, event, ctrl_c, _session_id) = codex_wrapper::init_codex(config).await?; + let CodexConversation { + codex: codex_wrapper, + session_configured, + ctrl_c, + .. + } = codex_wrapper::init_codex(config).await?; let codex = Arc::new(codex_wrapper); - info!("Codex initialized with event: {event:?}"); + info!("Codex initialized with event: {session_configured:?}"); let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::(); { diff --git a/codex-rs/mcp-server/src/codex_tool_runner.rs b/codex-rs/mcp-server/src/codex_tool_runner.rs index f2cacf6c8e..22a36b8366 100644 --- a/codex-rs/mcp-server/src/codex_tool_runner.rs +++ b/codex-rs/mcp-server/src/codex_tool_runner.rs @@ -6,6 +6,7 @@ use std::collections::HashMap; use std::sync::Arc; use codex_core::Codex; +use codex_core::codex_wrapper::CodexConversation; use codex_core::codex_wrapper::init_codex; use codex_core::config::Config as CodexConfig; use codex_core::protocol::AgentMessageEvent; @@ -42,7 +43,12 @@ pub async fn run_codex_tool_session( session_map: Arc>>>, running_requests_id_to_codex_uuid: Arc>>, ) { - let (codex, first_event, _ctrl_c, session_id) = match init_codex(config).await { + let CodexConversation { + codex, + session_configured, + session_id, + .. + } = match init_codex(config).await { Ok(res) => res, Err(e) => { let result = CallToolResult { @@ -66,7 +72,9 @@ pub async fn run_codex_tool_session( drop(session_map); // Send initial SessionConfigured event. - outgoing.send_event_as_notification(&first_event).await; + outgoing + .send_event_as_notification(&session_configured) + .await; // Use the original MCP request ID as the `sub_id` for the Codex submission so that // any events emitted for this tool-call can be correlated with the diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 01285c02c9..5e839d1419 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -1,6 +1,7 @@ use std::path::PathBuf; use std::sync::Arc; +use codex_core::codex_wrapper::CodexConversation; use codex_core::codex_wrapper::init_codex; use codex_core::config::Config; use codex_core::protocol::AgentMessageDeltaEvent; @@ -89,19 +90,22 @@ impl ChatWidget<'_> { // Create the Codex asynchronously so the UI loads as quickly as possible. let config_for_agent_loop = config.clone(); tokio::spawn(async move { - let (codex, session_event, _ctrl_c, _session_id) = - match init_codex(config_for_agent_loop).await { - Ok(vals) => vals, - Err(e) => { - // TODO: surface this error to the user. - tracing::error!("failed to initialize codex: {e}"); - return; - } - }; + let CodexConversation { + codex, + session_configured, + .. + } = match init_codex(config_for_agent_loop).await { + Ok(vals) => vals, + Err(e) => { + // TODO: surface this error to the user. + tracing::error!("failed to initialize codex: {e}"); + return; + } + }; // Forward the captured `SessionInitialized` event that was consumed // inside `init_codex()` so it can be rendered in the UI. - app_event_tx_clone.send(AppEvent::CodexEvent(session_event.clone())); + app_event_tx_clone.send(AppEvent::CodexEvent(session_configured.clone())); let codex = Arc::new(codex); let codex_clone = codex.clone(); tokio::spawn(async move { From 7ecd3153a8e89834ab6237cad601fad265f6c2c8 Mon Sep 17 00:00:00 2001 From: Jeremy Rose <172423086+nornagon-openai@users.noreply.github.com> Date: Mon, 28 Jul 2025 07:45:49 -0700 Subject: [PATCH 36/58] fix: correctly wrap history items (#1685) The overall idea here is: skip ratatui for writing into scrollback, because its primitives are wrong. We want to render full lines of text, that will be wrapped natively by the terminal, and which we never plan to update using ratatui (so the `Buffer` struct is overhead and in fact an inhibition). Instead, we use ANSI scrolling regions (link reference doc to come). Essentially, we: 1. Define a scrolling region that extends from the top of the prompt area all the way to the top of scrollback 2. Scroll that region up by N < (screen_height - viewport_height) lines, in this PR N=1 3. Put our cursor at the top of the newly empty region 4. Print out our new text like normal The terminal interactions here (write_spans and its dependencies) are mostly extracted from ratatui. --- codex-rs/Cargo.lock | 89 ++++---- codex-rs/Cargo.toml | 5 + codex-rs/tui/Cargo.toml | 3 +- codex-rs/tui/src/insert_history.rs | 320 ++++++++++++++--------------- 4 files changed, 208 insertions(+), 209 deletions(-) diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index ba71596ecd..da3bd50a85 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -463,18 +463,18 @@ checksum = "df8670b8c7b9dae1793364eafadf7239c40d669904660c5960d74cfd80b46a53" [[package]] name = "castaway" -version = "0.2.3" +version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0abae9be0aaf9ea96a3b1b8b1b55c602ca751eba1b1500220cea4ecbafe7c0d5" +checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a" dependencies = [ "rustversion", ] [[package]] name = "cc" -version = "1.2.29" +version = "1.2.30" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c1599538de2394445747c8cf7935946e3cc27e9625f889d979bfb2aaf569362" +checksum = "deec109607ca693028562ed836a5f1c4b8bd77755c4e132fc5ce11b0b6211ae7" dependencies = [ "jobserver", "libc", @@ -570,9 +570,9 @@ checksum = "b94f61472cee1439c0b966b47e3aca9ae07e45d070759512cd390ea2bebc6675" [[package]] name = "clipboard-win" -version = "5.4.0" +version = "5.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "15efe7a882b08f34e38556b14f2fb3daa98769d06c7f0c1b076dfd0d983bc892" +checksum = "bde03770d3df201d4fb868f2c9c59e66a3e4e2bd06692a0fe701e7103c7e84d4" dependencies = [ "error-code", ] @@ -978,9 +978,9 @@ dependencies = [ [[package]] name = "crc32fast" -version = "1.4.2" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a97769d94ddab943e4510d138150169a2758b5ef3eb191a9ee688de3e23ef7b3" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" dependencies = [ "cfg-if", ] @@ -1527,7 +1527,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ce92ff622d6dadf7349484f42c93271a0d49b7cc4d466a936405bacbe10aa78" dependencies = [ "cfg-if", - "rustix 1.0.7", + "rustix 1.0.8", "windows-sys 0.59.0", ] @@ -1976,9 +1976,9 @@ dependencies = [ [[package]] name = "hyper-util" -version = "0.1.15" +version = "0.1.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f66d5bd4c6f02bf0542fad85d626775bab9258cf795a4256dcaf3161114d1df" +checksum = "8d9b05277c7e8da2c93a568989bb6207bef0112e8d17df7a6eda4a3cf143bc5e" dependencies = [ "base64 0.22.1", "bytes", @@ -1992,7 +1992,7 @@ dependencies = [ "libc", "percent-encoding", "pin-project-lite", - "socket2", + "socket2 0.6.0", "system-configuration", "tokio", "tower-service", @@ -2245,9 +2245,9 @@ dependencies = [ [[package]] name = "instability" -version = "0.3.7" +version = "0.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0bf9fed6d91cfb734e7476a06bde8300a1b94e217e1b523b6f0cd1a01998c71d" +checksum = "435d80800b936787d62688c927b6490e887c7ef5ff9ce922c6c6050fca75eb9a" dependencies = [ "darling", "indoc", @@ -2278,9 +2278,9 @@ dependencies = [ [[package]] name = "io-uring" -version = "0.7.8" +version = "0.7.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b86e202f00093dcba4275d4636b93ef9dd75d025ae560d2521b45ea28ab49013" +checksum = "d93587f37623a1a17d94ef2bc9ada592f5465fe7732084ab7beefabe5c77c0c4" dependencies = [ "bitflags 2.9.1", "cfg-if", @@ -2484,9 +2484,9 @@ dependencies = [ [[package]] name = "libredox" -version = "0.1.4" +version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1580801010e535496706ba011c15f8532df6b42297d2e471fec38ceadd8c0638" +checksum = "4488594b9328dee448adb906d8b126d9b7deb7cf5c22161ee591610bb1be83c0" dependencies = [ "bitflags 2.9.1", "libc", @@ -3359,8 +3359,7 @@ dependencies = [ [[package]] name = "ratatui" version = "0.29.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eabd94c2f37801c20583fc49dd5cd6b0ba68c716787c2dd6ed18571e1e63117b" +source = "git+https://github.com/nornagon/ratatui?branch=nornagon-v0.29.0-patch#bca287ddc5d38fe088c79e2eda22422b96226f2e" dependencies = [ "bitflags 2.9.1", "cassowary", @@ -3465,9 +3464,9 @@ dependencies = [ [[package]] name = "redox_syscall" -version = "0.5.13" +version = "0.5.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d04b7d0ee6b4a0207a0a7adb104d23ecb0b47d6beae7152d0fa34b692b29fd6" +checksum = "7e8af0dde094006011e6a740d4879319439489813bd0bcdc7d821beaeeff48ec" dependencies = [ "bitflags 2.9.1", ] @@ -3615,9 +3614,9 @@ dependencies = [ [[package]] name = "rgb" -version = "0.8.51" +version = "0.8.52" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a457e416a0f90d246a4c3288bd7a25b2304ca727f253f95be383dd17af56be8f" +checksum = "0c6a884d2998352bb4daf0183589aec883f16a6da1f4dde84d8e2e9a5409a1ce" [[package]] name = "ring" @@ -3693,22 +3692,22 @@ dependencies = [ [[package]] name = "rustix" -version = "1.0.7" +version = "1.0.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c71e83d6afe7ff64890ec6b71d6a69bb8a610ab78ce364b3352876bb4c801266" +checksum = "11181fbabf243db407ef8df94a6ce0b2f9a733bd8be4ad02b4eda9602296cac8" dependencies = [ "bitflags 2.9.1", "errno", "libc", "linux-raw-sys 0.9.4", - "windows-sys 0.59.0", + "windows-sys 0.60.2", ] [[package]] name = "rustls" -version = "0.23.28" +version = "0.23.29" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7160e3e10bf4535308537f3c4e1641468cd0e485175d6163087c0393c7d46643" +checksum = "2491382039b29b9b11ff08b76ff6c97cf287671dbb74f0be44bda389fffe9bd1" dependencies = [ "once_cell", "rustls-pki-types", @@ -3728,9 +3727,9 @@ dependencies = [ [[package]] name = "rustls-webpki" -version = "0.103.3" +version = "0.103.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e4a72fe2bcf7a6ac6fd7d0b9e5cb68aeb7d4c0a0271730218b3e92d43b4eb435" +checksum = "0a17884ae0c1b773f1ccd2bd4a8c72f16da897310a98b0e84bf349ad5ead92fc" dependencies = [ "ring", "rustls-pki-types", @@ -3956,9 +3955,9 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.140" +version = "1.0.141" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "20068b6e96dc6c9bd23e01df8827e6c7e1f2fddd43c21810382803c136b99373" +checksum = "30b9eff21ebe718216c6ec64e1d9ac57087aad11efc64e32002bce4a0d4c03d3" dependencies = [ "indexmap 2.10.0", "itoa", @@ -4151,6 +4150,16 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "socket2" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "233504af464074f9d066d7b5416c5f9b894a5862a6506e306f7b816cdd6f1807" +dependencies = [ + "libc", + "windows-sys 0.59.0", +] + [[package]] name = "stable_deref_trait" version = "1.2.0" @@ -4442,7 +4451,7 @@ dependencies = [ "fastrand", "getrandom 0.3.3", "once_cell", - "rustix 1.0.7", + "rustix 1.0.8", "windows-sys 0.59.0", ] @@ -4463,7 +4472,7 @@ version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "45c6481c4829e4cc63825e62c49186a34538b7b2750b73b266581ffb612fb5ed" dependencies = [ - "rustix 1.0.7", + "rustix 1.0.8", "windows-sys 0.59.0", ] @@ -4609,7 +4618,7 @@ dependencies = [ "pin-project-lite", "signal-hook-registry", "slab", - "socket2", + "socket2 0.5.10", "tokio-macros", "windows-sys 0.52.0", ] @@ -4751,9 +4760,9 @@ dependencies = [ [[package]] name = "toml_writer" -version = "1.0.0" +version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b679217f2848de74cabd3e8fc5e6d66f40b7da40f8e1954d92054d9010690fd5" +checksum = "fcc842091f2def52017664b53082ecbbeb5c7731092bad69d2c63050401dfd64" [[package]] name = "tower" @@ -5575,9 +5584,9 @@ checksum = "271414315aff87387382ec3d271b52d7ae78726f5d44ac98b4f4030c91880486" [[package]] name = "winnow" -version = "0.7.11" +version = "0.7.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "74c7b26e3480b707944fc872477815d29a8e429d2f93a1ce000f5fa84a15cbcd" +checksum = "f3edebf492c8125044983378ecb5766203ad3b4c2f7a922bd7dd207f6d443e95" dependencies = [ "memchr", ] diff --git a/codex-rs/Cargo.toml b/codex-rs/Cargo.toml index eba43e548b..6f89e8faa7 100644 --- a/codex-rs/Cargo.toml +++ b/codex-rs/Cargo.toml @@ -40,3 +40,8 @@ strip = "symbols" # See https://github.com/openai/codex/issues/1411 for details. codegen-units = 1 + +[patch.crates-io] +# ratatui = { path = "../../ratatui" } +ratatui = { git = "https://github.com/nornagon/ratatui", branch = "nornagon-v0.29.0-patch" } + diff --git a/codex-rs/tui/Cargo.toml b/codex-rs/tui/Cargo.toml index 9d73e3b386..b88ac8a080 100644 --- a/codex-rs/tui/Cargo.toml +++ b/codex-rs/tui/Cargo.toml @@ -35,8 +35,9 @@ lazy_static = "1" mcp-types = { path = "../mcp-types" } path-clean = "1.0.1" ratatui = { version = "0.29.0", features = [ - "unstable-widget-ref", + "scrolling-regions", "unstable-rendered-line-info", + "unstable-widget-ref", ] } ratatui-image = "8.0.0" regex-lite = "0.1" diff --git a/codex-rs/tui/src/insert_history.rs b/codex-rs/tui/src/insert_history.rs index 247e024cb0..7948436cd8 100644 --- a/codex-rs/tui/src/insert_history.rs +++ b/codex-rs/tui/src/insert_history.rs @@ -1,178 +1,162 @@ +use std::io; +use std::io::Write; + use crate::tui; -use ratatui::layout::Rect; -use ratatui::style::Style; +use crossterm::queue; +use crossterm::style::Color as CColor; +use crossterm::style::Colors; +use crossterm::style::Print; +use crossterm::style::SetAttribute; +use crossterm::style::SetBackgroundColor; +use crossterm::style::SetColors; +use crossterm::style::SetForegroundColor; +use ratatui::layout::Position; +use ratatui::prelude::Backend; +use ratatui::style::Color; +use ratatui::style::Modifier; use ratatui::text::Line; use ratatui::text::Span; -use ratatui::widgets::Paragraph; -use ratatui::widgets::Widget; -use unicode_width::UnicodeWidthChar; - -/// Insert a batch of history lines into the terminal scrollback above the -/// inline viewport. -/// -/// The incoming `lines` are the logical lines supplied by the -/// `ConversationHistory`. They may contain embedded newlines and arbitrary -/// runs of whitespace inside individual [`Span`]s. All of that must be -/// normalised before writing to the backing terminal buffer because the -/// ratatui [`Paragraph`] widget does not perform soft‑wrapping when used in -/// conjunction with [`Terminal::insert_before`]. -/// -/// This function performs a minimal wrapping / normalisation pass: -/// -/// * A terminal width is determined via `Terminal::size()` (falling back to -/// 80 columns if the size probe fails). -/// * Each logical line is broken into words and whitespace. Consecutive -/// whitespace is collapsed to a single space; leading whitespace is -/// discarded. -/// * Words that do not fit on the current line cause a soft wrap. Extremely -/// long words (longer than the terminal width) are split character by -/// character so they still populate the display instead of overflowing the -/// line. -/// * Explicit `\n` characters inside a span force a hard line break. -/// * Empty lines (including a trailing newline at the end of the batch) are -/// preserved so vertical spacing remains faithful to the logical history. -/// -/// Finally the physical lines are rendered directly into the terminal's -/// scrollback region using [`Terminal::insert_before`]. Any backend error is -/// ignored: failing to insert history is non‑fatal and a subsequent redraw -/// will eventually repaint a consistent view. -fn display_width(s: &str) -> usize { - s.chars() - .map(|c| UnicodeWidthChar::width(c).unwrap_or(0)) - .sum() -} - -struct LineBuilder { - term_width: usize, - spans: Vec>, - width: usize, -} - -impl LineBuilder { - fn new(term_width: usize) -> Self { - Self { - term_width, - spans: Vec::new(), - width: 0, - } - } - - fn flush_line(&mut self, out: &mut Vec>) { - out.push(Line::from(std::mem::take(&mut self.spans))); - self.width = 0; - } - - fn push_segment(&mut self, text: String, style: Style) { - self.width += display_width(&text); - self.spans.push(Span::styled(text, style)); - } - - fn push_word(&mut self, word: &mut String, style: Style, out: &mut Vec>) { - if word.is_empty() { - return; - } - let w_len = display_width(word); - if self.width > 0 && self.width + w_len > self.term_width { - self.flush_line(out); - } - if w_len > self.term_width && self.width == 0 { - // Split an overlong word across multiple lines. - let mut cur = String::new(); - let mut cur_w = 0; - for ch in word.chars() { - let ch_w = UnicodeWidthChar::width(ch).unwrap_or(0); - if cur_w + ch_w > self.term_width && cur_w > 0 { - self.push_segment(cur.clone(), style); - self.flush_line(out); - cur.clear(); - cur_w = 0; - } - cur.push(ch); - cur_w += ch_w; - } - if !cur.is_empty() { - self.push_segment(cur, style); - } - } else { - self.push_segment(word.clone(), style); - } - word.clear(); - } - - fn consume_whitespace(&mut self, ws: &mut String, style: Style, out: &mut Vec>) { - if ws.is_empty() { - return; - } - let space_w = display_width(ws); - if self.width > 0 && self.width + space_w > self.term_width { - self.flush_line(out); - } - if self.width > 0 { - self.push_segment(" ".to_string(), style); - } - ws.clear(); - } -} pub(crate) fn insert_history_lines(terminal: &mut tui::Tui, lines: Vec>) { - let term_width = terminal.size().map(|a| a.width).unwrap_or(80) as usize; - let mut physical: Vec> = Vec::new(); - - for logical in lines.into_iter() { - if logical.spans.is_empty() { - physical.push(logical); - continue; - } - - let mut builder = LineBuilder::new(term_width); - let mut buf_space = String::new(); - - for span in logical.spans.into_iter() { - let style = span.style; - let mut buf_word = String::new(); - - for ch in span.content.chars() { - if ch == '\n' { - builder.push_word(&mut buf_word, style, &mut physical); - buf_space.clear(); - builder.flush_line(&mut physical); - continue; - } - if ch.is_whitespace() { - builder.push_word(&mut buf_word, style, &mut physical); - buf_space.push(ch); - } else { - builder.consume_whitespace(&mut buf_space, style, &mut physical); - buf_word.push(ch); - } - if builder.width >= term_width { - builder.flush_line(&mut physical); - } - } - builder.push_word(&mut buf_word, style, &mut physical); - // whitespace intentionally left to allow collapsing across spans - } - if !builder.spans.is_empty() { - physical.push(Line::from(std::mem::take(&mut builder.spans))); + let screen_height = terminal + .backend() + .size() + .map(|s| s.height) + .unwrap_or(0xffffu16); + let mut area = terminal.get_frame().area(); + // We scroll up one line at a time because we can't position the cursor + // above the top of the screen. i.e. if + // lines.len() > screen_height - area.top() + // we would need to print the first line above the top of the screen, which + // can't be done. + for line in lines.into_iter() { + // 1. Scroll everything above the viewport up by one line + if area.bottom() >= screen_height { + let top = area.top(); + terminal.backend_mut().scroll_region_up(0..top, 1).ok(); + // 2. Move the cursor to the blank line + terminal.set_cursor_position(Position::new(0, top - 1)).ok(); } else { - // Preserve explicit blank line (e.g. due to a trailing newline). - physical.push(Line::from(Vec::>::new())); + // If the viewport isn't at the bottom of the screen, scroll down instead + terminal + .backend_mut() + .scroll_region_down(area.top()..area.bottom() + 1, 1) + .ok(); + terminal + .set_cursor_position(Position::new(0, area.top())) + .ok(); + area.y += 1; } + // 3. Write the line + write_spans(&mut std::io::stdout(), line.iter()).ok(); + } + terminal.set_viewport_area(area); +} + +struct ModifierDiff { + pub from: Modifier, + pub to: Modifier, +} + +impl ModifierDiff { + fn queue(self, mut w: W) -> io::Result<()> + where + W: io::Write, + { + use crossterm::style::Attribute as CAttribute; + let removed = self.from - self.to; + if removed.contains(Modifier::REVERSED) { + queue!(w, SetAttribute(CAttribute::NoReverse))?; + } + if removed.contains(Modifier::BOLD) { + queue!(w, SetAttribute(CAttribute::NormalIntensity))?; + if self.to.contains(Modifier::DIM) { + queue!(w, SetAttribute(CAttribute::Dim))?; + } + } + if removed.contains(Modifier::ITALIC) { + queue!(w, SetAttribute(CAttribute::NoItalic))?; + } + if removed.contains(Modifier::UNDERLINED) { + queue!(w, SetAttribute(CAttribute::NoUnderline))?; + } + if removed.contains(Modifier::DIM) { + queue!(w, SetAttribute(CAttribute::NormalIntensity))?; + } + if removed.contains(Modifier::CROSSED_OUT) { + queue!(w, SetAttribute(CAttribute::NotCrossedOut))?; + } + if removed.contains(Modifier::SLOW_BLINK) || removed.contains(Modifier::RAPID_BLINK) { + queue!(w, SetAttribute(CAttribute::NoBlink))?; + } + + let added = self.to - self.from; + if added.contains(Modifier::REVERSED) { + queue!(w, SetAttribute(CAttribute::Reverse))?; + } + if added.contains(Modifier::BOLD) { + queue!(w, SetAttribute(CAttribute::Bold))?; + } + if added.contains(Modifier::ITALIC) { + queue!(w, SetAttribute(CAttribute::Italic))?; + } + if added.contains(Modifier::UNDERLINED) { + queue!(w, SetAttribute(CAttribute::Underlined))?; + } + if added.contains(Modifier::DIM) { + queue!(w, SetAttribute(CAttribute::Dim))?; + } + if added.contains(Modifier::CROSSED_OUT) { + queue!(w, SetAttribute(CAttribute::CrossedOut))?; + } + if added.contains(Modifier::SLOW_BLINK) { + queue!(w, SetAttribute(CAttribute::SlowBlink))?; + } + if added.contains(Modifier::RAPID_BLINK) { + queue!(w, SetAttribute(CAttribute::RapidBlink))?; + } + + Ok(()) + } +} + +fn write_spans<'a, I>(mut writer: &mut impl Write, content: I) -> io::Result<()> +where + I: Iterator>, +{ + let mut fg = Color::Reset; + let mut bg = Color::Reset; + let mut modifier = Modifier::empty(); + for span in content { + let mut next_modifier = modifier; + next_modifier.insert(span.style.add_modifier); + next_modifier.remove(span.style.sub_modifier); + if next_modifier != modifier { + let diff = ModifierDiff { + from: modifier, + to: next_modifier, + }; + diff.queue(&mut writer)?; + modifier = next_modifier; + } + let next_fg = span.style.fg.unwrap_or(Color::Reset); + let next_bg = span.style.bg.unwrap_or(Color::Reset); + if next_fg != fg || next_bg != bg { + queue!( + writer, + SetColors(Colors::new(next_fg.into(), next_bg.into())) + )?; + fg = next_fg; + bg = next_bg; + } + + queue!(writer, Print(span.content.clone()))?; } - let total = physical.len() as u16; - terminal - .insert_before(total, |buf| { - let width = buf.area.width; - for (i, line) in physical.into_iter().enumerate() { - let area = Rect { - x: 0, - y: i as u16, - width, - height: 1, - }; - Paragraph::new(line).render(area, buf); - } - }) - .ok(); + queue!( + writer, + SetForegroundColor(CColor::Reset), + SetBackgroundColor(CColor::Reset), + SetAttribute(crossterm::style::Attribute::Reset), + ) } From 9102255854eab4310d7d90ab64215bf3f2e06850 Mon Sep 17 00:00:00 2001 From: Michael Bolin Date: Mon, 28 Jul 2025 08:31:24 -0700 Subject: [PATCH 37/58] fix: move arg0 handling out of codex-linux-sandbox and into its own crate (#1697) --- codex-rs/Cargo.lock | 20 ++++++--- codex-rs/Cargo.toml | 1 + codex-rs/arg0/Cargo.toml | 18 ++++++++ codex-rs/arg0/src/lib.rs | 68 +++++++++++++++++++++++++++++++ codex-rs/cli/Cargo.toml | 2 +- codex-rs/cli/src/main.rs | 3 +- codex-rs/exec/Cargo.toml | 2 +- codex-rs/exec/src/main.rs | 3 +- codex-rs/linux-sandbox/Cargo.toml | 14 +++---- codex-rs/linux-sandbox/src/lib.rs | 65 +---------------------------- codex-rs/mcp-server/Cargo.toml | 2 +- codex-rs/mcp-server/src/main.rs | 3 +- codex-rs/tui/Cargo.toml | 2 +- codex-rs/tui/src/main.rs | 3 +- 14 files changed, 121 insertions(+), 85 deletions(-) create mode 100644 codex-rs/arg0/Cargo.toml create mode 100644 codex-rs/arg0/src/lib.rs diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index da3bd50a85..f3903d6ea6 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -605,6 +605,17 @@ dependencies = [ "tree-sitter-bash", ] +[[package]] +name = "codex-arg0" +version = "0.0.0" +dependencies = [ + "anyhow", + "codex-core", + "codex-linux-sandbox", + "dotenvy", + "tokio", +] + [[package]] name = "codex-chatgpt" version = "0.0.0" @@ -628,11 +639,11 @@ dependencies = [ "anyhow", "clap", "clap_complete", + "codex-arg0", "codex-chatgpt", "codex-common", "codex-core", "codex-exec", - "codex-linux-sandbox", "codex-login", "codex-mcp-server", "codex-tui", @@ -709,9 +720,9 @@ dependencies = [ "anyhow", "chrono", "clap", + "codex-arg0", "codex-common", "codex-core", - "codex-linux-sandbox", "owo-colors", "serde_json", "shlex", @@ -761,7 +772,6 @@ dependencies = [ "clap", "codex-common", "codex-core", - "dotenvy", "landlock", "libc", "seccompiler", @@ -799,8 +809,8 @@ version = "0.0.0" dependencies = [ "anyhow", "assert_cmd", + "codex-arg0", "codex-core", - "codex-linux-sandbox", "mcp-types", "mcp_test_support", "pretty_assertions", @@ -826,10 +836,10 @@ dependencies = [ "base64 0.22.1", "clap", "codex-ansi-escape", + "codex-arg0", "codex-common", "codex-core", "codex-file-search", - "codex-linux-sandbox", "codex-login", "color-eyre", "crossterm", diff --git a/codex-rs/Cargo.toml b/codex-rs/Cargo.toml index 6f89e8faa7..51b2b5cc12 100644 --- a/codex-rs/Cargo.toml +++ b/codex-rs/Cargo.toml @@ -3,6 +3,7 @@ resolver = "2" members = [ "ansi-escape", "apply-patch", + "arg0", "cli", "common", "core", diff --git a/codex-rs/arg0/Cargo.toml b/codex-rs/arg0/Cargo.toml new file mode 100644 index 0000000000..9ad1896746 --- /dev/null +++ b/codex-rs/arg0/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "codex-arg0" +version = { workspace = true } +edition = "2024" + +[lib] +name = "codex_arg0" +path = "src/lib.rs" + +[lints] +workspace = true + +[dependencies] +anyhow = "1" +codex-core = { path = "../core" } +codex-linux-sandbox = { path = "../linux-sandbox" } +dotenvy = "0.15.7" +tokio = { version = "1", features = ["rt-multi-thread"] } diff --git a/codex-rs/arg0/src/lib.rs b/codex-rs/arg0/src/lib.rs new file mode 100644 index 0000000000..86b98c7d30 --- /dev/null +++ b/codex-rs/arg0/src/lib.rs @@ -0,0 +1,68 @@ +use std::future::Future; +use std::path::Path; +use std::path::PathBuf; + +/// While we want to deploy the Codex CLI as a single executable for simplicity, +/// we also want to expose some of its functionality as distinct CLIs, so we use +/// the "arg0 trick" to determine which CLI to dispatch. This effectively allows +/// us to simulate deploying multiple executables as a single binary on Mac and +/// Linux (but not Windows). +/// +/// When the current executable is invoked through the hard-link or alias named +/// `codex-linux-sandbox` we *directly* execute +/// [`codex_linux_sandbox::run_main`] (which never returns). Otherwise we: +/// +/// 1. Use [`dotenvy::from_path`] and [`dotenvy::dotenv`] to modify the +/// environment before creating any threads. +/// 2. Construct a Tokio multi-thread runtime. +/// 3. Derive the path to the current executable (so children can re-invoke the +/// sandbox) when running on Linux. +/// 4. Execute the provided async `main_fn` inside that runtime, forwarding any +/// error. Note that `main_fn` receives `codex_linux_sandbox_exe: +/// Option`, as an argument, which is generally needed as part of +/// constructing [`codex_core::config::Config`]. +/// +/// This function should be used to wrap any `main()` function in binary crates +/// in this workspace that depends on these helper CLIs. +pub fn arg0_dispatch_or_else(main_fn: F) -> anyhow::Result<()> +where + F: FnOnce(Option) -> Fut, + Fut: Future>, +{ + // Determine if we were invoked via the special alias. + let argv0 = std::env::args().next().unwrap_or_default(); + let exe_name = Path::new(&argv0) + .file_name() + .and_then(|s| s.to_str()) + .unwrap_or(""); + + if exe_name == "codex-linux-sandbox" { + // Safety: [`run_main`] never returns. + codex_linux_sandbox::run_main(); + } + + // This modifies the environment, which is not thread-safe, so do this + // before creating any threads/the Tokio runtime. + load_dotenv(); + + // Regular invocation – create a Tokio runtime and execute the provided + // async entry-point. + let runtime = tokio::runtime::Runtime::new()?; + runtime.block_on(async move { + let codex_linux_sandbox_exe: Option = if cfg!(target_os = "linux") { + std::env::current_exe().ok() + } else { + None + }; + + main_fn(codex_linux_sandbox_exe).await + }) +} + +/// Load env vars from ~/.codex/.env and `$(pwd)/.env`. +fn load_dotenv() { + if let Ok(codex_home) = codex_core::config::find_codex_home() { + dotenvy::from_path(codex_home.join(".env")).ok(); + } + dotenvy::dotenv().ok(); +} diff --git a/codex-rs/cli/Cargo.toml b/codex-rs/cli/Cargo.toml index 943788157b..ab98764bed 100644 --- a/codex-rs/cli/Cargo.toml +++ b/codex-rs/cli/Cargo.toml @@ -18,12 +18,12 @@ workspace = true anyhow = "1" clap = { version = "4", features = ["derive"] } clap_complete = "4" +codex-arg0 = { path = "../arg0" } codex-chatgpt = { path = "../chatgpt" } codex-core = { path = "../core" } codex-common = { path = "../common", features = ["cli"] } codex-exec = { path = "../exec" } codex-login = { path = "../login" } -codex-linux-sandbox = { path = "../linux-sandbox" } codex-mcp-server = { path = "../mcp-server" } codex-tui = { path = "../tui" } serde_json = "1" diff --git a/codex-rs/cli/src/main.rs b/codex-rs/cli/src/main.rs index 7916a7dc79..efda03bda4 100644 --- a/codex-rs/cli/src/main.rs +++ b/codex-rs/cli/src/main.rs @@ -2,6 +2,7 @@ use clap::CommandFactory; use clap::Parser; use clap_complete::Shell; use clap_complete::generate; +use codex_arg0::arg0_dispatch_or_else; use codex_chatgpt::apply_command::ApplyCommand; use codex_chatgpt::apply_command::run_apply_command; use codex_cli::LandlockCommand; @@ -92,7 +93,7 @@ struct LoginCommand { } fn main() -> anyhow::Result<()> { - codex_linux_sandbox::run_with_sandbox(|codex_linux_sandbox_exe| async move { + arg0_dispatch_or_else(|codex_linux_sandbox_exe| async move { cli_main(codex_linux_sandbox_exe).await?; Ok(()) }) diff --git a/codex-rs/exec/Cargo.toml b/codex-rs/exec/Cargo.toml index ed01b78ec8..c9d94deb5a 100644 --- a/codex-rs/exec/Cargo.toml +++ b/codex-rs/exec/Cargo.toml @@ -18,13 +18,13 @@ workspace = true anyhow = "1" chrono = "0.4.40" clap = { version = "4", features = ["derive"] } +codex-arg0 = { path = "../arg0" } codex-core = { path = "../core" } codex-common = { path = "../common", features = [ "cli", "elapsed", "sandbox_summary", ] } -codex-linux-sandbox = { path = "../linux-sandbox" } owo-colors = "4.2.0" serde_json = "1" shlex = "1.3.0" diff --git a/codex-rs/exec/src/main.rs b/codex-rs/exec/src/main.rs index 3a8e1f9411..03ee533ea9 100644 --- a/codex-rs/exec/src/main.rs +++ b/codex-rs/exec/src/main.rs @@ -10,6 +10,7 @@ //! This allows us to ship a completely separate set of functionality as part //! of the `codex-exec` binary. use clap::Parser; +use codex_arg0::arg0_dispatch_or_else; use codex_common::CliConfigOverrides; use codex_exec::Cli; use codex_exec::run_main; @@ -24,7 +25,7 @@ struct TopCli { } fn main() -> anyhow::Result<()> { - codex_linux_sandbox::run_with_sandbox(|codex_linux_sandbox_exe| async move { + arg0_dispatch_or_else(|codex_linux_sandbox_exe| async move { let top_cli = TopCli::parse(); // Merge root-level overrides into inner CLI struct so downstream logic remains unchanged. let mut inner = top_cli.inner; diff --git a/codex-rs/linux-sandbox/Cargo.toml b/codex-rs/linux-sandbox/Cargo.toml index 5c2dea6083..4b173ea17a 100644 --- a/codex-rs/linux-sandbox/Cargo.toml +++ b/codex-rs/linux-sandbox/Cargo.toml @@ -14,15 +14,16 @@ path = "src/lib.rs" [lints] workspace = true -[dependencies] +[target.'cfg(target_os = "linux")'.dependencies] anyhow = "1" clap = { version = "4", features = ["derive"] } codex-common = { path = "../common", features = ["cli"] } codex-core = { path = "../core" } -dotenvy = "0.15.7" -tokio = { version = "1", features = ["rt-multi-thread"] } +libc = "0.2.172" +landlock = "0.4.1" +seccompiler = "0.5.0" -[dev-dependencies] +[target.'cfg(target_os = "linux")'.dev-dependencies] tempfile = "3" tokio = { version = "1", features = [ "io-std", @@ -31,8 +32,3 @@ tokio = { version = "1", features = [ "rt-multi-thread", "signal", ] } - -[target.'cfg(target_os = "linux")'.dependencies] -libc = "0.2.172" -landlock = "0.4.1" -seccompiler = "0.5.0" diff --git a/codex-rs/linux-sandbox/src/lib.rs b/codex-rs/linux-sandbox/src/lib.rs index 960678467c..80453c7f96 100644 --- a/codex-rs/linux-sandbox/src/lib.rs +++ b/codex-rs/linux-sandbox/src/lib.rs @@ -4,72 +4,11 @@ mod landlock; mod linux_run_main; #[cfg(target_os = "linux")] -pub use linux_run_main::run_main; - -use std::future::Future; -use std::path::PathBuf; - -/// Helper that consolidates the common boilerplate found in several Codex -/// binaries (`codex`, `codex-exec`, `codex-tui`) around dispatching to the -/// `codex-linux-sandbox` sub-command. -/// -/// When the current executable is invoked through the hard-link or alias -/// named `codex-linux-sandbox` we *directly* execute [`run_main`](crate::run_main) -/// (which never returns). Otherwise we: -/// 1. Construct a Tokio multi-thread runtime. -/// 2. Derive the path to the current executable (so children can re-invoke -/// the sandbox) when running on Linux. -/// 3. Execute the provided async `main_fn` inside that runtime, forwarding -/// any error. -/// -/// This function eliminates duplicated code across the various `main.rs` -/// entry-points. -pub fn run_with_sandbox(main_fn: F) -> anyhow::Result<()> -where - F: FnOnce(Option) -> Fut, - Fut: Future>, -{ - use std::path::Path; - - // Determine if we were invoked via the special alias. - let argv0 = std::env::args().next().unwrap_or_default(); - let exe_name = Path::new(&argv0) - .file_name() - .and_then(|s| s.to_str()) - .unwrap_or(""); - - if exe_name == "codex-linux-sandbox" { - // Safety: [`run_main`] never returns. - crate::run_main(); - } - - // This modifies the environment, which is not thread-safe, so do this - // before creating any threads/the Tokio runtime. - load_dotenv(); - - // Regular invocation – create a Tokio runtime and execute the provided - // async entry-point. - let runtime = tokio::runtime::Runtime::new()?; - runtime.block_on(async move { - let codex_linux_sandbox_exe: Option = if cfg!(target_os = "linux") { - std::env::current_exe().ok() - } else { - None - }; - - main_fn(codex_linux_sandbox_exe).await - }) +pub fn run_main() -> ! { + linux_run_main::run_main(); } #[cfg(not(target_os = "linux"))] pub fn run_main() -> ! { panic!("codex-linux-sandbox is only supported on Linux"); } - -/// Load env vars from ~/.codex/.env and `$(pwd)/.env`. -fn load_dotenv() { - if let Ok(codex_home) = codex_core::config::find_codex_home() { - dotenvy::from_path(codex_home.join(".env")).ok(); - } - dotenvy::dotenv().ok(); -} diff --git a/codex-rs/mcp-server/Cargo.toml b/codex-rs/mcp-server/Cargo.toml index 1088b92481..488ee6a67c 100644 --- a/codex-rs/mcp-server/Cargo.toml +++ b/codex-rs/mcp-server/Cargo.toml @@ -16,8 +16,8 @@ workspace = true [dependencies] anyhow = "1" +codex-arg0 = { path = "../arg0" } codex-core = { path = "../core" } -codex-linux-sandbox = { path = "../linux-sandbox" } mcp-types = { path = "../mcp-types" } schemars = "0.8.22" serde = { version = "1", features = ["derive"] } diff --git a/codex-rs/mcp-server/src/main.rs b/codex-rs/mcp-server/src/main.rs index 51c46c44d2..60ddeeab41 100644 --- a/codex-rs/mcp-server/src/main.rs +++ b/codex-rs/mcp-server/src/main.rs @@ -1,7 +1,8 @@ +use codex_arg0::arg0_dispatch_or_else; use codex_mcp_server::run_main; fn main() -> anyhow::Result<()> { - codex_linux_sandbox::run_with_sandbox(|codex_linux_sandbox_exe| async move { + arg0_dispatch_or_else(|codex_linux_sandbox_exe| async move { run_main(codex_linux_sandbox_exe).await?; Ok(()) }) diff --git a/codex-rs/tui/Cargo.toml b/codex-rs/tui/Cargo.toml index b88ac8a080..2f150921fb 100644 --- a/codex-rs/tui/Cargo.toml +++ b/codex-rs/tui/Cargo.toml @@ -19,6 +19,7 @@ anyhow = "1" base64 = "0.22.1" clap = { version = "4", features = ["derive"] } codex-ansi-escape = { path = "../ansi-escape" } +codex-arg0 = { path = "../arg0" } codex-core = { path = "../core" } codex-common = { path = "../common", features = [ "cli", @@ -26,7 +27,6 @@ codex-common = { path = "../common", features = [ "sandbox_summary", ] } codex-file-search = { path = "../file-search" } -codex-linux-sandbox = { path = "../linux-sandbox" } codex-login = { path = "../login" } color-eyre = "0.6.3" crossterm = { version = "0.28.1", features = ["bracketed-paste"] } diff --git a/codex-rs/tui/src/main.rs b/codex-rs/tui/src/main.rs index fdb3cdaf82..480e56e88e 100644 --- a/codex-rs/tui/src/main.rs +++ b/codex-rs/tui/src/main.rs @@ -1,4 +1,5 @@ use clap::Parser; +use codex_arg0::arg0_dispatch_or_else; use codex_common::CliConfigOverrides; use codex_tui::Cli; use codex_tui::run_main; @@ -13,7 +14,7 @@ struct TopCli { } fn main() -> anyhow::Result<()> { - codex_linux_sandbox::run_with_sandbox(|codex_linux_sandbox_exe| async move { + arg0_dispatch_or_else(|codex_linux_sandbox_exe| async move { let top_cli = TopCli::parse(); let mut inner = top_cli.inner; inner From fcd197d5962347db2abc21043b806fb0b560820c Mon Sep 17 00:00:00 2001 From: Michael Bolin Date: Mon, 28 Jul 2025 08:52:18 -0700 Subject: [PATCH 38/58] fix: use std::env::args_os instead of std::env::args (#1698) Apparently `std::env::args()` will panic during iteration if any argument to the process is not valid Unicode: https://doc.rust-lang.org/std/env/fn.args.html Let's avoid the risk and just go with `std::env::args_os()`. --- [//]: # (BEGIN SAPLING FOOTER) Stack created with [Sapling](https://sapling-scm.com). Best reviewed with [ReviewStack](https://reviewstack.dev/openai/codex/pull/1698). * #1705 * #1703 * #1702 * __->__ #1698 * #1697 --- codex-rs/arg0/src/lib.rs | 2 +- codex-rs/core/src/mcp_connection_manager.rs | 8 +++++++- codex-rs/mcp-client/src/main.rs | 3 ++- codex-rs/mcp-client/src/mcp_client.rs | 5 +++-- 4 files changed, 13 insertions(+), 5 deletions(-) diff --git a/codex-rs/arg0/src/lib.rs b/codex-rs/arg0/src/lib.rs index 86b98c7d30..624583b8aa 100644 --- a/codex-rs/arg0/src/lib.rs +++ b/codex-rs/arg0/src/lib.rs @@ -30,7 +30,7 @@ where Fut: Future>, { // Determine if we were invoked via the special alias. - let argv0 = std::env::args().next().unwrap_or_default(); + let argv0 = std::env::args_os().next().unwrap_or_default(); let exe_name = Path::new(&argv0) .file_name() .and_then(|s| s.to_str()) diff --git a/codex-rs/core/src/mcp_connection_manager.rs b/codex-rs/core/src/mcp_connection_manager.rs index 886e4f8bf7..2e33c8754b 100644 --- a/codex-rs/core/src/mcp_connection_manager.rs +++ b/codex-rs/core/src/mcp_connection_manager.rs @@ -8,6 +8,7 @@ use std::collections::HashMap; use std::collections::HashSet; +use std::ffi::OsString; use std::time::Duration; use anyhow::Context; @@ -127,7 +128,12 @@ impl McpConnectionManager { join_set.spawn(async move { let McpServerConfig { command, args, env } = cfg; - let client_res = McpClient::new_stdio_client(command, args, env).await; + let client_res = McpClient::new_stdio_client( + command.into(), + args.into_iter().map(OsString::from).collect(), + env, + ) + .await; match client_res { Ok(client) => { // Initialize the client. diff --git a/codex-rs/mcp-client/src/main.rs b/codex-rs/mcp-client/src/main.rs index 8d671b830f..10cfe389bf 100644 --- a/codex-rs/mcp-client/src/main.rs +++ b/codex-rs/mcp-client/src/main.rs @@ -10,6 +10,7 @@ //! program. The utility connects, issues a `tools/list` request and prints the //! server's response as pretty JSON. +use std::ffi::OsString; use std::time::Duration; use anyhow::Context; @@ -37,7 +38,7 @@ async fn main() -> Result<()> { .try_init(); // Collect command-line arguments excluding the program name itself. - let mut args: Vec = std::env::args().skip(1).collect(); + let mut args: Vec = std::env::args_os().skip(1).collect(); if args.is_empty() || args[0] == "--help" || args[0] == "-h" { eprintln!("Usage: mcp-client [args..]\n\nExample: mcp-client codex-mcp-server"); diff --git a/codex-rs/mcp-client/src/mcp_client.rs b/codex-rs/mcp-client/src/mcp_client.rs index 6a9111e69f..084d0bf4ba 100644 --- a/codex-rs/mcp-client/src/mcp_client.rs +++ b/codex-rs/mcp-client/src/mcp_client.rs @@ -12,6 +12,7 @@ //! issue requests and receive strongly-typed results. use std::collections::HashMap; +use std::ffi::OsString; use std::sync::Arc; use std::sync::atomic::AtomicI64; use std::sync::atomic::Ordering; @@ -82,8 +83,8 @@ impl McpClient { /// Caller is responsible for sending the `initialize` request. See /// [`initialize`](Self::initialize) for details. pub async fn new_stdio_client( - program: String, - args: Vec, + program: OsString, + args: Vec, env: Option>, ) -> std::io::Result { let mut child = Command::new(program) From d76f96ce797d96a1df19176aa23c0d253ed44ad1 Mon Sep 17 00:00:00 2001 From: Michael Bolin Date: Mon, 28 Jul 2025 09:26:44 -0700 Subject: [PATCH 39/58] fix: support special --codex-run-as-apply-patch arg (#1702) This introduces some special behavior to the CLIs that are using the `codex-arg0` crate where if `arg1` is `--codex-run-as-apply-patch`, then it will run as if `apply_patch arg2` were invoked. This is important because it means we can do things like: ``` SANDBOX_TYPE=landlock # or seatbelt for macOS codex debug "${SANDBOX_TYPE}" -- codex --codex-run-as-apply-patch PATCH ``` which gives us a way to run `apply_patch` while ensuring it adheres to the sandbox the user specified. While it would be nice to use the `arg0` trick like we are currently doing for `codex-linux-sandbox`, there is no way to specify the `arg0` for the underlying command when running under `/usr/bin/sandbox-exec`, so it will not work for us in this case. Admittedly, we could have also supported this via a custom environment variable (e.g., `CODEX_ARG0`), but since environment variables are inherited by child processes, that seemed like a potentially leakier abstraction. This change, as well as our existing reliance on checking `arg0`, place additional requirements on those who include `codex-core`. Its `README.md` has been updated to reflect this. While we could have just added an `apply-patch` subcommand to the `codex` multitool CLI, that would not be sufficient for the standalone `codex-exec` CLI, which is something that we distribute as part of our GitHub releases for those who know they will not be using the TUI and therefore prefer to use a slightly smaller executable: https://github.com/openai/codex/releases/tag/rust-v0.10.0 To that end, this PR adds an integration test to ensure that the `--codex-run-as-apply-patch` option works with the standalone `codex-exec` CLI. --- [//]: # (BEGIN SAPLING FOOTER) Stack created with [Sapling](https://sapling-scm.com). Best reviewed with [ReviewStack](https://reviewstack.dev/openai/codex/pull/1702). * #1705 * #1703 * __->__ #1702 * #1698 * #1697 --- codex-rs/Cargo.lock | 4 ++++ codex-rs/arg0/Cargo.toml | 1 + codex-rs/arg0/src/lib.rs | 23 +++++++++++++++++- codex-rs/core/README.md | 17 +++++++++---- codex-rs/exec/Cargo.toml | 5 ++++ codex-rs/exec/tests/apply_patch.rs | 38 ++++++++++++++++++++++++++++++ 6 files changed, 83 insertions(+), 5 deletions(-) create mode 100644 codex-rs/exec/tests/apply_patch.rs diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index f3903d6ea6..653c3e4ef2 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -610,6 +610,7 @@ name = "codex-arg0" version = "0.0.0" dependencies = [ "anyhow", + "codex-apply-patch", "codex-core", "codex-linux-sandbox", "dotenvy", @@ -718,14 +719,17 @@ name = "codex-exec" version = "0.0.0" dependencies = [ "anyhow", + "assert_cmd", "chrono", "clap", "codex-arg0", "codex-common", "codex-core", "owo-colors", + "predicates", "serde_json", "shlex", + "tempfile", "tokio", "tracing", "tracing-subscriber", diff --git a/codex-rs/arg0/Cargo.toml b/codex-rs/arg0/Cargo.toml index 9ad1896746..7c55ac0d96 100644 --- a/codex-rs/arg0/Cargo.toml +++ b/codex-rs/arg0/Cargo.toml @@ -12,6 +12,7 @@ workspace = true [dependencies] anyhow = "1" +codex-apply-patch = { path = "../apply-patch" } codex-core = { path = "../core" } codex-linux-sandbox = { path = "../linux-sandbox" } dotenvy = "0.15.7" diff --git a/codex-rs/arg0/src/lib.rs b/codex-rs/arg0/src/lib.rs index 624583b8aa..d7109176a5 100644 --- a/codex-rs/arg0/src/lib.rs +++ b/codex-rs/arg0/src/lib.rs @@ -30,7 +30,8 @@ where Fut: Future>, { // Determine if we were invoked via the special alias. - let argv0 = std::env::args_os().next().unwrap_or_default(); + let mut args = std::env::args_os(); + let argv0 = args.next().unwrap_or_default(); let exe_name = Path::new(&argv0) .file_name() .and_then(|s| s.to_str()) @@ -41,6 +42,26 @@ where codex_linux_sandbox::run_main(); } + let argv1 = args.next().unwrap_or_default(); + if argv1 == "--codex-run-as-apply-patch" { + let patch_arg = args.next().and_then(|s| s.to_str().map(|s| s.to_owned())); + let exit_code = match patch_arg { + Some(patch_arg) => { + let mut stdout = std::io::stdout(); + let mut stderr = std::io::stderr(); + match codex_apply_patch::apply_patch(&patch_arg, &mut stdout, &mut stderr) { + Ok(()) => 0, + Err(_) => 1, + } + } + None => { + eprintln!("Error: --codex-run-as-apply-patch requires a UTF-8 PATCH argument."); + 1 + } + }; + std::process::exit(exit_code); + } + // This modifies the environment, which is not thread-safe, so do this // before creating any threads/the Tokio runtime. load_dotenv(); diff --git a/codex-rs/core/README.md b/codex-rs/core/README.md index 9b3e59c8af..9a4c255abe 100644 --- a/codex-rs/core/README.md +++ b/codex-rs/core/README.md @@ -2,9 +2,18 @@ This crate implements the business logic for Codex. It is designed to be used by the various Codex UIs written in Rust. -Though for non-Rust UIs, we are also working to define a _protocol_ for talking to Codex. See: +## Dependencies -- [Specification](../docs/protocol_v1.md) -- [Rust types](./src/protocol.rs) +Note that `codex-core` makes some assumptions about certain helper utilities being available in the environment. Currently, this -You can use the `proto` subcommand using the executable in the [`cli` crate](../cli) to speak the protocol using newline-delimited-JSON over stdin/stdout. +### macOS + +Expects `/usr/bin/sandbox-exec` to be present. + +### Linux + +Expects the binary containing `codex-core` to run the equivalent of `codex debug landlock` when `arg0` is `codex-linux-sandbox`. See the `codex-arg0` crate for details. + +### All Platforms + +Expects the binary containing `codex-core` to simulate the virtual `apply_patch` CLI when `arg1` is `--codex-run-as-apply-patch`. See the `codex-arg0` crate for details. diff --git a/codex-rs/exec/Cargo.toml b/codex-rs/exec/Cargo.toml index c9d94deb5a..ced771f238 100644 --- a/codex-rs/exec/Cargo.toml +++ b/codex-rs/exec/Cargo.toml @@ -37,3 +37,8 @@ tokio = { version = "1", features = [ ] } tracing = { version = "0.1.41", features = ["log"] } tracing-subscriber = { version = "0.3.19", features = ["env-filter"] } + +[dev-dependencies] +assert_cmd = "2" +predicates = "3" +tempfile = "3.13.0" diff --git a/codex-rs/exec/tests/apply_patch.rs b/codex-rs/exec/tests/apply_patch.rs new file mode 100644 index 0000000000..69ac1b8c0a --- /dev/null +++ b/codex-rs/exec/tests/apply_patch.rs @@ -0,0 +1,38 @@ +use anyhow::Context; +use assert_cmd::prelude::*; +use std::fs; +use std::process::Command; +use tempfile::tempdir; + +/// While we may add an `apply-patch` subcommand to the `codex` CLI multitool +/// at some point, we must ensure that the smaller `codex-exec` CLI can still +/// emulate the `apply_patch` CLI. +#[test] +fn test_standalone_exec_cli_can_use_apply_patch() -> anyhow::Result<()> { + let tmp = tempdir()?; + let relative_path = "source.txt"; + let absolute_path = tmp.path().join(relative_path); + fs::write(&absolute_path, "original content\n")?; + + Command::cargo_bin("codex-exec") + .context("should find binary for codex-exec")? + .arg("--codex-run-as-apply-patch") + .arg( + r#"*** Begin Patch +*** Update File: source.txt +@@ +-original content ++modified by apply_patch +*** End Patch"#, + ) + .current_dir(tmp.path()) + .assert() + .success() + .stdout("Success. Updated the following files:\nM source.txt\n") + .stderr(predicates::str::is_empty()); + assert_eq!( + fs::read_to_string(absolute_path)?, + "modified by apply_patch\n" + ); + Ok(()) +} From 5ebb7dd34cb773c19414b4d2fd8bb33670fad1cf Mon Sep 17 00:00:00 2001 From: Michael Bolin Date: Mon, 28 Jul 2025 09:51:22 -0700 Subject: [PATCH 40/58] chore: split apply_patch logic out of codex.rs and into apply_patch.rs (#1703) This is a straight refactor, moving apply-patch-related code from `codex.rs` and into the new `apply_patch.rs` file. The only "logical" change is inlining `#[allow(clippy::unwrap_used)]` instead of declaring `#![allow(clippy::unwrap_used)]` at the top of the file (which is currently the case in `codex.rs`). --- [//]: # (BEGIN SAPLING FOOTER) Stack created with [Sapling](https://sapling-scm.com). Best reviewed with [ReviewStack](https://reviewstack.dev/openai/codex/pull/1703). * #1705 * __->__ #1703 * #1702 * #1698 * #1697 --- codex-rs/core/src/apply_patch.rs | 406 +++++++++++++++++++++++++++++++ codex-rs/core/src/codex.rs | 400 +----------------------------- codex-rs/core/src/lib.rs | 1 + 3 files changed, 415 insertions(+), 392 deletions(-) create mode 100644 codex-rs/core/src/apply_patch.rs diff --git a/codex-rs/core/src/apply_patch.rs b/codex-rs/core/src/apply_patch.rs new file mode 100644 index 0000000000..44af72c746 --- /dev/null +++ b/codex-rs/core/src/apply_patch.rs @@ -0,0 +1,406 @@ +use crate::codex::Session; +use crate::models::FunctionCallOutputPayload; +use crate::models::ResponseInputItem; +use crate::protocol::Event; +use crate::protocol::EventMsg; +use crate::protocol::FileChange; +use crate::protocol::PatchApplyBeginEvent; +use crate::protocol::PatchApplyEndEvent; +use crate::protocol::ReviewDecision; +use crate::safety::SafetyCheck; +use crate::safety::assess_patch_safety; +use anyhow::Context; +use codex_apply_patch::AffectedPaths; +use codex_apply_patch::ApplyPatchAction; +use codex_apply_patch::ApplyPatchFileChange; +use codex_apply_patch::print_summary; +use std::collections::HashMap; +use std::path::Path; +use std::path::PathBuf; + +pub(crate) async fn apply_patch( + sess: &Session, + sub_id: String, + call_id: String, + action: ApplyPatchAction, +) -> ResponseInputItem { + let writable_roots_snapshot = { + #[allow(clippy::unwrap_used)] + let guard = sess.writable_roots.lock().unwrap(); + guard.clone() + }; + + let auto_approved = match assess_patch_safety( + &action, + sess.approval_policy, + &writable_roots_snapshot, + &sess.cwd, + ) { + SafetyCheck::AutoApprove { .. } => true, + SafetyCheck::AskUser => { + // Compute a readable summary of path changes to include in the + // approval request so the user can make an informed decision. + let rx_approve = sess + .request_patch_approval(sub_id.clone(), call_id.clone(), &action, None, None) + .await; + match rx_approve.await.unwrap_or_default() { + ReviewDecision::Approved | ReviewDecision::ApprovedForSession => false, + ReviewDecision::Denied | ReviewDecision::Abort => { + return ResponseInputItem::FunctionCallOutput { + call_id, + output: FunctionCallOutputPayload { + content: "patch rejected by user".to_string(), + success: Some(false), + }, + }; + } + } + } + SafetyCheck::Reject { reason } => { + return ResponseInputItem::FunctionCallOutput { + call_id, + output: FunctionCallOutputPayload { + content: format!("patch rejected: {reason}"), + success: Some(false), + }, + }; + } + }; + + // Verify write permissions before touching the filesystem. + let writable_snapshot = { + #[allow(clippy::unwrap_used)] + sess.writable_roots.lock().unwrap().clone() + }; + + if let Some(offending) = first_offending_path(&action, &writable_snapshot, &sess.cwd) { + let root = offending.parent().unwrap_or(&offending).to_path_buf(); + + let reason = Some(format!( + "grant write access to {} for this session", + root.display() + )); + + let rx = sess + .request_patch_approval( + sub_id.clone(), + call_id.clone(), + &action, + reason.clone(), + Some(root.clone()), + ) + .await; + + if !matches!( + rx.await.unwrap_or_default(), + ReviewDecision::Approved | ReviewDecision::ApprovedForSession + ) { + return ResponseInputItem::FunctionCallOutput { + call_id, + output: FunctionCallOutputPayload { + content: "patch rejected by user".to_string(), + success: Some(false), + }, + }; + } + + // user approved, extend writable roots for this session + #[allow(clippy::unwrap_used)] + sess.writable_roots.lock().unwrap().push(root); + } + + let _ = sess + .tx_event + .send(Event { + id: sub_id.clone(), + msg: EventMsg::PatchApplyBegin(PatchApplyBeginEvent { + call_id: call_id.clone(), + auto_approved, + changes: convert_apply_patch_to_protocol(&action), + }), + }) + .await; + + let mut stdout = Vec::new(); + let mut stderr = Vec::new(); + // Enforce writable roots. If a write is blocked, collect offending root + // and prompt the user to extend permissions. + let mut result = apply_changes_from_apply_patch_and_report(&action, &mut stdout, &mut stderr); + + if let Err(err) = &result { + if err.kind() == std::io::ErrorKind::PermissionDenied { + // Determine first offending path. + let offending_opt = action + .changes() + .iter() + .flat_map(|(path, change)| match change { + ApplyPatchFileChange::Add { .. } => vec![path.as_ref()], + ApplyPatchFileChange::Delete => vec![path.as_ref()], + ApplyPatchFileChange::Update { + move_path: Some(move_path), + .. + } => { + vec![path.as_ref(), move_path.as_ref()] + } + ApplyPatchFileChange::Update { + move_path: None, .. + } => vec![path.as_ref()], + }) + .find_map(|path: &Path| { + // ApplyPatchAction promises to guarantee absolute paths. + if !path.is_absolute() { + panic!("apply_patch invariant failed: path is not absolute: {path:?}"); + } + + let writable = { + #[allow(clippy::unwrap_used)] + let roots = sess.writable_roots.lock().unwrap(); + roots.iter().any(|root| path.starts_with(root)) + }; + if writable { + None + } else { + Some(path.to_path_buf()) + } + }); + + if let Some(offending) = offending_opt { + let root = offending.parent().unwrap_or(&offending).to_path_buf(); + + let reason = Some(format!( + "grant write access to {} for this session", + root.display() + )); + let rx = sess + .request_patch_approval( + sub_id.clone(), + call_id.clone(), + &action, + reason.clone(), + Some(root.clone()), + ) + .await; + if matches!( + rx.await.unwrap_or_default(), + ReviewDecision::Approved | ReviewDecision::ApprovedForSession + ) { + // Extend writable roots. + #[allow(clippy::unwrap_used)] + sess.writable_roots.lock().unwrap().push(root); + stdout.clear(); + stderr.clear(); + result = apply_changes_from_apply_patch_and_report( + &action, + &mut stdout, + &mut stderr, + ); + } + } + } + } + + // Emit PatchApplyEnd event. + let success_flag = result.is_ok(); + let _ = sess + .tx_event + .send(Event { + id: sub_id.clone(), + msg: EventMsg::PatchApplyEnd(PatchApplyEndEvent { + call_id: call_id.clone(), + stdout: String::from_utf8_lossy(&stdout).to_string(), + stderr: String::from_utf8_lossy(&stderr).to_string(), + success: success_flag, + }), + }) + .await; + + match result { + Ok(_) => ResponseInputItem::FunctionCallOutput { + call_id, + output: FunctionCallOutputPayload { + content: String::from_utf8_lossy(&stdout).to_string(), + success: None, + }, + }, + Err(e) => ResponseInputItem::FunctionCallOutput { + call_id, + output: FunctionCallOutputPayload { + content: format!("error: {e:#}, stderr: {}", String::from_utf8_lossy(&stderr)), + success: Some(false), + }, + }, + } +} + +/// Return the first path in `hunks` that is NOT under any of the +/// `writable_roots` (after normalising). If all paths are acceptable, +/// returns None. +fn first_offending_path( + action: &ApplyPatchAction, + writable_roots: &[PathBuf], + cwd: &Path, +) -> Option { + let changes = action.changes(); + for (path, change) in changes { + let candidate = match change { + ApplyPatchFileChange::Add { .. } => path, + ApplyPatchFileChange::Delete => path, + ApplyPatchFileChange::Update { move_path, .. } => move_path.as_ref().unwrap_or(path), + }; + + let abs = if candidate.is_absolute() { + candidate.clone() + } else { + cwd.join(candidate) + }; + + let mut allowed = false; + for root in writable_roots { + let root_abs = if root.is_absolute() { + root.clone() + } else { + cwd.join(root) + }; + if abs.starts_with(&root_abs) { + allowed = true; + break; + } + } + + if !allowed { + return Some(candidate.clone()); + } + } + None +} + +pub(crate) fn convert_apply_patch_to_protocol( + action: &ApplyPatchAction, +) -> HashMap { + let changes = action.changes(); + let mut result = HashMap::with_capacity(changes.len()); + for (path, change) in changes { + let protocol_change = match change { + ApplyPatchFileChange::Add { content } => FileChange::Add { + content: content.clone(), + }, + ApplyPatchFileChange::Delete => FileChange::Delete, + ApplyPatchFileChange::Update { + unified_diff, + move_path, + new_content: _new_content, + } => FileChange::Update { + unified_diff: unified_diff.clone(), + move_path: move_path.clone(), + }, + }; + result.insert(path.clone(), protocol_change); + } + result +} + +fn apply_changes_from_apply_patch_and_report( + action: &ApplyPatchAction, + stdout: &mut impl std::io::Write, + stderr: &mut impl std::io::Write, +) -> std::io::Result<()> { + match apply_changes_from_apply_patch(action) { + Ok(affected_paths) => { + print_summary(&affected_paths, stdout)?; + } + Err(err) => { + writeln!(stderr, "{err:?}")?; + } + } + + Ok(()) +} + +fn apply_changes_from_apply_patch(action: &ApplyPatchAction) -> anyhow::Result { + let mut added: Vec = Vec::new(); + let mut modified: Vec = Vec::new(); + let mut deleted: Vec = Vec::new(); + + let changes = action.changes(); + for (path, change) in changes { + match change { + ApplyPatchFileChange::Add { content } => { + if let Some(parent) = path.parent() { + if !parent.as_os_str().is_empty() { + std::fs::create_dir_all(parent).with_context(|| { + format!("Failed to create parent directories for {}", path.display()) + })?; + } + } + std::fs::write(path, content) + .with_context(|| format!("Failed to write file {}", path.display()))?; + added.push(path.clone()); + } + ApplyPatchFileChange::Delete => { + std::fs::remove_file(path) + .with_context(|| format!("Failed to delete file {}", path.display()))?; + deleted.push(path.clone()); + } + ApplyPatchFileChange::Update { + unified_diff: _unified_diff, + move_path, + new_content, + } => { + if let Some(move_path) = move_path { + if let Some(parent) = move_path.parent() { + if !parent.as_os_str().is_empty() { + std::fs::create_dir_all(parent).with_context(|| { + format!( + "Failed to create parent directories for {}", + move_path.display() + ) + })?; + } + } + + std::fs::rename(path, move_path) + .with_context(|| format!("Failed to rename file {}", path.display()))?; + std::fs::write(move_path, new_content)?; + modified.push(move_path.clone()); + deleted.push(path.clone()); + } else { + std::fs::write(path, new_content)?; + modified.push(path.clone()); + } + } + } + } + + Ok(AffectedPaths { + added, + modified, + deleted, + }) +} + +pub(crate) fn get_writable_roots(cwd: &Path) -> Vec { + let mut writable_roots = Vec::new(); + if cfg!(target_os = "macos") { + // On macOS, $TMPDIR is private to the user. + writable_roots.push(std::env::temp_dir()); + + // Allow pyenv to update its shims directory. Without this, any tool + // that happens to be managed by `pyenv` will fail with an error like: + // + // pyenv: cannot rehash: $HOME/.pyenv/shims isn't writable + // + // which is emitted every time `pyenv` tries to run `rehash` (for + // example, after installing a new Python package that drops an entry + // point). Although the sandbox is intentionally read‑only by default, + // writing to the user's local `pyenv` directory is safe because it + // is already user‑writable and scoped to the current user account. + if let Ok(home_dir) = std::env::var("HOME") { + let pyenv_dir = PathBuf::from(home_dir).join(".pyenv"); + writable_roots.push(pyenv_dir); + } + } + + writable_roots.push(cwd.to_path_buf()); + + writable_roots +} diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 5764440e79..3ab3e8d780 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -4,22 +4,17 @@ use std::borrow::Cow; use std::collections::HashMap; use std::collections::HashSet; -use std::path::Path; use std::path::PathBuf; use std::sync::Arc; use std::sync::Mutex; use std::sync::atomic::AtomicU64; use std::time::Duration; -use anyhow::Context; use async_channel::Receiver; use async_channel::Sender; -use codex_apply_patch::AffectedPaths; use codex_apply_patch::ApplyPatchAction; -use codex_apply_patch::ApplyPatchFileChange; use codex_apply_patch::MaybeApplyPatchVerified; use codex_apply_patch::maybe_parse_apply_patch_verified; -use codex_apply_patch::print_summary; use futures::prelude::*; use mcp_types::CallToolResult; use serde::Serialize; @@ -34,6 +29,9 @@ use tracing::trace; use tracing::warn; use uuid::Uuid; +use crate::apply_patch::convert_apply_patch_to_protocol; +use crate::apply_patch::get_writable_roots; +use crate::apply_patch::{self}; use crate::client::ModelClient; use crate::client_common::Prompt; use crate::client_common::ResponseEvent; @@ -71,11 +69,8 @@ use crate::protocol::EventMsg; use crate::protocol::ExecApprovalRequestEvent; use crate::protocol::ExecCommandBeginEvent; use crate::protocol::ExecCommandEndEvent; -use crate::protocol::FileChange; use crate::protocol::InputItem; use crate::protocol::Op; -use crate::protocol::PatchApplyBeginEvent; -use crate::protocol::PatchApplyEndEvent; use crate::protocol::ReviewDecision; use crate::protocol::SandboxPolicy; use crate::protocol::SessionConfiguredEvent; @@ -84,7 +79,6 @@ use crate::protocol::TaskCompleteEvent; use crate::rollout::RolloutRecorder; use crate::safety::SafetyCheck; use crate::safety::assess_command_safety; -use crate::safety::assess_patch_safety; use crate::shell; use crate::user_notification::UserNotification; use crate::util::backoff; @@ -189,19 +183,19 @@ impl Codex { /// A session has at most 1 running task at a time, and can be interrupted by user input. pub(crate) struct Session { client: ModelClient, - tx_event: Sender, + pub(crate) tx_event: Sender, ctrl_c: Arc, /// The session's current working directory. All relative paths provided by /// the model as well as sandbox policies are resolved against this path /// instead of `std::env::current_dir()`. - cwd: PathBuf, + pub(crate) cwd: PathBuf, base_instructions: Option, user_instructions: Option, - approval_policy: AskForApproval, + pub(crate) approval_policy: AskForApproval, sandbox_policy: SandboxPolicy, shell_environment_policy: ShellEnvironmentPolicy, - writable_roots: Mutex>, + pub(crate) writable_roots: Mutex>, disable_response_storage: bool, /// Manager for external MCP servers/tools. @@ -1419,7 +1413,7 @@ async fn handle_container_exec_with_params( // check if this was a patch, and apply it if so match maybe_parse_apply_patch_verified(¶ms.command, ¶ms.cwd) { MaybeApplyPatchVerified::Body(changes) => { - return apply_patch(sess, sub_id, call_id, changes).await; + return apply_patch::apply_patch(sess, sub_id, call_id, changes).await; } MaybeApplyPatchVerified::CorrectnessError(parse_error) => { // It looks like an invocation of `apply_patch`, but we @@ -1668,384 +1662,6 @@ async fn handle_sandbox_error( } } -async fn apply_patch( - sess: &Session, - sub_id: String, - call_id: String, - action: ApplyPatchAction, -) -> ResponseInputItem { - let writable_roots_snapshot = { - let guard = sess.writable_roots.lock().unwrap(); - guard.clone() - }; - - let auto_approved = match assess_patch_safety( - &action, - sess.approval_policy, - &writable_roots_snapshot, - &sess.cwd, - ) { - SafetyCheck::AutoApprove { .. } => true, - SafetyCheck::AskUser => { - // Compute a readable summary of path changes to include in the - // approval request so the user can make an informed decision. - let rx_approve = sess - .request_patch_approval(sub_id.clone(), call_id.clone(), &action, None, None) - .await; - match rx_approve.await.unwrap_or_default() { - ReviewDecision::Approved | ReviewDecision::ApprovedForSession => false, - ReviewDecision::Denied | ReviewDecision::Abort => { - return ResponseInputItem::FunctionCallOutput { - call_id, - output: FunctionCallOutputPayload { - content: "patch rejected by user".to_string(), - success: Some(false), - }, - }; - } - } - } - SafetyCheck::Reject { reason } => { - return ResponseInputItem::FunctionCallOutput { - call_id, - output: FunctionCallOutputPayload { - content: format!("patch rejected: {reason}"), - success: Some(false), - }, - }; - } - }; - - // Verify write permissions before touching the filesystem. - let writable_snapshot = { sess.writable_roots.lock().unwrap().clone() }; - - if let Some(offending) = first_offending_path(&action, &writable_snapshot, &sess.cwd) { - let root = offending.parent().unwrap_or(&offending).to_path_buf(); - - let reason = Some(format!( - "grant write access to {} for this session", - root.display() - )); - - let rx = sess - .request_patch_approval( - sub_id.clone(), - call_id.clone(), - &action, - reason.clone(), - Some(root.clone()), - ) - .await; - - if !matches!( - rx.await.unwrap_or_default(), - ReviewDecision::Approved | ReviewDecision::ApprovedForSession - ) { - return ResponseInputItem::FunctionCallOutput { - call_id, - output: FunctionCallOutputPayload { - content: "patch rejected by user".to_string(), - success: Some(false), - }, - }; - } - - // user approved, extend writable roots for this session - sess.writable_roots.lock().unwrap().push(root); - } - - let _ = sess - .tx_event - .send(Event { - id: sub_id.clone(), - msg: EventMsg::PatchApplyBegin(PatchApplyBeginEvent { - call_id: call_id.clone(), - auto_approved, - changes: convert_apply_patch_to_protocol(&action), - }), - }) - .await; - - let mut stdout = Vec::new(); - let mut stderr = Vec::new(); - // Enforce writable roots. If a write is blocked, collect offending root - // and prompt the user to extend permissions. - let mut result = apply_changes_from_apply_patch_and_report(&action, &mut stdout, &mut stderr); - - if let Err(err) = &result { - if err.kind() == std::io::ErrorKind::PermissionDenied { - // Determine first offending path. - let offending_opt = action - .changes() - .iter() - .flat_map(|(path, change)| match change { - ApplyPatchFileChange::Add { .. } => vec![path.as_ref()], - ApplyPatchFileChange::Delete => vec![path.as_ref()], - ApplyPatchFileChange::Update { - move_path: Some(move_path), - .. - } => { - vec![path.as_ref(), move_path.as_ref()] - } - ApplyPatchFileChange::Update { - move_path: None, .. - } => vec![path.as_ref()], - }) - .find_map(|path: &Path| { - // ApplyPatchAction promises to guarantee absolute paths. - if !path.is_absolute() { - panic!("apply_patch invariant failed: path is not absolute: {path:?}"); - } - - let writable = { - let roots = sess.writable_roots.lock().unwrap(); - roots.iter().any(|root| path.starts_with(root)) - }; - if writable { - None - } else { - Some(path.to_path_buf()) - } - }); - - if let Some(offending) = offending_opt { - let root = offending.parent().unwrap_or(&offending).to_path_buf(); - - let reason = Some(format!( - "grant write access to {} for this session", - root.display() - )); - let rx = sess - .request_patch_approval( - sub_id.clone(), - call_id.clone(), - &action, - reason.clone(), - Some(root.clone()), - ) - .await; - if matches!( - rx.await.unwrap_or_default(), - ReviewDecision::Approved | ReviewDecision::ApprovedForSession - ) { - // Extend writable roots. - sess.writable_roots.lock().unwrap().push(root); - stdout.clear(); - stderr.clear(); - result = apply_changes_from_apply_patch_and_report( - &action, - &mut stdout, - &mut stderr, - ); - } - } - } - } - - // Emit PatchApplyEnd event. - let success_flag = result.is_ok(); - let _ = sess - .tx_event - .send(Event { - id: sub_id.clone(), - msg: EventMsg::PatchApplyEnd(PatchApplyEndEvent { - call_id: call_id.clone(), - stdout: String::from_utf8_lossy(&stdout).to_string(), - stderr: String::from_utf8_lossy(&stderr).to_string(), - success: success_flag, - }), - }) - .await; - - match result { - Ok(_) => ResponseInputItem::FunctionCallOutput { - call_id, - output: FunctionCallOutputPayload { - content: String::from_utf8_lossy(&stdout).to_string(), - success: None, - }, - }, - Err(e) => ResponseInputItem::FunctionCallOutput { - call_id, - output: FunctionCallOutputPayload { - content: format!("error: {e:#}, stderr: {}", String::from_utf8_lossy(&stderr)), - success: Some(false), - }, - }, - } -} - -/// Return the first path in `hunks` that is NOT under any of the -/// `writable_roots` (after normalising). If all paths are acceptable, -/// returns None. -fn first_offending_path( - action: &ApplyPatchAction, - writable_roots: &[PathBuf], - cwd: &Path, -) -> Option { - let changes = action.changes(); - for (path, change) in changes { - let candidate = match change { - ApplyPatchFileChange::Add { .. } => path, - ApplyPatchFileChange::Delete => path, - ApplyPatchFileChange::Update { move_path, .. } => move_path.as_ref().unwrap_or(path), - }; - - let abs = if candidate.is_absolute() { - candidate.clone() - } else { - cwd.join(candidate) - }; - - let mut allowed = false; - for root in writable_roots { - let root_abs = if root.is_absolute() { - root.clone() - } else { - cwd.join(root) - }; - if abs.starts_with(&root_abs) { - allowed = true; - break; - } - } - - if !allowed { - return Some(candidate.clone()); - } - } - None -} - -fn convert_apply_patch_to_protocol(action: &ApplyPatchAction) -> HashMap { - let changes = action.changes(); - let mut result = HashMap::with_capacity(changes.len()); - for (path, change) in changes { - let protocol_change = match change { - ApplyPatchFileChange::Add { content } => FileChange::Add { - content: content.clone(), - }, - ApplyPatchFileChange::Delete => FileChange::Delete, - ApplyPatchFileChange::Update { - unified_diff, - move_path, - new_content: _new_content, - } => FileChange::Update { - unified_diff: unified_diff.clone(), - move_path: move_path.clone(), - }, - }; - result.insert(path.clone(), protocol_change); - } - result -} - -fn apply_changes_from_apply_patch_and_report( - action: &ApplyPatchAction, - stdout: &mut impl std::io::Write, - stderr: &mut impl std::io::Write, -) -> std::io::Result<()> { - match apply_changes_from_apply_patch(action) { - Ok(affected_paths) => { - print_summary(&affected_paths, stdout)?; - } - Err(err) => { - writeln!(stderr, "{err:?}")?; - } - } - - Ok(()) -} - -fn apply_changes_from_apply_patch(action: &ApplyPatchAction) -> anyhow::Result { - let mut added: Vec = Vec::new(); - let mut modified: Vec = Vec::new(); - let mut deleted: Vec = Vec::new(); - - let changes = action.changes(); - for (path, change) in changes { - match change { - ApplyPatchFileChange::Add { content } => { - if let Some(parent) = path.parent() { - if !parent.as_os_str().is_empty() { - std::fs::create_dir_all(parent).with_context(|| { - format!("Failed to create parent directories for {}", path.display()) - })?; - } - } - std::fs::write(path, content) - .with_context(|| format!("Failed to write file {}", path.display()))?; - added.push(path.clone()); - } - ApplyPatchFileChange::Delete => { - std::fs::remove_file(path) - .with_context(|| format!("Failed to delete file {}", path.display()))?; - deleted.push(path.clone()); - } - ApplyPatchFileChange::Update { - unified_diff: _unified_diff, - move_path, - new_content, - } => { - if let Some(move_path) = move_path { - if let Some(parent) = move_path.parent() { - if !parent.as_os_str().is_empty() { - std::fs::create_dir_all(parent).with_context(|| { - format!( - "Failed to create parent directories for {}", - move_path.display() - ) - })?; - } - } - - std::fs::rename(path, move_path) - .with_context(|| format!("Failed to rename file {}", path.display()))?; - std::fs::write(move_path, new_content)?; - modified.push(move_path.clone()); - deleted.push(path.clone()); - } else { - std::fs::write(path, new_content)?; - modified.push(path.clone()); - } - } - } - } - - Ok(AffectedPaths { - added, - modified, - deleted, - }) -} - -fn get_writable_roots(cwd: &Path) -> Vec { - let mut writable_roots = Vec::new(); - if cfg!(target_os = "macos") { - // On macOS, $TMPDIR is private to the user. - writable_roots.push(std::env::temp_dir()); - - // Allow pyenv to update its shims directory. Without this, any tool - // that happens to be managed by `pyenv` will fail with an error like: - // - // pyenv: cannot rehash: $HOME/.pyenv/shims isn't writable - // - // which is emitted every time `pyenv` tries to run `rehash` (for - // example, after installing a new Python package that drops an entry - // point). Although the sandbox is intentionally read‑only by default, - // writing to the user's local `pyenv` directory is safe because it - // is already user‑writable and scoped to the current user account. - if let Ok(home_dir) = std::env::var("HOME") { - let pyenv_dir = PathBuf::from(home_dir).join(".pyenv"); - writable_roots.push(pyenv_dir); - } - } - - writable_roots.push(cwd.to_path_buf()); - - writable_roots -} - /// Exec output is a pre-serialized JSON payload fn format_exec_output(output: &str, exit_code: i32, duration: Duration) -> String { #[derive(Serialize)] diff --git a/codex-rs/core/src/lib.rs b/codex-rs/core/src/lib.rs index f390038c0c..6cb6aaa629 100644 --- a/codex-rs/core/src/lib.rs +++ b/codex-rs/core/src/lib.rs @@ -5,6 +5,7 @@ // the TUI or the tracing stack). #![deny(clippy::print_stdout, clippy::print_stderr)] +mod apply_patch; mod bash; mod chat_completions; mod client; From 19bef7659f3974d8b5bc116f1096a86696acce3c Mon Sep 17 00:00:00 2001 From: aibrahim-oai Date: Mon, 28 Jul 2025 10:26:27 -0700 Subject: [PATCH 41/58] Serializing the `eventmsg` type to snake_case (#1709) This was an abrupt change on our clients. We need to serialize as snake_case. --- codex-rs/core/src/protocol.rs | 2 +- codex-rs/mcp-server/tests/common/mcp_process.rs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/codex-rs/core/src/protocol.rs b/codex-rs/core/src/protocol.rs index 1a6313db92..22bc1809f4 100644 --- a/codex-rs/core/src/protocol.rs +++ b/codex-rs/core/src/protocol.rs @@ -280,7 +280,7 @@ pub struct Event { /// Response event from the agent #[derive(Debug, Clone, Deserialize, Serialize, Display)] #[serde(tag = "type", rename_all = "snake_case")] -#[strum(serialize_all = "lowercase")] +#[strum(serialize_all = "snake_case")] pub enum EventMsg { /// Error while executing a submission Error(ErrorEvent), diff --git a/codex-rs/mcp-server/tests/common/mcp_process.rs b/codex-rs/mcp-server/tests/common/mcp_process.rs index 528a40152f..8138749c40 100644 --- a/codex-rs/mcp-server/tests/common/mcp_process.rs +++ b/codex-rs/mcp-server/tests/common/mcp_process.rs @@ -294,7 +294,7 @@ impl McpProcess { } } // New schema: method is the Display of EventMsg::SessionConfigured => "SessionConfigured" - if notification.method == "sessionconfigured" { + if notification.method == "session_configured" { if let Some(msg) = params.get("msg") { if let Some(session_id) = msg.get("session_id").and_then(|v| v.as_str()) From 80c19ea77c41d520312b05cd71eaa323a045766b Mon Sep 17 00:00:00 2001 From: easong-openai Date: Mon, 28 Jul 2025 12:00:06 -0700 Subject: [PATCH 42/58] Fix approval workflow (#1696) (Hopefully) temporary solution to the invisible approvals problem - prints commands to history when they need approval and then also prints the result of the approval. In the near future we should be able to do some fancy stuff with updating commands before writing them to permanent history. Also, ctr-c while in the approval modal now acts as esc (aborts command) and puts the TUI in the state where one additional ctr-c will exit. --- .../src/bottom_pane/approval_modal_view.rs | 43 +++++++++++++ .../tui/src/bottom_pane/bottom_pane_view.rs | 6 ++ codex-rs/tui/src/bottom_pane/mod.rs | 64 +++++++++++++++++++ codex-rs/tui/src/chatwidget.rs | 33 ++++++++-- codex-rs/tui/src/user_approval_widget.rs | 35 ++++++++-- 5 files changed, 168 insertions(+), 13 deletions(-) diff --git a/codex-rs/tui/src/bottom_pane/approval_modal_view.rs b/codex-rs/tui/src/bottom_pane/approval_modal_view.rs index ba5b07b93c..376135ef31 100644 --- a/codex-rs/tui/src/bottom_pane/approval_modal_view.rs +++ b/codex-rs/tui/src/bottom_pane/approval_modal_view.rs @@ -9,6 +9,7 @@ use crate::user_approval_widget::UserApprovalWidget; use super::BottomPane; use super::BottomPaneView; +use super::CancellationEvent; /// Modal overlay asking the user to approve/deny a sequence of requests. pub(crate) struct ApprovalModalView<'a> { @@ -46,6 +47,12 @@ impl<'a> BottomPaneView<'a> for ApprovalModalView<'a> { self.maybe_advance(); } + fn on_ctrl_c(&mut self, _pane: &mut BottomPane<'a>) -> CancellationEvent { + self.current.on_ctrl_c(); + self.queue.clear(); + CancellationEvent::Handled + } + fn is_complete(&self) -> bool { self.current.is_complete() && self.queue.is_empty() } @@ -59,3 +66,39 @@ impl<'a> BottomPaneView<'a> for ApprovalModalView<'a> { None } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::app_event::AppEvent; + use std::path::PathBuf; + use std::sync::mpsc::channel; + + fn make_exec_request() -> ApprovalRequest { + ApprovalRequest::Exec { + id: "test".to_string(), + command: vec!["echo".to_string(), "hi".to_string()], + cwd: PathBuf::from("/tmp"), + reason: None, + } + } + + #[test] + fn ctrl_c_aborts_and_clears_queue() { + let (tx_raw, _rx) = channel::(); + let tx = AppEventSender::new(tx_raw); + let first = make_exec_request(); + let mut view = ApprovalModalView::new(first, tx); + view.enqueue_request(make_exec_request()); + + let (tx_raw2, _rx2) = channel::(); + let mut pane = BottomPane::new(super::super::BottomPaneParams { + app_event_tx: AppEventSender::new(tx_raw2), + has_input_focus: true, + }); + assert_eq!(CancellationEvent::Handled, view.on_ctrl_c(&mut pane)); + assert!(view.queue.is_empty()); + assert!(view.current.is_complete()); + assert!(view.is_complete()); + } +} diff --git a/codex-rs/tui/src/bottom_pane/bottom_pane_view.rs b/codex-rs/tui/src/bottom_pane/bottom_pane_view.rs index 677d6db95b..96922d94e7 100644 --- a/codex-rs/tui/src/bottom_pane/bottom_pane_view.rs +++ b/codex-rs/tui/src/bottom_pane/bottom_pane_view.rs @@ -4,6 +4,7 @@ use ratatui::buffer::Buffer; use ratatui::layout::Rect; use super::BottomPane; +use super::CancellationEvent; /// Type to use for a method that may require a redraw of the UI. pub(crate) enum ConditionalUpdate { @@ -22,6 +23,11 @@ pub(crate) trait BottomPaneView<'a> { false } + /// Handle Ctrl-C while this view is active. + fn on_ctrl_c(&mut self, _pane: &mut BottomPane<'a>) -> CancellationEvent { + CancellationEvent::Ignored + } + /// Render the view: this will be displayed in place of the composer. fn render(&self, area: Rect, buf: &mut Buffer); diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index 0ddb36f635..4ec1ba4b3e 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -20,6 +20,12 @@ mod command_popup; mod file_search_popup; mod status_indicator_view; +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum CancellationEvent { + Ignored, + Handled, +} + pub(crate) use chat_composer::ChatComposer; pub(crate) use chat_composer::InputResult; @@ -80,6 +86,33 @@ impl BottomPane<'_> { } } + /// Handle Ctrl-C in the bottom pane. If a modal view is active it gets a + /// chance to consume the event (e.g. to dismiss itself). + pub(crate) fn on_ctrl_c(&mut self) -> CancellationEvent { + let mut view = match self.active_view.take() { + Some(view) => view, + None => return CancellationEvent::Ignored, + }; + + let event = view.on_ctrl_c(self); + match event { + CancellationEvent::Handled => { + if !view.is_complete() { + self.active_view = Some(view); + } else if self.is_task_running { + self.active_view = Some(Box::new(StatusIndicatorView::new( + self.app_event_tx.clone(), + ))); + } + self.show_ctrl_c_quit_hint(); + } + CancellationEvent::Ignored => { + self.active_view = Some(view); + } + } + event + } + pub fn handle_paste(&mut self, pasted: String) { if self.active_view.is_none() { let needs_redraw = self.composer.handle_paste(pasted); @@ -234,3 +267,34 @@ impl WidgetRef for &BottomPane<'_> { } } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::app_event::AppEvent; + use std::path::PathBuf; + use std::sync::mpsc::channel; + + fn exec_request() -> ApprovalRequest { + ApprovalRequest::Exec { + id: "1".to_string(), + command: vec!["echo".into(), "ok".into()], + cwd: PathBuf::from("."), + reason: None, + } + } + + #[test] + fn ctrl_c_on_modal_consumes_and_shows_quit_hint() { + let (tx_raw, _rx) = channel::(); + let tx = AppEventSender::new(tx_raw); + let mut pane = BottomPane::new(BottomPaneParams { + app_event_tx: tx, + has_input_focus: true, + }); + pane.push_approval_request(exec_request()); + assert_eq!(CancellationEvent::Handled, pane.on_ctrl_c()); + assert!(pane.ctrl_c_quit_hint_visible()); + assert_eq!(CancellationEvent::Ignored, pane.on_ctrl_c()); + } +} diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 5e839d1419..a896ae37bc 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -34,8 +34,10 @@ use crate::app_event::AppEvent; use crate::app_event_sender::AppEventSender; use crate::bottom_pane::BottomPane; use crate::bottom_pane::BottomPaneParams; +use crate::bottom_pane::CancellationEvent; use crate::bottom_pane::InputResult; use crate::conversation_history_widget::ConversationHistoryWidget; +use crate::exec_command::strip_bash_lc_and_escape; use crate::history_cell::PatchEventType; use crate::user_approval_widget::ApprovalRequest; use codex_file_search::FileMatch; @@ -301,6 +303,20 @@ impl ChatWidget<'_> { cwd, reason, }) => { + // Print the command to the history so it is visible in the + // transcript *before* the modal asks for approval. + let cmdline = strip_bash_lc_and_escape(&command); + let text = format!( + "command requires approval:\n$ {cmdline}{reason}", + reason = reason + .as_ref() + .map(|r| format!("\n{r}")) + .unwrap_or_default() + ); + self.conversation_history.add_background_event(text); + self.emit_last_history_entry(); + self.conversation_history.scroll_to_bottom(); + let request = ApprovalRequest::Exec { id, command, @@ -308,6 +324,7 @@ impl ChatWidget<'_> { reason, }; self.bottom_pane.push_approval_request(request); + self.request_redraw(); } EventMsg::ApplyPatchApprovalRequest(ApplyPatchApprovalRequestEvent { call_id: _, @@ -453,21 +470,25 @@ impl ChatWidget<'_> { } /// Handle Ctrl-C key press. - /// Returns true if the key press was handled, false if it was not. - /// If the key press was not handled, the caller should handle it (likely by exiting the process). - pub(crate) fn on_ctrl_c(&mut self) -> bool { + /// Returns CancellationEvent::Handled if the event was consumed by the UI, or + /// CancellationEvent::Ignored if the caller should handle it (e.g. exit). + pub(crate) fn on_ctrl_c(&mut self) -> CancellationEvent { + match self.bottom_pane.on_ctrl_c() { + CancellationEvent::Handled => return CancellationEvent::Handled, + CancellationEvent::Ignored => {} + } if self.bottom_pane.is_task_running() { self.bottom_pane.clear_ctrl_c_quit_hint(); self.submit_op(Op::Interrupt); self.answer_buffer.clear(); self.reasoning_buffer.clear(); - false + CancellationEvent::Ignored } else if self.bottom_pane.ctrl_c_quit_hint_visible() { self.submit_op(Op::Shutdown); - true + CancellationEvent::Handled } else { self.bottom_pane.show_ctrl_c_quit_hint(); - false + CancellationEvent::Ignored } } diff --git a/codex-rs/tui/src/user_approval_widget.rs b/codex-rs/tui/src/user_approval_widget.rs index 431f85a268..a161c2c399 100644 --- a/codex-rs/tui/src/user_approval_widget.rs +++ b/codex-rs/tui/src/user_approval_widget.rs @@ -203,6 +203,12 @@ impl UserApprovalWidget<'_> { } } + /// Handle Ctrl-C pressed by the user while the modal is visible. + /// Behaves like pressing Escape: abort the request and close the modal. + pub(crate) fn on_ctrl_c(&mut self) { + self.send_decision(ReviewDecision::Abort); + } + fn handle_select_key(&mut self, key_event: KeyEvent) { match key_event.code { KeyCode::Up => { @@ -265,7 +271,28 @@ impl UserApprovalWidget<'_> { self.send_decision_with_feedback(decision, String::new()) } - fn send_decision_with_feedback(&mut self, decision: ReviewDecision, _feedback: String) { + fn send_decision_with_feedback(&mut self, decision: ReviewDecision, feedback: String) { + let mut lines: Vec> = Vec::new(); + match &self.approval_request { + ApprovalRequest::Exec { command, .. } => { + let cmd = strip_bash_lc_and_escape(command); + lines.push(Line::from("approval decision")); + lines.push(Line::from(format!("$ {cmd}"))); + lines.push(Line::from(format!("decision: {decision:?}"))); + } + ApprovalRequest::ApplyPatch { .. } => { + lines.push(Line::from(format!("patch approval decision: {decision:?}"))); + } + } + if !feedback.trim().is_empty() { + lines.push(Line::from("feedback:")); + for l in feedback.lines() { + lines.push(Line::from(l.to_string())); + } + } + lines.push(Line::from("")); + self.app_event_tx.send(AppEvent::InsertHistory(lines)); + let op = match &self.approval_request { ApprovalRequest::Exec { id, .. } => Op::ExecApproval { id: id.clone(), @@ -277,12 +304,6 @@ impl UserApprovalWidget<'_> { }, }; - // Ignore feedback for now – the current `Op` variants do not carry it. - - // Forward the Op to the agent. The caller (ChatWidget) will trigger a - // redraw after it processes the resulting state change, so we avoid - // issuing an extra Redraw here to prevent a transient frame where the - // modal is still visible. self.app_event_tx.send(AppEvent::CodexOp(op)); self.done = true; } From 2d2df891bb2c1087a68fff6b9a219421097c189f Mon Sep 17 00:00:00 2001 From: Jeremy Rose <172423086+nornagon-openai@users.noreply.github.com> Date: Mon, 28 Jul 2025 12:19:03 -0700 Subject: [PATCH 43/58] fix: long lines incorrectly wrapped (#1710) fix to #1685. --- codex-rs/tui/src/insert_history.rs | 143 +++++++++++++++++++++++------ 1 file changed, 113 insertions(+), 30 deletions(-) diff --git a/codex-rs/tui/src/insert_history.rs b/codex-rs/tui/src/insert_history.rs index 7948436cd8..1e8b1f5392 100644 --- a/codex-rs/tui/src/insert_history.rs +++ b/codex-rs/tui/src/insert_history.rs @@ -1,7 +1,9 @@ +use std::fmt; use std::io; use std::io::Write; use crate::tui; +use crossterm::Command; use crossterm::queue; use crossterm::style::Color as CColor; use crossterm::style::Colors; @@ -11,46 +13,127 @@ use crossterm::style::SetBackgroundColor; use crossterm::style::SetColors; use crossterm::style::SetForegroundColor; use ratatui::layout::Position; +use ratatui::layout::Size; use ratatui::prelude::Backend; use ratatui::style::Color; use ratatui::style::Modifier; use ratatui::text::Line; use ratatui::text::Span; +/// Insert `lines` above the viewport. pub(crate) fn insert_history_lines(terminal: &mut tui::Tui, lines: Vec>) { - let screen_height = terminal - .backend() - .size() - .map(|s| s.height) - .unwrap_or(0xffffu16); + let screen_size = terminal.backend().size().unwrap_or(Size::new(0, 0)); + let mut area = terminal.get_frame().area(); - // We scroll up one line at a time because we can't position the cursor - // above the top of the screen. i.e. if - // lines.len() > screen_height - area.top() - // we would need to print the first line above the top of the screen, which - // can't be done. - for line in lines.into_iter() { - // 1. Scroll everything above the viewport up by one line - if area.bottom() >= screen_height { - let top = area.top(); - terminal.backend_mut().scroll_region_up(0..top, 1).ok(); - // 2. Move the cursor to the blank line - terminal.set_cursor_position(Position::new(0, top - 1)).ok(); - } else { - // If the viewport isn't at the bottom of the screen, scroll down instead - terminal - .backend_mut() - .scroll_region_down(area.top()..area.bottom() + 1, 1) - .ok(); - terminal - .set_cursor_position(Position::new(0, area.top())) - .ok(); - area.y += 1; - } - // 3. Write the line + + let wrapped_lines = wrapped_line_count(&lines, area.width); + let cursor_top = if area.bottom() < screen_size.height { + // If the viewport is not at the bottom of the screen, scroll it down to make room. + // Don't scroll it past the bottom of the screen. + let scroll_amount = wrapped_lines.min(screen_size.height - area.bottom()); + terminal + .backend_mut() + .scroll_region_down(area.top()..screen_size.height, scroll_amount) + .ok(); + let cursor_top = area.top() - 1; + area.y += scroll_amount; + terminal.set_viewport_area(area); + cursor_top + } else { + area.top() - 1 + }; + + // Limit the scroll region to the lines from the top of the screen to the + // top of the viewport. With this in place, when we add lines inside this + // area, only the lines in this area will be scrolled. We place the cursor + // at the end of the scroll region, and add lines starting there. + // + // ┌─Screen───────────────────────┐ + // │┌╌Scroll region╌╌╌╌╌╌╌╌╌╌╌╌╌╌┐│ + // │┆ ┆│ + // │┆ ┆│ + // │┆ ┆│ + // │█╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┘│ + // │╭─Viewport───────────────────╮│ + // ││ ││ + // │╰────────────────────────────╯│ + // └──────────────────────────────┘ + queue!(std::io::stdout(), SetScrollRegion(1..area.top())).ok(); + + terminal + .set_cursor_position(Position::new(0, cursor_top)) + .ok(); + + for line in lines { + queue!(std::io::stdout(), Print("\r\n")).ok(); write_spans(&mut std::io::stdout(), line.iter()).ok(); } - terminal.set_viewport_area(area); + + queue!(std::io::stdout(), ResetScrollRegion).ok(); +} + +fn wrapped_line_count(lines: &[Line], width: u16) -> u16 { + let mut count = 0; + for line in lines { + count += line_height(line, width); + } + count +} + +fn line_height(line: &Line, width: u16) -> u16 { + use unicode_width::UnicodeWidthStr; + // get the total display width of the line, accounting for double-width chars + let total_width = line + .spans + .iter() + .map(|span| span.content.width()) + .sum::(); + // divide by width to get the number of lines, rounding up + if width == 0 { + 1 + } else { + (total_width as u16).div_ceil(width).max(1) + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SetScrollRegion(pub std::ops::Range); + +impl Command for SetScrollRegion { + fn write_ansi(&self, f: &mut impl fmt::Write) -> fmt::Result { + write!(f, "\x1b[{};{}r", self.0.start, self.0.end) + } + + #[cfg(windows)] + fn execute_winapi(&self) -> std::io::Result<()> { + panic!("tried to execute SetScrollRegion command using WinAPI, use ANSI instead"); + } + + #[cfg(windows)] + fn is_ansi_code_supported(&self) -> bool { + // TODO(nornagon): is this supported on Windows? + true + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ResetScrollRegion; + +impl Command for ResetScrollRegion { + fn write_ansi(&self, f: &mut impl fmt::Write) -> fmt::Result { + write!(f, "\x1b[r") + } + + #[cfg(windows)] + fn execute_winapi(&self) -> std::io::Result<()> { + panic!("tried to execute ResetScrollRegion command using WinAPI, use ANSI instead"); + } + + #[cfg(windows)] + fn is_ansi_code_supported(&self) -> bool { + // TODO(nornagon): is this supported on Windows? + true + } } struct ModifierDiff { From 094d7af8c3a60fcc54e83f9a286415b0582b9d3c Mon Sep 17 00:00:00 2001 From: Dylan Date: Mon, 28 Jul 2025 13:32:09 -0700 Subject: [PATCH 44/58] [mcp-server] Populate notifications._meta with requestId (#1704) ## Summary Per the [latest MCP spec](https://modelcontextprotocol.io/specification/2025-06-18/basic#meta), the `_meta` field is reserved for metadata. In the [Typescript Schema](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/0695a497eb50a804fc0e88c18a93a21a675d6b3e/schema/2025-06-18/schema.ts#L37-L40), `progressToken` is defined as a value to be attached to subsequent notifications for that request. The [CallToolRequestParams](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/0695a497eb50a804fc0e88c18a93a21a675d6b3e/schema/2025-06-18/schema.ts#L806-L817) extends this definition but overwrites the params field. This ambiguity makes our generated type definitions tricky, so I'm going to skip `progressToken` field for now and just send back the `requestId` instead. In a future PR, we can clarify, update our `generate_mcp_types.py` script, and update our progressToken logic accordingly. ## Testing - [x] Added unit tests - [x] Manually tested with mcp client --- codex-rs/mcp-server/src/codex_tool_runner.rs | 14 +- codex-rs/mcp-server/src/outgoing_message.rs | 161 ++++++++++++++++++- 2 files changed, 167 insertions(+), 8 deletions(-) diff --git a/codex-rs/mcp-server/src/codex_tool_runner.rs b/codex-rs/mcp-server/src/codex_tool_runner.rs index 22a36b8366..c3cb39c452 100644 --- a/codex-rs/mcp-server/src/codex_tool_runner.rs +++ b/codex-rs/mcp-server/src/codex_tool_runner.rs @@ -27,6 +27,7 @@ use uuid::Uuid; use crate::exec_approval::handle_exec_approval_request; use crate::outgoing_message::OutgoingMessageSender; +use crate::outgoing_message::OutgoingNotificationMeta; use crate::patch_approval::handle_patch_approval_request; pub(crate) const INVALID_PARAMS_ERROR_CODE: i64 = -32602; @@ -71,9 +72,11 @@ pub async fn run_codex_tool_session( session_map.lock().await.insert(session_id, codex.clone()); drop(session_map); - // Send initial SessionConfigured event. outgoing - .send_event_as_notification(&session_configured) + .send_event_as_notification( + &session_configured, + Some(OutgoingNotificationMeta::new(Some(id.clone()))), + ) .await; // Use the original MCP request ID as the `sub_id` for the Codex submission so that @@ -158,7 +161,12 @@ async fn run_codex_tool_session_inner( loop { match codex.next_event().await { Ok(event) => { - outgoing.send_event_as_notification(&event).await; + outgoing + .send_event_as_notification( + &event, + Some(OutgoingNotificationMeta::new(Some(request_id.clone()))), + ) + .await; match event.msg { EventMsg::ExecApprovalRequest(ExecApprovalRequestEvent { diff --git a/codex-rs/mcp-server/src/outgoing_message.rs b/codex-rs/mcp-server/src/outgoing_message.rs index e4af1f78cd..e7b0b9b63c 100644 --- a/codex-rs/mcp-server/src/outgoing_message.rs +++ b/codex-rs/mcp-server/src/outgoing_message.rs @@ -18,6 +18,7 @@ use tokio::sync::mpsc; use tokio::sync::oneshot; use tracing::warn; +/// Sends messages to the client and manages request callbacks. pub(crate) struct OutgoingMessageSender { next_request_id: AtomicI64, sender: mpsc::Sender, @@ -78,18 +79,34 @@ impl OutgoingMessageSender { let _ = self.sender.send(outgoing_message).await; } - pub(crate) async fn send_event_as_notification(&self, event: &Event) { - #[expect(clippy::expect_used)] - let params = Some(serde_json::to_value(event).expect("Event must serialize")); + pub(crate) async fn send_event_as_notification( + &self, + event: &Event, + meta: Option, + ) { + #[allow(clippy::expect_used)] + let event_json = serde_json::to_value(event).expect("Event must serialize"); + + let params = if let Ok(params) = serde_json::to_value(OutgoingNotificationParams { + meta, + event: event_json.clone(), + }) { + params + } else { + warn!("Failed to serialize event as OutgoingNotificationParams"); + event_json + }; + let outgoing_message = OutgoingMessage::Notification(OutgoingNotification { method: "codex/event".to_string(), - params: params.clone(), + params: Some(params.clone()), }); let _ = self.sender.send(outgoing_message).await; - self.send_event_as_notification_new_schema(event, params) + self.send_event_as_notification_new_schema(event, Some(params.clone())) .await; } + // should be backwards compatible. // it will replace send_event_as_notification eventually. async fn send_event_as_notification_new_schema( @@ -167,6 +184,30 @@ pub(crate) struct OutgoingNotification { pub params: Option, } +#[derive(Debug, Clone, PartialEq, Serialize)] +pub(crate) struct OutgoingNotificationParams { + #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")] + pub meta: Option, + + #[serde(flatten)] + pub event: serde_json::Value, +} + +// Additional mcp-specific data to be added to a [`codex_core::protocol::Event`] as notification.params._meta +// MCP Spec: https://modelcontextprotocol.io/specification/2025-06-18/basic#meta +// Typescript Schema: https://github.com/modelcontextprotocol/modelcontextprotocol/blob/0695a497eb50a804fc0e88c18a93a21a675d6b3e/schema/2025-06-18/schema.ts +#[derive(Debug, Clone, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct OutgoingNotificationMeta { + pub request_id: Option, +} + +impl OutgoingNotificationMeta { + pub(crate) fn new(request_id: Option) -> Self { + Self { request_id } + } +} + #[derive(Debug, Clone, PartialEq, Serialize)] pub(crate) struct OutgoingResponse { pub id: RequestId, @@ -178,3 +219,113 @@ pub(crate) struct OutgoingError { pub error: JSONRPCErrorError, pub id: RequestId, } + +#[cfg(test)] +mod tests { + #![allow(clippy::unwrap_used)] + + use codex_core::protocol::EventMsg; + use codex_core::protocol::SessionConfiguredEvent; + use pretty_assertions::assert_eq; + use serde_json::json; + use uuid::Uuid; + + use super::*; + + #[tokio::test] + async fn test_send_event_as_notification() { + let (outgoing_tx, mut outgoing_rx) = mpsc::channel::(2); + let outgoing_message_sender = OutgoingMessageSender::new(outgoing_tx); + + let event = Event { + id: "1".to_string(), + msg: EventMsg::SessionConfigured(SessionConfiguredEvent { + session_id: Uuid::new_v4(), + model: "gpt-4o".to_string(), + history_log_id: 1, + history_entry_count: 1000, + }), + }; + + outgoing_message_sender + .send_event_as_notification(&event, None) + .await; + + let result = outgoing_rx.recv().await.unwrap(); + let OutgoingMessage::Notification(OutgoingNotification { method, params }) = result else { + panic!("expected Notification for first message"); + }; + assert_eq!(method, "codex/event"); + + let Ok(expected_params) = serde_json::to_value(&event) else { + panic!("Event must serialize"); + }; + assert_eq!(params, Some(expected_params.clone())); + + let result2 = outgoing_rx.recv().await.unwrap(); + let OutgoingMessage::Notification(OutgoingNotification { + method: method2, + params: params2, + }) = result2 + else { + panic!("expected Notification for second message"); + }; + assert_eq!(method2, event.msg.to_string()); + assert_eq!(params2, Some(expected_params)); + } + + #[tokio::test] + async fn test_send_event_as_notification_with_meta() { + let (outgoing_tx, mut outgoing_rx) = mpsc::channel::(2); + let outgoing_message_sender = OutgoingMessageSender::new(outgoing_tx); + + let session_configured_event = SessionConfiguredEvent { + session_id: Uuid::new_v4(), + model: "gpt-4o".to_string(), + history_log_id: 1, + history_entry_count: 1000, + }; + let event = Event { + id: "1".to_string(), + msg: EventMsg::SessionConfigured(session_configured_event.clone()), + }; + let meta = OutgoingNotificationMeta { + request_id: Some(RequestId::String("123".to_string())), + }; + + outgoing_message_sender + .send_event_as_notification(&event, Some(meta)) + .await; + + let result = outgoing_rx.recv().await.unwrap(); + let OutgoingMessage::Notification(OutgoingNotification { method, params }) = result else { + panic!("expected Notification for first message"); + }; + assert_eq!(method, "codex/event"); + let expected_params = json!({ + "_meta": { + "requestId": "123", + }, + "id": "1", + "msg": { + "session_id": session_configured_event.session_id, + "model": session_configured_event.model, + "history_log_id": session_configured_event.history_log_id, + "history_entry_count": session_configured_event.history_entry_count, + "type": "session_configured", + } + }); + assert_eq!(params.unwrap(), expected_params); + + let result2 = outgoing_rx.recv().await.unwrap(); + let OutgoingMessage::Notification(OutgoingNotification { + method: method2, + params: params2, + }) = result2 + else { + panic!("expected Notification for second message"); + }; + assert_eq!(method2, event.msg.to_string()); + assert_eq!(params2.unwrap(), expected_params); + } +} From f66704a88f3415c84c985aa3feab09e95178bc8a Mon Sep 17 00:00:00 2001 From: Jeremy Rose <172423086+nornagon-openai@users.noreply.github.com> Date: Mon, 28 Jul 2025 17:25:14 -0700 Subject: [PATCH 45/58] replace login screen with a simple prompt (#1713) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Perhaps there was an intention to make the login screen prettier, but it feels quite silly right now to just have a screen that says "press q", so replace it with something that lets the user directly login without having to quit the app. Screenshot 2025-07-28 at 2 54 05 PM --- codex-rs/cli/src/main.rs | 2 +- codex-rs/login/src/lib.rs | 7 +++- codex-rs/tui/src/app.rs | 39 +++++---------------- codex-rs/tui/src/lib.rs | 58 +++++++++++++++++--------------- codex-rs/tui/src/login_screen.rs | 46 ------------------------- codex-rs/tui/src/main.rs | 2 +- 6 files changed, 47 insertions(+), 107 deletions(-) delete mode 100644 codex-rs/tui/src/login_screen.rs diff --git a/codex-rs/cli/src/main.rs b/codex-rs/cli/src/main.rs index efda03bda4..6dd596ff9f 100644 --- a/codex-rs/cli/src/main.rs +++ b/codex-rs/cli/src/main.rs @@ -106,7 +106,7 @@ async fn cli_main(codex_linux_sandbox_exe: Option) -> anyhow::Result<() None => { let mut tui_cli = cli.interactive; prepend_config_flags(&mut tui_cli.config_overrides, cli.config_overrides); - let usage = codex_tui::run_main(tui_cli, codex_linux_sandbox_exe)?; + let usage = codex_tui::run_main(tui_cli, codex_linux_sandbox_exe).await?; println!("{}", codex_core::protocol::FinalOutput::from(usage)); } Some(Subcommand::Exec(mut exec_cli)) => { diff --git a/codex-rs/login/src/lib.rs b/codex-rs/login/src/lib.rs index 99d2f7f983..ab92ecf616 100644 --- a/codex-rs/login/src/lib.rs +++ b/codex-rs/login/src/lib.rs @@ -9,6 +9,7 @@ use std::io::Write; use std::os::unix::fs::OpenOptionsExt; use std::path::Path; use std::process::Stdio; +use std::time::Duration; use tokio::process::Command; const SOURCE_FOR_PYTHON_SERVER: &str = include_str!("./login_with_chatgpt.py"); @@ -73,7 +74,11 @@ pub async fn try_read_auth_json(codex_home: &Path) -> std::io::Result { /// `AppState`. widget: Box>, }, - /// The login screen for the OpenAI provider. - Login { screen: LoginScreen }, /// The start-up warning that recommends running codex inside a Git repo. GitWarning { screen: GitWarningScreen }, } @@ -74,7 +71,6 @@ impl App<'_> { pub(crate) fn new( config: Config, initial_prompt: Option, - show_login_screen: bool, show_git_warning: bool, initial_images: Vec, ) -> Self { @@ -138,18 +134,7 @@ impl App<'_> { }); } - let (app_state, chat_args) = if show_login_screen { - ( - AppState::Login { - screen: LoginScreen::new(app_event_tx.clone(), config.codex_home.clone()), - }, - Some(ChatWidgetArgs { - config: config.clone(), - initial_prompt, - initial_images, - }), - ) - } else if show_git_warning { + let (app_state, chat_args) = if show_git_warning { ( AppState::GitWarning { screen: GitWarningScreen::new(), @@ -243,7 +228,7 @@ impl App<'_> { AppState::Chat { widget } => { widget.on_ctrl_c(); } - AppState::Login { .. } | AppState::GitWarning { .. } => { + AppState::GitWarning { .. } => { // No-op. } } @@ -264,7 +249,7 @@ impl App<'_> { self.dispatch_key_event(key_event); } } - AppState::Login { .. } | AppState::GitWarning { .. } => { + AppState::GitWarning { .. } => { self.app_event_tx.send(AppEvent::ExitRequest); } } @@ -288,11 +273,11 @@ impl App<'_> { } AppEvent::CodexOp(op) => match &mut self.app_state { AppState::Chat { widget } => widget.submit_op(op), - AppState::Login { .. } | AppState::GitWarning { .. } => {} + AppState::GitWarning { .. } => {} }, AppEvent::LatestLog(line) => match &mut self.app_state { AppState::Chat { widget } => widget.update_latest_log(line), - AppState::Login { .. } | AppState::GitWarning { .. } => {} + AppState::GitWarning { .. } => {} }, AppEvent::DispatchCommand(command) => match command { SlashCommand::New => { @@ -348,9 +333,7 @@ impl App<'_> { pub(crate) fn token_usage(&self) -> codex_core::protocol::TokenUsage { match &self.app_state { AppState::Chat { widget } => widget.token_usage().clone(), - AppState::Login { .. } | AppState::GitWarning { .. } => { - codex_core::protocol::TokenUsage::default() - } + AppState::GitWarning { .. } => codex_core::protocol::TokenUsage::default(), } } @@ -361,9 +344,6 @@ impl App<'_> { AppState::Chat { widget } => { terminal.draw(|frame| frame.render_widget_ref(&**widget, frame.area()))?; } - AppState::Login { screen } => { - terminal.draw(|frame| frame.render_widget_ref(&*screen, frame.area()))?; - } AppState::GitWarning { screen } => { terminal.draw(|frame| frame.render_widget_ref(&*screen, frame.area()))?; } @@ -378,7 +358,6 @@ impl App<'_> { AppState::Chat { widget } => { widget.handle_key_event(key_event); } - AppState::Login { screen } => screen.handle_key_event(key_event), AppState::GitWarning { screen } => match screen.handle_key_event(key_event) { GitWarningOutcome::Continue => { // User accepted – switch to chat view. @@ -409,21 +388,21 @@ impl App<'_> { fn dispatch_paste_event(&mut self, pasted: String) { match &mut self.app_state { AppState::Chat { widget } => widget.handle_paste(pasted), - AppState::Login { .. } | AppState::GitWarning { .. } => {} + AppState::GitWarning { .. } => {} } } fn dispatch_scroll_event(&mut self, scroll_delta: i32) { match &mut self.app_state { AppState::Chat { widget } => widget.handle_scroll_delta(scroll_delta), - AppState::Login { .. } | AppState::GitWarning { .. } => {} + AppState::GitWarning { .. } => {} } } fn dispatch_codex_event(&mut self, event: Event) { match &mut self.app_state { AppState::Chat { widget } => widget.handle_codex_event(event), - AppState::Login { .. } | AppState::GitWarning { .. } => {} + AppState::GitWarning { .. } => {} } } } diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index 905f0aaf0b..1f660b1aaf 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -14,6 +14,7 @@ use codex_core::util::is_inside_git_repo; use codex_login::try_read_openai_api_key; use log_layer::TuiLogLayer; use std::fs::OpenOptions; +use std::io::Write; use std::path::PathBuf; use tracing_appender::non_blocking; use tracing_subscriber::EnvFilter; @@ -35,7 +36,6 @@ mod git_warning_screen; mod history_cell; mod insert_history; mod log_layer; -mod login_screen; mod markdown; mod scroll_event_helper; mod slash_command; @@ -47,7 +47,7 @@ mod user_approval_widget; pub use cli::Cli; -pub fn run_main( +pub async fn run_main( cli: Cli, codex_linux_sandbox_exe: Option, ) -> std::io::Result { @@ -142,7 +142,25 @@ pub fn run_main( .with(tui_layer) .try_init(); - let show_login_screen = should_show_login_screen(&config); + let show_login_screen = should_show_login_screen(&config).await; + if show_login_screen { + std::io::stdout().write_all( + b"Oh dear, we don't seem to have an API key.\nTerribly sorry, but may I open a browser window for you to log in? [Yn] ", + )?; + std::io::stdout().flush()?; + let mut input = String::new(); + std::io::stdin().read_line(&mut input)?; + let trimmed = input.trim(); + if !(trimmed.is_empty() || trimmed.eq_ignore_ascii_case("y")) { + std::io::stdout().write_all(b"Right-o, fair enough. See you next time!\n")?; + std::process::exit(1); + } + // Spawn a task to run the login command. + // Block until the login command is finished. + let new_key = codex_login::login_with_chatgpt(&config.codex_home, false).await?; + set_openai_api_key(new_key); + std::io::stdout().write_all(b"Excellent, looks like that worked. Let's get started!\n")?; + } // Determine whether we need to display the "not a git repo" warning // modal. The flag is shown when the current working directory is *not* @@ -150,14 +168,13 @@ pub fn run_main( // `--allow-no-git-exec` flag. let show_git_warning = !cli.skip_git_repo_check && !is_inside_git_repo(&config); - run_ratatui_app(cli, config, show_login_screen, show_git_warning, log_rx) + run_ratatui_app(cli, config, show_git_warning, log_rx) .map_err(|err| std::io::Error::other(err.to_string())) } fn run_ratatui_app( cli: Cli, config: Config, - show_login_screen: bool, show_git_warning: bool, mut log_rx: tokio::sync::mpsc::UnboundedReceiver, ) -> color_eyre::Result { @@ -172,13 +189,7 @@ fn run_ratatui_app( terminal.clear()?; let Cli { prompt, images, .. } = cli; - let mut app = App::new( - config.clone(), - prompt, - show_login_screen, - show_git_warning, - images, - ); + let mut app = App::new(config.clone(), prompt, show_git_warning, images); // Bridge log receiver into the AppEvent channel so latest log lines update the UI. { @@ -210,26 +221,17 @@ fn restore() { } } -#[allow(clippy::unwrap_used)] -fn should_show_login_screen(config: &Config) -> bool { +async fn should_show_login_screen(config: &Config) -> bool { if is_in_need_of_openai_api_key(config) { // Reading the OpenAI API key is an async operation because it may need // to refresh the token. Block on it. let codex_home = config.codex_home.clone(); - let (tx, rx) = tokio::sync::oneshot::channel(); - tokio::spawn(async move { - match try_read_openai_api_key(&codex_home).await { - Ok(openai_api_key) => { - set_openai_api_key(openai_api_key); - tx.send(false).unwrap(); - } - Err(_) => { - tx.send(true).unwrap(); - } - } - }); - // TODO(mbolin): Impose some sort of timeout. - tokio::task::block_in_place(|| rx.blocking_recv()).unwrap() + if let Ok(openai_api_key) = try_read_openai_api_key(&codex_home).await { + set_openai_api_key(openai_api_key); + false + } else { + true + } } else { false } diff --git a/codex-rs/tui/src/login_screen.rs b/codex-rs/tui/src/login_screen.rs deleted file mode 100644 index 1bd11c19d3..0000000000 --- a/codex-rs/tui/src/login_screen.rs +++ /dev/null @@ -1,46 +0,0 @@ -use std::path::PathBuf; - -use crossterm::event::KeyCode; -use crossterm::event::KeyEvent; -use ratatui::buffer::Buffer; -use ratatui::layout::Rect; -use ratatui::widgets::Paragraph; -use ratatui::widgets::Widget as _; -use ratatui::widgets::WidgetRef; - -use crate::app_event::AppEvent; -use crate::app_event_sender::AppEventSender; - -pub(crate) struct LoginScreen { - app_event_tx: AppEventSender, - - /// Use this with login_with_chatgpt() in login/src/lib.rs and, if - /// successful, update the in-memory config via - /// codex_core::openai_api_key::set_openai_api_key(). - #[allow(dead_code)] - codex_home: PathBuf, -} - -impl LoginScreen { - pub(crate) fn new(app_event_tx: AppEventSender, codex_home: PathBuf) -> Self { - Self { - app_event_tx, - codex_home, - } - } - - pub(crate) fn handle_key_event(&mut self, key_event: KeyEvent) { - if let KeyCode::Char('q') = key_event.code { - self.app_event_tx.send(AppEvent::ExitRequest); - } - } -} - -impl WidgetRef for &LoginScreen { - fn render_ref(&self, area: Rect, buf: &mut Buffer) { - let text = Paragraph::new( - "Login using `codex login` and then run this command again. 'q' to quit.", - ); - text.render(area, buf); - } -} diff --git a/codex-rs/tui/src/main.rs b/codex-rs/tui/src/main.rs index 480e56e88e..209febf035 100644 --- a/codex-rs/tui/src/main.rs +++ b/codex-rs/tui/src/main.rs @@ -21,7 +21,7 @@ fn main() -> anyhow::Result<()> { .config_overrides .raw_overrides .splice(0..0, top_cli.config_overrides.raw_overrides); - let usage = run_main(inner, codex_linux_sandbox_exe)?; + let usage = run_main(inner, codex_linux_sandbox_exe).await?; println!("{}", codex_core::protocol::FinalOutput::from(usage)); Ok(()) }) From efe7f3c79335d31e6a82feb2d3c22868d2999ab0 Mon Sep 17 00:00:00 2001 From: easong-openai Date: Tue, 29 Jul 2025 09:23:09 -0700 Subject: [PATCH 46/58] alternate login wording? (#1723) Co-authored-by: Jeremy Rose <172423086+nornagon-openai@users.noreply.github.com> --- codex-rs/tui/src/lib.rs | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index 1f660b1aaf..f93c0a2b79 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -144,22 +144,20 @@ pub async fn run_main( let show_login_screen = should_show_login_screen(&config).await; if show_login_screen { - std::io::stdout().write_all( - b"Oh dear, we don't seem to have an API key.\nTerribly sorry, but may I open a browser window for you to log in? [Yn] ", - )?; + std::io::stdout() + .write_all(b"No API key detected.\nLogin with your ChatGPT account? [Yn] ")?; std::io::stdout().flush()?; let mut input = String::new(); std::io::stdin().read_line(&mut input)?; let trimmed = input.trim(); if !(trimmed.is_empty() || trimmed.eq_ignore_ascii_case("y")) { - std::io::stdout().write_all(b"Right-o, fair enough. See you next time!\n")?; std::process::exit(1); } // Spawn a task to run the login command. // Block until the login command is finished. let new_key = codex_login::login_with_chatgpt(&config.codex_home, false).await?; set_openai_api_key(new_key); - std::io::stdout().write_all(b"Excellent, looks like that worked. Let's get started!\n")?; + std::io::stdout().write_all(b"Login successful.\n")?; } // Determine whether we need to display the "not a git repo" warning From fc85f4812f8cb38c772f2b5b5c43411101b5b380 Mon Sep 17 00:00:00 2001 From: Jeremy Rose <172423086+nornagon-openai@users.noreply.github.com> Date: Tue, 29 Jul 2025 09:40:26 -0700 Subject: [PATCH 47/58] feat: map ^U to kill-line-to-head (#1711) see [discussion](https://github.com/rhysd/tui-textarea/issues/51#issuecomment-3021191712), it's surprising that ^U behaves this way. IMO the undo/redo functionality in tui-textarea isn't good enough to be worth preserving, but if we do bring it back it should probably be on C-z / C-S-z / C-y. --- codex-rs/tui/src/bottom_pane/chat_composer.rs | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index 6a1bb526ce..b15d81f8f5 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -477,6 +477,17 @@ impl ChatComposer<'_> { } } + if let Input { + key: Key::Char('u'), + ctrl: true, + alt: false, + .. + } = input + { + self.textarea.delete_line_by_head(); + return (InputResult::None, true); + } + // Normal input handling self.textarea.input(input); let text_after = self.textarea.lines().join("\n"); From f8fcaaaf6f28c769b452c1fdeb4a62bd14a9415f Mon Sep 17 00:00:00 2001 From: easong-openai Date: Tue, 29 Jul 2025 10:06:05 -0700 Subject: [PATCH 48/58] Relative instruction file (#1722) Passing in an instruction file with a bad path led to silent failures, also instruction relative paths were handled in an unintuitive fashion. --- codex-rs/core/src/config.rs | 54 ++++++++++++++++--- codex-rs/core/tests/cli_stream.rs | 90 +++++++++++++++++++++++++++++++ codex-rs/exec/src/lib.rs | 28 +++++----- 3 files changed, 150 insertions(+), 22 deletions(-) diff --git a/codex-rs/core/src/config.rs b/codex-rs/core/src/config.rs index 2dfd3e55fe..57027bd0f6 100644 --- a/codex-rs/core/src/config.rs +++ b/codex-rs/core/src/config.rs @@ -465,9 +465,14 @@ impl Config { let experimental_resume = cfg.experimental_resume; - let base_instructions = base_instructions.or(Self::get_base_instructions( + // Load base instructions override from a file if specified. If the + // path is relative, resolve it against the effective cwd so the + // behaviour matches other path-like config values. + let file_base_instructions = Self::get_base_instructions( cfg.experimental_instructions_file.as_ref(), - )); + &resolved_cwd, + )?; + let base_instructions = base_instructions.or(file_base_instructions); let config = Self { model, @@ -539,13 +544,46 @@ impl Config { }) } - fn get_base_instructions(path: Option<&PathBuf>) -> Option { - let path = path.as_ref()?; + fn get_base_instructions( + path: Option<&PathBuf>, + cwd: &Path, + ) -> std::io::Result> { + let p = match path.as_ref() { + None => return Ok(None), + Some(p) => p, + }; - std::fs::read_to_string(path) - .ok() - .map(|s| s.trim().to_string()) - .filter(|s| !s.is_empty()) + // Resolve relative paths against the provided cwd to make CLI + // overrides consistent regardless of where the process was launched + // from. + let full_path = if p.is_relative() { + cwd.join(p) + } else { + p.to_path_buf() + }; + + let contents = std::fs::read_to_string(&full_path).map_err(|e| { + std::io::Error::new( + e.kind(), + format!( + "failed to read experimental instructions file {}: {e}", + full_path.display() + ), + ) + })?; + + let s = contents.trim().to_string(); + if s.is_empty() { + Err(std::io::Error::new( + std::io::ErrorKind::InvalidData, + format!( + "experimental instructions file is empty: {}", + full_path.display() + ), + )) + } else { + Ok(Some(s)) + } } } diff --git a/codex-rs/core/tests/cli_stream.rs b/codex-rs/core/tests/cli_stream.rs index 4694ba85ed..0ab7bd0bb2 100644 --- a/codex-rs/core/tests/cli_stream.rs +++ b/codex-rs/core/tests/cli_stream.rs @@ -81,6 +81,96 @@ async fn chat_mode_stream_cli() { server.verify().await; } +/// Verify that passing `-c experimental_instructions_file=...` to the CLI +/// overrides the built-in base instructions by inspecting the request body +/// received by a mock OpenAI Responses endpoint. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn exec_cli_applies_experimental_instructions_file() { + if std::env::var(CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR).is_ok() { + println!( + "Skipping test because it cannot execute when network is disabled in a Codex sandbox." + ); + return; + } + + // Start mock server which will capture the request and return a minimal + // SSE stream for a single turn. + let server = MockServer::start().await; + let sse = concat!( + "data: {\"type\":\"response.created\",\"response\":{}}\n\n", + "data: {\"type\":\"response.completed\",\"response\":{\"id\":\"r1\"}}\n\n" + ); + Mock::given(method("POST")) + .and(path("/v1/responses")) + .respond_with( + ResponseTemplate::new(200) + .insert_header("content-type", "text/event-stream") + .set_body_raw(sse, "text/event-stream"), + ) + .expect(1) + .mount(&server) + .await; + + // Create a temporary instructions file with a unique marker we can assert + // appears in the outbound request payload. + let custom = TempDir::new().unwrap(); + let marker = "cli-experimental-instructions-marker"; + let custom_path = custom.path().join("instr.md"); + std::fs::write(&custom_path, marker).unwrap(); + let custom_path_str = custom_path.to_string_lossy().replace('\\', "/"); + + // Build a provider override that points at the mock server and instructs + // Codex to use the Responses API with the dummy env var. + let provider_override = format!( + "model_providers.mock={{ name = \"mock\", base_url = \"{}/v1\", env_key = \"PATH\", wire_api = \"responses\" }}", + server.uri() + ); + + let home = TempDir::new().unwrap(); + let mut cmd = AssertCommand::new("cargo"); + cmd.arg("run") + .arg("-p") + .arg("codex-cli") + .arg("--quiet") + .arg("--") + .arg("exec") + .arg("--skip-git-repo-check") + .arg("-c") + .arg(&provider_override) + .arg("-c") + .arg("model_provider=\"mock\"") + .arg("-c") + .arg(format!( + "experimental_instructions_file=\"{custom_path_str}\"" + )) + .arg("-C") + .arg(env!("CARGO_MANIFEST_DIR")) + .arg("hello?\n"); + cmd.env("CODEX_HOME", home.path()) + .env("OPENAI_API_KEY", "dummy") + .env("OPENAI_BASE_URL", format!("{}/v1", server.uri())); + + let output = cmd.output().unwrap(); + println!("Status: {}", output.status); + println!("Stdout:\n{}", String::from_utf8_lossy(&output.stdout)); + println!("Stderr:\n{}", String::from_utf8_lossy(&output.stderr)); + assert!(output.status.success()); + + // Inspect the captured request and verify our custom base instructions were + // included in the `instructions` field. + let request = &server.received_requests().await.unwrap()[0]; + let body = request.body_json::().unwrap(); + let instructions = body + .get("instructions") + .and_then(|v| v.as_str()) + .unwrap_or_default() + .to_string(); + assert!( + instructions.contains(marker), + "instructions did not contain custom marker; got: {instructions}" + ); +} + /// Tests streaming responses through the CLI using a local SSE fixture file. /// This test: /// 1. Uses a pre-recorded SSE response fixture instead of a live server diff --git a/codex-rs/exec/src/lib.rs b/codex-rs/exec/src/lib.rs index f966d200a1..cf2f2bd6a5 100644 --- a/codex-rs/exec/src/lib.rs +++ b/codex-rs/exec/src/lib.rs @@ -92,6 +92,20 @@ pub async fn run_main(cli: Cli, codex_linux_sandbox_exe: Option) -> any ), }; + // TODO(mbolin): Take a more thoughtful approach to logging. + let default_level = "error"; + let _ = tracing_subscriber::fmt() + // Fallback to the `default_level` log filter if the environment + // variable is not set _or_ contains an invalid value + .with_env_filter( + EnvFilter::try_from_default_env() + .or_else(|_| EnvFilter::try_new(default_level)) + .unwrap_or_else(|_| EnvFilter::new(default_level)), + ) + .with_ansi(stderr_with_ansi) + .with_writer(std::io::stderr) + .try_init(); + let sandbox_mode = if full_auto { Some(SandboxMode::WorkspaceWrite) } else if dangerously_bypass_approvals_and_sandbox { @@ -142,20 +156,6 @@ pub async fn run_main(cli: Cli, codex_linux_sandbox_exe: Option) -> any std::process::exit(1); } - // TODO(mbolin): Take a more thoughtful approach to logging. - let default_level = "error"; - let _ = tracing_subscriber::fmt() - // Fallback to the `default_level` log filter if the environment - // variable is not set _or_ contains an invalid value - .with_env_filter( - EnvFilter::try_from_default_env() - .or_else(|_| EnvFilter::try_new(default_level)) - .unwrap_or_else(|_| EnvFilter::new(default_level)), - ) - .with_ansi(stderr_with_ansi) - .with_writer(std::io::stderr) - .try_init(); - let CodexConversation { codex: codex_wrapper, session_configured, From 8828f6f082c7e1f4144fa014f18d480851c7f34e Mon Sep 17 00:00:00 2001 From: Gabriel Peal Date: Tue, 29 Jul 2025 11:22:02 -0700 Subject: [PATCH 49/58] Add an experimental plan tool (#1726) This adds a tool the model can call to update a plan. The tool doesn't actually _do_ anything but it gives clients a chance to read and render the structured plan. We will likely iterate on the prompt and tools exposed for planning over time. --- codex-rs/core/src/chat_completions.rs | 3 +- codex-rs/core/src/client.rs | 7 +- codex-rs/core/src/codex.rs | 2 + codex-rs/core/src/config.rs | 12 +- codex-rs/core/src/lib.rs | 1 + codex-rs/core/src/openai_tools.rs | 18 ++- codex-rs/core/src/plan_tool.rs | 126 ++++++++++++++++++ codex-rs/core/src/protocol.rs | 3 + .../src/event_processor_with_human_output.rs | 6 + codex-rs/exec/src/lib.rs | 1 + codex-rs/mcp-server/src/codex_tool_config.rs | 12 +- codex-rs/mcp-server/src/codex_tool_runner.rs | 1 + codex-rs/mcp-server/tests/interrupt.rs | 1 + codex-rs/tui/src/lib.rs | 1 + 14 files changed, 184 insertions(+), 10 deletions(-) create mode 100644 codex-rs/core/src/plan_tool.rs diff --git a/codex-rs/core/src/chat_completions.rs b/codex-rs/core/src/chat_completions.rs index 5adf3c4d50..3042ec452a 100644 --- a/codex-rs/core/src/chat_completions.rs +++ b/codex-rs/core/src/chat_completions.rs @@ -30,6 +30,7 @@ use crate::util::backoff; pub(crate) async fn stream_chat_completions( prompt: &Prompt, model: &str, + include_plan_tool: bool, client: &reqwest::Client, provider: &ModelProviderInfo, ) -> Result { @@ -105,7 +106,7 @@ pub(crate) async fn stream_chat_completions( } } - let tools_json = create_tools_json_for_chat_completions_api(prompt, model)?; + let tools_json = create_tools_json_for_chat_completions_api(prompt, model, include_plan_tool)?; let payload = json!({ "model": model, "messages": messages, diff --git a/codex-rs/core/src/client.rs b/codex-rs/core/src/client.rs index 1648da6d96..aa31b67ecb 100644 --- a/codex-rs/core/src/client.rs +++ b/codex-rs/core/src/client.rs @@ -77,6 +77,7 @@ impl ModelClient { let response_stream = stream_chat_completions( prompt, &self.config.model, + self.config.include_plan_tool, &self.client, &self.provider, ) @@ -115,7 +116,11 @@ impl ModelClient { } let full_instructions = prompt.get_full_instructions(&self.config.model); - let tools_json = create_tools_json_for_responses_api(prompt, &self.config.model)?; + let tools_json = create_tools_json_for_responses_api( + prompt, + &self.config.model, + self.config.include_plan_tool, + )?; let reasoning = create_reasoning_param_for_request(&self.config, self.effort, self.summary); // Request encrypted COT if we are not storing responses, diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 3ab3e8d780..6efc878fb5 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -55,6 +55,7 @@ use crate::models::ReasoningItemReasoningSummary; use crate::models::ResponseInputItem; use crate::models::ResponseItem; use crate::models::ShellToolCallParams; +use crate::plan_tool::handle_update_plan; use crate::project_doc::get_user_instructions; use crate::protocol::AgentMessageDeltaEvent; use crate::protocol::AgentMessageEvent; @@ -1336,6 +1337,7 @@ async fn handle_function_call( }; handle_container_exec_with_params(params, sess, sub_id, call_id).await } + "update_plan" => handle_update_plan(sess, arguments, sub_id, call_id).await, _ => { match sess.mcp_connection_manager.parse_tool_name(&name) { Some((server, tool_name)) => { diff --git a/codex-rs/core/src/config.rs b/codex-rs/core/src/config.rs index 57027bd0f6..53ca8d5ba9 100644 --- a/codex-rs/core/src/config.rs +++ b/codex-rs/core/src/config.rs @@ -143,6 +143,9 @@ pub struct Config { /// Experimental rollout resume path (absolute path to .jsonl; undocumented). pub experimental_resume: Option, + + /// Include an experimental plan tool that the model can use to update its current plan and status of each step. + pub include_plan_tool: bool, } impl Config { @@ -366,6 +369,7 @@ pub struct ConfigOverrides { pub config_profile: Option, pub codex_linux_sandbox_exe: Option, pub base_instructions: Option, + pub include_plan_tool: Option, } impl Config { @@ -388,6 +392,7 @@ impl Config { config_profile: config_profile_key, codex_linux_sandbox_exe, base_instructions, + include_plan_tool, } = overrides; let config_profile = match config_profile_key.as_ref().or(cfg.profile.as_ref()) { @@ -521,8 +526,8 @@ impl Config { .chatgpt_base_url .or(cfg.chatgpt_base_url) .unwrap_or("https://chatgpt.com/backend-api/".to_string()), - experimental_resume, + include_plan_tool: include_plan_tool.unwrap_or(false), }; Ok(config) } @@ -829,7 +834,7 @@ disable_response_storage = true /// /// 1. custom command-line argument, e.g. `--model o3` /// 2. as part of a profile, where the `--profile` is specified via a CLI - /// (or in the config file itelf) + /// (or in the config file itself) /// 3. as an entry in `config.toml`, e.g. `model = "o3"` /// 4. the default value for a required field defined in code, e.g., /// `crate::flags::OPENAI_DEFAULT_MODEL` @@ -879,6 +884,7 @@ disable_response_storage = true chatgpt_base_url: "https://chatgpt.com/backend-api/".to_string(), experimental_resume: None, base_instructions: None, + include_plan_tool: false, }, o3_profile_config ); @@ -927,6 +933,7 @@ disable_response_storage = true chatgpt_base_url: "https://chatgpt.com/backend-api/".to_string(), experimental_resume: None, base_instructions: None, + include_plan_tool: false, }; assert_eq!(expected_gpt3_profile_config, gpt3_profile_config); @@ -990,6 +997,7 @@ disable_response_storage = true chatgpt_base_url: "https://chatgpt.com/backend-api/".to_string(), experimental_resume: None, base_instructions: None, + include_plan_tool: false, }; assert_eq!(expected_zdr_profile_config, zdr_profile_config); diff --git a/codex-rs/core/src/lib.rs b/codex-rs/core/src/lib.rs index 6cb6aaa629..b2dbded5f1 100644 --- a/codex-rs/core/src/lib.rs +++ b/codex-rs/core/src/lib.rs @@ -34,6 +34,7 @@ mod models; pub mod openai_api_key; mod openai_model_info; mod openai_tools; +pub mod plan_tool; mod project_doc; pub mod protocol; mod rollout; diff --git a/codex-rs/core/src/openai_tools.rs b/codex-rs/core/src/openai_tools.rs index ef12a629b6..0f1e7d9ca7 100644 --- a/codex-rs/core/src/openai_tools.rs +++ b/codex-rs/core/src/openai_tools.rs @@ -4,13 +4,14 @@ use std::collections::BTreeMap; use std::sync::LazyLock; use crate::client_common::Prompt; +use crate::plan_tool::PLAN_TOOL; #[derive(Debug, Clone, Serialize)] pub(crate) struct ResponsesApiTool { - name: &'static str, - description: &'static str, - strict: bool, - parameters: JsonSchema, + pub(crate) name: &'static str, + pub(crate) description: &'static str, + pub(crate) strict: bool, + pub(crate) parameters: JsonSchema, } /// When serialized as JSON, this produces a valid "Tool" in the OpenAI @@ -74,6 +75,7 @@ static DEFAULT_CODEX_MODEL_TOOLS: LazyLock> = pub(crate) fn create_tools_json_for_responses_api( prompt: &Prompt, model: &str, + include_plan_tool: bool, ) -> crate::error::Result> { // Assemble tool list: built-in tools + any extra tools from the prompt. let default_tools = if model.starts_with("codex") { @@ -93,6 +95,10 @@ pub(crate) fn create_tools_json_for_responses_api( .map(|(name, tool)| mcp_tool_to_openai_tool(name, tool)), ); + if include_plan_tool { + tools_json.push(serde_json::to_value(PLAN_TOOL.clone())?); + } + Ok(tools_json) } @@ -102,10 +108,12 @@ pub(crate) fn create_tools_json_for_responses_api( pub(crate) fn create_tools_json_for_chat_completions_api( prompt: &Prompt, model: &str, + include_plan_tool: bool, ) -> crate::error::Result> { // We start with the JSON for the Responses API and than rewrite it to match // the chat completions tool call format. - let responses_api_tools_json = create_tools_json_for_responses_api(prompt, model)?; + let responses_api_tools_json = + create_tools_json_for_responses_api(prompt, model, include_plan_tool)?; let tools_json = responses_api_tools_json .into_iter() .filter_map(|mut tool| { diff --git a/codex-rs/core/src/plan_tool.rs b/codex-rs/core/src/plan_tool.rs new file mode 100644 index 0000000000..dbddb8b5eb --- /dev/null +++ b/codex-rs/core/src/plan_tool.rs @@ -0,0 +1,126 @@ +use std::collections::BTreeMap; +use std::sync::LazyLock; + +use serde::Deserialize; +use serde::Serialize; + +use crate::codex::Session; +use crate::models::FunctionCallOutputPayload; +use crate::models::ResponseInputItem; +use crate::openai_tools::JsonSchema; +use crate::openai_tools::OpenAiTool; +use crate::openai_tools::ResponsesApiTool; +use crate::protocol::Event; +use crate::protocol::EventMsg; + +// Types for the TODO tool arguments matching codex-vscode/todo-mcp/src/main.rs +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum StepStatus { + Pending, + InProgress, + Completed, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct PlanItemArg { + pub step: String, + pub status: StepStatus, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct UpdatePlanArgs { + #[serde(default)] + pub explanation: Option, + pub plan: Vec, +} + +pub(crate) static PLAN_TOOL: LazyLock = LazyLock::new(|| { + let mut plan_item_props = BTreeMap::new(); + plan_item_props.insert("step".to_string(), JsonSchema::String); + plan_item_props.insert("status".to_string(), JsonSchema::String); + + let plan_items_schema = JsonSchema::Array { + items: Box::new(JsonSchema::Object { + properties: plan_item_props, + required: &["step", "status"], + additional_properties: false, + }), + }; + + let mut properties = BTreeMap::new(); + properties.insert("explanation".to_string(), JsonSchema::String); + properties.insert("plan".to_string(), plan_items_schema); + + OpenAiTool::Function(ResponsesApiTool { + name: "update_plan", + description: r#"Use the update_plan tool to keep the user updated on the current plan for the task. +After understanding the user's task, call the update_plan tool with an initial plan. An example of a plan: +1. Explore the codebase to find relevant files (status: in_progress) +2. Implement the feature in the XYZ component (status: pending) +3. Commit changes and make a pull request (status: pending) +Each step should be a short, 1-sentence description. +Until all the steps are finished, there should always be exactly one in_progress step in the plan. +Call the update_plan tool whenever you finish a step, marking the completed step as `completed` and marking the next step as `in_progress`. +Before running a command, consider whether or not you have completed the previous step, and make sure to mark it as completed before moving on to the next step. +Sometimes, you may need to change plans in the middle of a task: call `update_plan` with the updated plan and make sure to provide an `explanation` of the rationale when doing so. +When all steps are completed, call update_plan one last time with all steps marked as `completed`."#, + strict: false, + parameters: JsonSchema::Object { + properties, + required: &["plan"], + additional_properties: false, + }, + }) +}); + +/// This function doesn't do anything useful. However, it gives the model a structured way to record its plan that clients can read and render. +/// So it's the _inputs_ to this function that are useful to clients, not the outputs and neither are actually useful for the model other +/// than forcing it to come up and document a plan (TBD how that affects performance). +pub(crate) async fn handle_update_plan( + session: &Session, + arguments: String, + sub_id: String, + call_id: String, +) -> ResponseInputItem { + match parse_update_plan_arguments(arguments, &call_id) { + Ok(args) => { + let output = ResponseInputItem::FunctionCallOutput { + call_id, + output: FunctionCallOutputPayload { + content: "Plan updated".to_string(), + success: Some(true), + }, + }; + session + .send_event(Event { + id: sub_id.to_string(), + msg: EventMsg::PlanUpdate(args), + }) + .await; + output + } + Err(output) => *output, + } +} + +fn parse_update_plan_arguments( + arguments: String, + call_id: &str, +) -> Result> { + match serde_json::from_str::(&arguments) { + Ok(args) => Ok(args), + Err(e) => { + let output = ResponseInputItem::FunctionCallOutput { + call_id: call_id.to_string(), + output: FunctionCallOutputPayload { + content: format!("failed to parse function arguments: {e}"), + success: None, + }, + }; + Err(Box::new(output)) + } + } +} diff --git a/codex-rs/core/src/protocol.rs b/codex-rs/core/src/protocol.rs index 22bc1809f4..041a8c58ce 100644 --- a/codex-rs/core/src/protocol.rs +++ b/codex-rs/core/src/protocol.rs @@ -19,6 +19,7 @@ use crate::config_types::ReasoningEffort as ReasoningEffortConfig; use crate::config_types::ReasoningSummary as ReasoningSummaryConfig; use crate::message_history::HistoryEntry; use crate::model_provider_info::ModelProviderInfo; +use crate::plan_tool::UpdatePlanArgs; /// Submission Queue Entry - requests from user #[derive(Debug, Clone, Deserialize, Serialize)] @@ -335,6 +336,8 @@ pub enum EventMsg { /// Response to GetHistoryEntryRequest. GetHistoryEntryResponse(GetHistoryEntryResponseEvent), + PlanUpdate(UpdatePlanArgs), + /// Notification that the agent is shutting down. ShutdownComplete, } 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 bc647c683e..6c3f73f0ca 100644 --- a/codex-rs/exec/src/event_processor_with_human_output.rs +++ b/codex-rs/exec/src/event_processor_with_human_output.rs @@ -1,5 +1,6 @@ use codex_common::elapsed::format_elapsed; use codex_core::config::Config; +use codex_core::plan_tool::UpdatePlanArgs; use codex_core::protocol::AgentMessageDeltaEvent; use codex_core::protocol::AgentMessageEvent; use codex_core::protocol::AgentReasoningDeltaEvent; @@ -513,6 +514,11 @@ impl EventProcessor for EventProcessorWithHumanOutput { ts_println!(self, "model: {}", model); println!(); } + EventMsg::PlanUpdate(plan_update_event) => { + let UpdatePlanArgs { explanation, plan } = plan_update_event; + ts_println!(self, "explanation: {explanation:?}"); + ts_println!(self, "plan: {plan:?}"); + } EventMsg::GetHistoryEntryResponse(_) => { // Currently ignored in exec output. } diff --git a/codex-rs/exec/src/lib.rs b/codex-rs/exec/src/lib.rs index cf2f2bd6a5..ce4d7f65cc 100644 --- a/codex-rs/exec/src/lib.rs +++ b/codex-rs/exec/src/lib.rs @@ -126,6 +126,7 @@ pub async fn run_main(cli: Cli, codex_linux_sandbox_exe: Option) -> any model_provider: None, codex_linux_sandbox_exe, base_instructions: None, + include_plan_tool: None, }; // Parse `-c` overrides. let cli_kv_overrides = match config_overrides.parse_overrides() { diff --git a/codex-rs/mcp-server/src/codex_tool_config.rs b/codex-rs/mcp-server/src/codex_tool_config.rs index 9f6f7a782d..877d0e05f7 100644 --- a/codex-rs/mcp-server/src/codex_tool_config.rs +++ b/codex-rs/mcp-server/src/codex_tool_config.rs @@ -50,6 +50,10 @@ pub struct CodexToolCallParam { /// The set of instructions to use instead of the default ones. #[serde(default, skip_serializing_if = "Option::is_none")] pub base_instructions: Option, + + /// Whether to include the plan tool in the conversation. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub include_plan_tool: Option, } /// Custom enum mirroring [`AskForApproval`], but has an extra dependency on @@ -140,9 +144,10 @@ impl CodexToolCallParam { sandbox, config: cli_overrides, base_instructions, + include_plan_tool, } = self; - // Build the `ConfigOverrides` recognised by codex-core. + // Build the `ConfigOverrides` recognized by codex-core. let overrides = codex_core::config::ConfigOverrides { model, config_profile: profile, @@ -152,6 +157,7 @@ impl CodexToolCallParam { model_provider: None, codex_linux_sandbox_exe, base_instructions, + include_plan_tool, }; let cli_overrides = cli_overrides @@ -262,6 +268,10 @@ mod tests { "description": "Working directory for the session. If relative, it is resolved against the server process's current working directory.", "type": "string" }, + "include-plan-tool": { + "description": "Whether to include the plan tool in the conversation.", + "type": "boolean" + }, "model": { "description": "Optional override for the model name (e.g. \"o3\", \"o4-mini\").", "type": "string" diff --git a/codex-rs/mcp-server/src/codex_tool_runner.rs b/codex-rs/mcp-server/src/codex_tool_runner.rs index c3cb39c452..f25659b284 100644 --- a/codex-rs/mcp-server/src/codex_tool_runner.rs +++ b/codex-rs/mcp-server/src/codex_tool_runner.rs @@ -263,6 +263,7 @@ async fn run_codex_tool_session_inner( | EventMsg::PatchApplyBegin(_) | EventMsg::PatchApplyEnd(_) | EventMsg::GetHistoryEntryResponse(_) + | EventMsg::PlanUpdate(_) | EventMsg::ShutdownComplete => { // For now, we do not do anything extra for these // events. Note that diff --git a/codex-rs/mcp-server/tests/interrupt.rs b/codex-rs/mcp-server/tests/interrupt.rs index cd163ea06b..313bc7afab 100644 --- a/codex-rs/mcp-server/tests/interrupt.rs +++ b/codex-rs/mcp-server/tests/interrupt.rs @@ -81,6 +81,7 @@ async fn shell_command_interruption() -> anyhow::Result<()> { sandbox: None, config: None, base_instructions: None, + include_plan_tool: None, }) .await?; diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index f93c0a2b79..6c6c662154 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -79,6 +79,7 @@ pub async fn run_main( config_profile: cli.config_profile.clone(), codex_linux_sandbox_exe, base_instructions: None, + include_plan_tool: None, }; // Parse `-c` overrides from the CLI. let cli_kv_overrides = match cli.config_overrides.parse_overrides() { From 6b10e22eb3f999fe2b5c6b8054feefbca791e2bd Mon Sep 17 00:00:00 2001 From: pakrym-oai Date: Tue, 29 Jul 2025 16:49:02 -0700 Subject: [PATCH 50/58] Trim bash lc and run with login shell (#1725) include .zshenv, .zprofile by running with the `-l` flag and don't start a shell inside a shell when we see the typical `bash -lc` invocation. --- codex-rs/core/src/shell.rs | 42 +++++++++++++++++++++++++++++++++----- 1 file changed, 37 insertions(+), 5 deletions(-) diff --git a/codex-rs/core/src/shell.rs b/codex-rs/core/src/shell.rs index 463651234c..98addffce2 100644 --- a/codex-rs/core/src/shell.rs +++ b/codex-rs/core/src/shell.rs @@ -20,8 +20,13 @@ impl Shell { return None; } - let mut result = vec![zsh.shell_path.clone(), "-c".to_string()]; - if let Ok(joined) = shlex::try_join(command.iter().map(|s| s.as_str())) { + let mut result = vec![zsh.shell_path.clone()]; + result.push("-lc".to_string()); + + let joined = strip_bash_lc(&command) + .or_else(|| shlex::try_join(command.iter().map(|s| s.as_str())).ok()); + + if let Some(joined) = joined { result.push(format!("source {} && ({joined})", zsh.zshrc_path)); } else { return None; @@ -33,6 +38,19 @@ impl Shell { } } +fn strip_bash_lc(command: &Vec) -> Option { + match command.as_slice() { + // exactly three items + [first, second, third] + // first two must be "bash", "-lc" + if first == "bash" && second == "-lc" => + { + Some(third.clone()) + } + _ => None, + } +} + #[cfg(target_os = "macos")] pub async fn default_user_shell() -> Shell { use tokio::process::Command; @@ -119,15 +137,29 @@ mod tests { let cases = vec![ ( vec!["myecho"], - vec![shell_path, "-c", "source ZSHRC_PATH && (myecho)"], + vec![shell_path, "-lc", "source ZSHRC_PATH && (myecho)"], Some("It works!\n"), ), + ( + vec!["myecho"], + vec![shell_path, "-lc", "source ZSHRC_PATH && (myecho)"], + Some("It works!\n"), + ), + ( + vec!["bash", "-c", "echo 'single' \"double\""], + vec![ + shell_path, + "-lc", + "source ZSHRC_PATH && (bash -c \"echo 'single' \\\"double\\\"\")", + ], + Some("single double\n"), + ), ( vec!["bash", "-lc", "echo 'single' \"double\""], vec![ shell_path, - "-c", - "source ZSHRC_PATH && (bash -lc \"echo 'single' \\\"double\\\"\")", + "-lc", + "source ZSHRC_PATH && (echo 'single' \"double\")", ], Some("single double\n"), ), From 3823b32b7a3cde9fe96b7e4f40ac9e3f5fb73e35 Mon Sep 17 00:00:00 2001 From: aibrahim-oai Date: Tue, 29 Jul 2025 20:14:41 -0700 Subject: [PATCH 51/58] Mcp protocol (#1715) - Add typed MCP protocol surface in `codex-rs/mcp-server/src/mcp_protocol.rs` for `requests`, `responses`, and `notifications` - Requests: `NewConversation`, `Connect`, `SendUserMessage`, `GetConversations` - Message content parts: `Text`, `Image` (`ImageUrl`/`FileId`, optional `ImageDetail`), File (`Url`/`Id`/`inline Data`) - Responses: `ToolCallResponseEnvelope` with optional `isError` and `structuredContent` variants (`NewConversation`, `Connect`, `SendUserMessageAccepted`, `GetConversations`) - Notifications: `InitialState`, `ConnectionRevoked`, `CodexEvent`, `Cancelled` - Uniform `_meta` on `notifications` via `NotificationMeta` (`conversationId`, `requestId`) - Unit tests validate JSON wire shapes for key `requests`/`responses`/`notifications` --- codex-rs/Cargo.lock | 1 + codex-rs/core/src/config_types.rs | 2 +- codex-rs/mcp-server/Cargo.toml | 1 + codex-rs/mcp-server/src/lib.rs | 1 + codex-rs/mcp-server/src/mcp_protocol.rs | 1020 +++++++++++++++++++++++ 5 files changed, 1024 insertions(+), 1 deletion(-) create mode 100644 codex-rs/mcp-server/src/mcp_protocol.rs diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 653c3e4ef2..9abce0c3db 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -822,6 +822,7 @@ dependencies = [ "serde", "serde_json", "shlex", + "strum_macros 0.27.2", "tempfile", "tokio", "tokio-test", diff --git a/codex-rs/core/src/config_types.rs b/codex-rs/core/src/config_types.rs index 735a571edc..9bf0d483e1 100644 --- a/codex-rs/core/src/config_types.rs +++ b/codex-rs/core/src/config_types.rs @@ -78,7 +78,7 @@ pub enum HistoryPersistence { #[derive(Deserialize, Debug, Clone, PartialEq, Default)] pub struct Tui {} -#[derive(Deserialize, Debug, Clone, Copy, PartialEq, Default)] +#[derive(Deserialize, Debug, Clone, Copy, PartialEq, Default, Serialize)] #[serde(rename_all = "kebab-case")] pub enum SandboxMode { #[serde(rename = "read-only")] diff --git a/codex-rs/mcp-server/Cargo.toml b/codex-rs/mcp-server/Cargo.toml index 488ee6a67c..19cf4db538 100644 --- a/codex-rs/mcp-server/Cargo.toml +++ b/codex-rs/mcp-server/Cargo.toml @@ -34,6 +34,7 @@ tokio = { version = "1", features = [ "signal", ] } uuid = { version = "1", features = ["serde", "v4"] } +strum_macros = "0.27.2" [dev-dependencies] assert_cmd = "2" diff --git a/codex-rs/mcp-server/src/lib.rs b/codex-rs/mcp-server/src/lib.rs index aaf67571b4..0912fed118 100644 --- a/codex-rs/mcp-server/src/lib.rs +++ b/codex-rs/mcp-server/src/lib.rs @@ -19,6 +19,7 @@ mod codex_tool_config; mod codex_tool_runner; mod exec_approval; mod json_to_toml; +mod mcp_protocol; mod message_processor; mod outgoing_message; mod patch_approval; diff --git a/codex-rs/mcp-server/src/mcp_protocol.rs b/codex-rs/mcp-server/src/mcp_protocol.rs new file mode 100644 index 0000000000..05eb0a258a --- /dev/null +++ b/codex-rs/mcp-server/src/mcp_protocol.rs @@ -0,0 +1,1020 @@ +use codex_core::config_types::SandboxMode; +use codex_core::protocol::AskForApproval; +use codex_core::protocol::EventMsg; +use serde::Deserialize; +use serde::Serialize; +use strum_macros::Display; +use uuid::Uuid; + +use mcp_types::RequestId; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(transparent)] +pub struct ConversationId(pub Uuid); + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(transparent)] +pub struct MessageId(pub Uuid); + +// Requests +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ToolCallRequest { + #[serde(rename = "jsonrpc")] + pub jsonrpc: &'static str, + pub id: u64, + pub method: &'static str, + pub params: ToolCallRequestParams, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(tag = "name", content = "arguments", rename_all = "camelCase")] +pub enum ToolCallRequestParams { + ConversationCreate(ConversationCreateArgs), + ConversationStream(ConversationStreamArgs), + ConversationSendMessage(ConversationSendMessageArgs), + ConversationsList(ConversationsListArgs), +} + +impl ToolCallRequestParams { + /// Wrap this request in a JSON-RPC request. + #[allow(dead_code)] + pub fn into_request(self, id: u64) -> ToolCallRequest { + ToolCallRequest { + jsonrpc: "2.0", + id, + method: "tools/call", + params: self, + } + } +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct ConversationCreateArgs { + pub prompt: String, + pub model: String, + pub cwd: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub approval_policy: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub sandbox: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub config: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub profile: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub base_instructions: Option, +} + +/// Optional overrides for an existing conversation's execution context when sending a message. +/// Fields left as `None` inherit the current conversation/session settings. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct ConversationOverrides { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub model: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub cwd: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub approval_policy: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub sandbox: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub config: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub profile: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub base_instructions: Option, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct ConversationStreamArgs { + pub conversation_id: ConversationId, +} + +/// If omitted, the message continues from the latest turn. +/// Set to resume/edit from an earlier parent message in the thread. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct ConversationSendMessageArgs { + pub conversation_id: ConversationId, + pub content: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub parent_message_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[serde(flatten)] + pub conversation_overrides: Option, +} + +/// Input items for a message. +/// Following OpenAI's Responses API: https://platform.openai.com/docs/api-reference/responses +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "camelCase")] +pub enum MessageInputItem { + Text { + text: String, + }, + Image { + #[serde(flatten)] + source: ImageSource, + #[serde(default, skip_serializing_if = "Option::is_none")] + detail: Option, + }, + File { + #[serde(flatten)] + source: FileSource, + }, +} + +/// Source of an image. +/// Following OpenAI's API: https://platform.openai.com/docs/guides/images-vision#giving-a-model-images-as-input +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(untagged)] +pub enum ImageSource { + ImageUrl { image_url: String }, + FileId { file_id: String }, +} + +/// Source of a file. +/// Following OpenAI's Responses API: https://platform.openai.com/docs/guides/pdf-files?api-mode=responses#uploading-files +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(untagged)] +pub enum FileSource { + Url { + file_url: String, + }, + Id { + file_id: String, + }, + Base64 { + #[serde(default, skip_serializing_if = "Option::is_none")] + filename: Option, + // Base64-encoded file contents. + file_data: String, + }, +} + +#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum ImageDetail { + Low, + High, + Auto, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct ConversationsListArgs { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub limit: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub cursor: Option, +} + +// Responses +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ToolCallResponse { + pub request_id: RequestId, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub is_error: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub result: Option, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(untagged)] +pub enum ToolCallResponseResult { + ConversationCreate(ConversationCreateResult), + ConversationStream(ConversationStreamResult), + ConversationSendMessage(ConversationSendMessageResult), + ConversationsList(ConversationsListResult), +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct ConversationCreateResult { + pub conversation_id: ConversationId, + pub model: String, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct ConversationStreamResult {} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct ConversationSendMessageResult { + pub success: bool, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct ConversationsListResult { + pub conversations: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub next_cursor: Option, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct ConversationSummary { + pub conversation_id: ConversationId, + pub title: String, +} + +// Notifications +#[derive(Debug, Clone, Deserialize, Display)] +pub enum ServerNotification { + InitialState(InitialStateNotificationParams), + StreamDisconnected(StreamDisconnectedNotificationParams), + CodexEvent(CodexEventNotificationParams), +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct NotificationMeta { + #[serde(skip_serializing_if = "Option::is_none")] + pub conversation_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub request_id: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct InitialStateNotificationParams { + #[serde(rename = "_meta", skip_serializing_if = "Option::is_none")] + pub meta: Option, + pub initial_state: InitialStatePayload, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct InitialStatePayload { + #[serde(default)] + pub events: Vec, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct StreamDisconnectedNotificationParams { + #[serde(rename = "_meta", skip_serializing_if = "Option::is_none")] + pub meta: Option, + pub reason: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CodexEventNotificationParams { + #[serde(rename = "_meta", skip_serializing_if = "Option::is_none")] + pub meta: Option, + pub msg: EventMsg, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CancelNotificationParams { + pub request_id: RequestId, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub reason: Option, +} + +impl Serialize for ServerNotification { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + use serde::ser::SerializeMap; + + let mut map = serializer.serialize_map(Some(2))?; + match self { + ServerNotification::CodexEvent(p) => { + map.serialize_entry("method", &format!("notifications/{}", p.msg))?; + map.serialize_entry("params", p)?; + } + ServerNotification::InitialState(p) => { + map.serialize_entry("method", "notifications/initial_state")?; + map.serialize_entry("params", p)?; + } + ServerNotification::StreamDisconnected(p) => { + map.serialize_entry("method", "notifications/stream_disconnected")?; + map.serialize_entry("params", p)?; + } + } + map.end() + } +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(tag = "method", content = "params", rename_all = "camelCase")] +pub enum ClientNotification { + #[serde(rename = "notifications/cancelled")] + Cancelled(CancelNotificationParams), +} + +#[cfg(test)] +#[allow(clippy::expect_used)] +#[allow(clippy::unwrap_used)] +mod tests { + use super::*; + use pretty_assertions::assert_eq; + use serde::Serialize; + use serde_json::Value; + use serde_json::json; + use uuid::uuid; + + fn to_val(v: &T) -> Value { + serde_json::to_value(v).expect("serialize to Value") + } + + // ----- Requests ----- + + #[test] + fn serialize_tool_call_request_params_conversation_create_minimal() { + let req = ToolCallRequestParams::ConversationCreate(ConversationCreateArgs { + prompt: "".into(), + model: "o3".into(), + cwd: "/repo".into(), + approval_policy: None, + sandbox: None, + config: None, + profile: None, + base_instructions: None, + }); + + let observed = to_val(&req.into_request(2)); + let expected = json!({ + "jsonrpc": "2.0", + "id": 2, + "method": "tools/call", + "params": { + "name": "conversationCreate", + "arguments": { + "prompt": "", + "model": "o3", + "cwd": "/repo" + } + } + }); + assert_eq!(observed, expected); + } + + #[test] + fn serialize_tool_call_request_params_conversation_send_message_with_overrides_and_parent_message_id() + { + let req = ToolCallRequestParams::ConversationSendMessage(ConversationSendMessageArgs { + conversation_id: ConversationId(uuid!("d0f6ecbe-84a2-41c1-b23d-b20473b25eab")), + content: vec![ + MessageInputItem::Text { text: "Hi".into() }, + MessageInputItem::Image { + source: ImageSource::ImageUrl { + image_url: "https://example.com/cat.jpg".into(), + }, + detail: Some(ImageDetail::High), + }, + MessageInputItem::File { + source: FileSource::Base64 { + filename: Some("notes.txt".into()), + file_data: "Zm9vYmFy".into(), + }, + }, + ], + parent_message_id: Some(MessageId(uuid!("67e55044-10b1-426f-9247-bb680e5fe0c8"))), + conversation_overrides: Some(ConversationOverrides { + model: Some("o4-mini".into()), + cwd: Some("/workdir".into()), + approval_policy: None, + sandbox: Some(SandboxMode::DangerFullAccess), + config: Some(json!({"temp": 0.2})), + profile: Some("eng".into()), + base_instructions: Some("Be terse".into()), + }), + }); + + let observed = to_val(&req.into_request(2)); + let expected = json!({ + "jsonrpc": "2.0", + "id": 2, + "method": "tools/call", + "params": { + "name": "conversationSendMessage", + "arguments": { + "conversation_id": "d0f6ecbe-84a2-41c1-b23d-b20473b25eab", + "content": [ + { "type": "text", "text": "Hi" }, + { "type": "image", "image_url": "https://example.com/cat.jpg", "detail": "high" }, + { "type": "file", "filename": "notes.txt", "file_data": "Zm9vYmFy" } + ], + "parent_message_id": "67e55044-10b1-426f-9247-bb680e5fe0c8", + "model": "o4-mini", + "cwd": "/workdir", + "sandbox": "danger-full-access", + "config": { "temp": 0.2 }, + "profile": "eng", + "base_instructions": "Be terse" + } + } + }); + assert_eq!(observed, expected); + } + + #[test] + fn serialize_tool_call_request_params_conversations_list_with_opts() { + let req = ToolCallRequestParams::ConversationsList(ConversationsListArgs { + limit: Some(50), + cursor: Some("abc".into()), + }); + + let observed = to_val(&req.into_request(2)); + let expected = json!({ + "jsonrpc": "2.0", + "id": 2, + "method": "tools/call", + "params": { + "name": "conversationsList", + "arguments": { + "limit": 50, + "cursor": "abc" + } + } + }); + assert_eq!(observed, expected); + } + + #[test] + fn serialize_tool_call_request_params_conversation_stream() { + let req = ToolCallRequestParams::ConversationStream(ConversationStreamArgs { + conversation_id: ConversationId(uuid!("67e55044-10b1-426f-9247-bb680e5fe0c8")), + }); + + let observed = to_val(&req.into_request(2)); + let expected = json!({ + "jsonrpc": "2.0", + "id": 2, + "method": "tools/call", + "params": { + "name": "conversationStream", + "arguments": { + "conversation_id": "67e55044-10b1-426f-9247-bb680e5fe0c8" + } + } + }); + assert_eq!(observed, expected); + } + + // ----- Message inputs / sources ----- + + #[test] + fn serialize_message_input_image_file_id_auto_detail() { + let item = MessageInputItem::Image { + source: ImageSource::FileId { + file_id: "file_123".into(), + }, + detail: Some(ImageDetail::Auto), + }; + let observed = to_val(&item); + let expected = json!({ + "type": "image", + "file_id": "file_123", + "detail": "auto" + }); + assert_eq!(observed, expected); + } + + #[test] + fn serialize_message_input_file_url_and_id_variants() { + let url = MessageInputItem::File { + source: FileSource::Url { + file_url: "https://example.com/a.pdf".into(), + }, + }; + let id = MessageInputItem::File { + source: FileSource::Id { + file_id: "file_456".into(), + }, + }; + assert_eq!( + to_val(&url), + json!({"type":"file","file_url":"https://example.com/a.pdf"}) + ); + assert_eq!(to_val(&id), json!({"type":"file","file_id":"file_456"})); + } + + #[test] + fn serialize_message_input_image_url_without_detail() { + let item = MessageInputItem::Image { + source: ImageSource::ImageUrl { + image_url: "https://example.com/x.png".into(), + }, + detail: None, + }; + let observed = to_val(&item); + let expected = json!({ + "type": "image", + "image_url": "https://example.com/x.png" + }); + assert_eq!(observed, expected); + } + + // ----- Responses ----- + + #[test] + fn response_success_conversation_create_full_schema() { + let env = ToolCallResponse { + request_id: RequestId::Integer(1), + is_error: None, + result: Some(ToolCallResponseResult::ConversationCreate( + ConversationCreateResult { + conversation_id: ConversationId(uuid!("d0f6ecbe-84a2-41c1-b23d-b20473b25eab")), + model: "o3".into(), + }, + )), + }; + let observed = to_val(&env); + let expected = json!({ + "requestId": 1, + "result": { + "conversation_id": "d0f6ecbe-84a2-41c1-b23d-b20473b25eab", + "model": "o3" + } + }); + assert_eq!( + observed, expected, + "response (ConversationCreate) must match" + ); + } + + #[test] + fn response_success_conversation_stream_empty_result_object() { + let env = ToolCallResponse { + request_id: RequestId::Integer(2), + is_error: None, + result: Some(ToolCallResponseResult::ConversationStream( + ConversationStreamResult {}, + )), + }; + let observed = to_val(&env); + let expected = json!({ + "requestId": 2, + "result": {} + }); + assert_eq!( + observed, expected, + "response (ConversationStream) must have empty object result" + ); + } + + #[test] + fn response_success_send_message_accepted_full_schema() { + let env = ToolCallResponse { + request_id: RequestId::Integer(3), + is_error: None, + result: Some(ToolCallResponseResult::ConversationSendMessage( + ConversationSendMessageResult { success: true }, + )), + }; + let observed = to_val(&env); + let expected = json!({ + "requestId": 3, + "result": { "success": true } + }); + assert_eq!( + observed, expected, + "response (ConversationSendMessageAccepted) must match" + ); + } + + #[test] + fn response_success_conversations_list_with_next_cursor_full_schema() { + let env = ToolCallResponse { + request_id: RequestId::Integer(4), + is_error: None, + result: Some(ToolCallResponseResult::ConversationsList( + ConversationsListResult { + conversations: vec![ConversationSummary { + conversation_id: ConversationId(uuid!( + "67e55044-10b1-426f-9247-bb680e5fe0c8" + )), + title: "Refactor config loader".into(), + }], + next_cursor: Some("next123".into()), + }, + )), + }; + let observed = to_val(&env); + let expected = json!({ + "requestId": 4, + "result": { + "conversations": [ + { + "conversation_id": "67e55044-10b1-426f-9247-bb680e5fe0c8", + "title": "Refactor config loader" + } + ], + "next_cursor": "next123" + } + }); + assert_eq!( + observed, expected, + "response (ConversationsList with cursor) must match" + ); + } + + #[test] + fn response_error_only_is_error_and_request_id_string() { + let env = ToolCallResponse { + request_id: RequestId::Integer(4), + is_error: Some(true), + result: None, + }; + let observed = to_val(&env); + let expected = json!({ + "requestId": 4, + "isError": true + }); + assert_eq!( + observed, expected, + "error response must omit `result` and include `isError`" + ); + } + + // ----- Notifications ----- + + #[test] + fn serialize_notification_initial_state_minimal() { + let params = InitialStateNotificationParams { + meta: Some(NotificationMeta { + conversation_id: Some(ConversationId(uuid!( + "67e55044-10b1-426f-9247-bb680e5fe0c8" + ))), + request_id: Some(RequestId::Integer(44)), + }), + initial_state: InitialStatePayload { + events: vec![ + CodexEventNotificationParams { + meta: None, + msg: EventMsg::TaskStarted, + }, + CodexEventNotificationParams { + meta: None, + msg: EventMsg::AgentMessageDelta( + codex_core::protocol::AgentMessageDeltaEvent { + delta: "Loading...".into(), + }, + ), + }, + ], + }, + }; + + let observed = to_val(&ServerNotification::InitialState(params.clone())); + let expected = json!({ + "method": "notifications/initial_state", + "params": { + "_meta": { + "conversationId": "67e55044-10b1-426f-9247-bb680e5fe0c8", + "requestId": 44 + }, + "initial_state": { + "events": [ + { "msg": { "type": "task_started" } }, + { "msg": { "type": "agent_message_delta", "delta": "Loading..." } } + ] + } + } + }); + assert_eq!(observed, expected); + } + + #[test] + fn serialize_notification_initial_state_omits_empty_events_full_json() { + let params = InitialStateNotificationParams { + meta: None, + initial_state: InitialStatePayload { events: vec![] }, + }; + + let observed = to_val(&ServerNotification::InitialState(params)); + let expected = json!({ + "method": "notifications/initial_state", + "params": { + "initial_state": { "events": [] } + } + }); + assert_eq!(observed, expected); + } + + #[test] + fn serialize_notification_stream_disconnected() { + let params = StreamDisconnectedNotificationParams { + meta: Some(NotificationMeta { + conversation_id: Some(ConversationId(uuid!( + "67e55044-10b1-426f-9247-bb680e5fe0c8" + ))), + request_id: None, + }), + reason: "New stream() took over".into(), + }; + + let observed = to_val(&ServerNotification::StreamDisconnected(params)); + let expected = json!({ + "method": "notifications/stream_disconnected", + "params": { + "_meta": { "conversationId": "67e55044-10b1-426f-9247-bb680e5fe0c8" }, + "reason": "New stream() took over" + } + }); + assert_eq!(observed, expected); + } + + #[test] + fn serialize_notification_codex_event_uses_eventmsg_type_in_method() { + let params = CodexEventNotificationParams { + meta: Some(NotificationMeta { + conversation_id: Some(ConversationId(uuid!( + "67e55044-10b1-426f-9247-bb680e5fe0c8" + ))), + request_id: Some(RequestId::Integer(44)), + }), + msg: EventMsg::AgentMessage(codex_core::protocol::AgentMessageEvent { + message: "hi".into(), + }), + }; + + let observed = to_val(&ServerNotification::CodexEvent(params)); + let expected = json!({ + "method": "notifications/agent_message", + "params": { + "_meta": { + "conversationId": "67e55044-10b1-426f-9247-bb680e5fe0c8", + "requestId": 44 + }, + "msg": { "type": "agent_message", "message": "hi" } + } + }); + assert_eq!(observed, expected); + } + + #[test] + fn serialize_notification_codex_event_task_started_full_json() { + let params = CodexEventNotificationParams { + meta: Some(NotificationMeta { + conversation_id: Some(ConversationId(uuid!( + "67e55044-10b1-426f-9247-bb680e5fe0c8" + ))), + request_id: Some(RequestId::Integer(7)), + }), + msg: EventMsg::TaskStarted, + }; + + let observed = to_val(&ServerNotification::CodexEvent(params)); + let expected = json!({ + "method": "notifications/task_started", + "params": { + "_meta": { + "conversationId": "67e55044-10b1-426f-9247-bb680e5fe0c8", + "requestId": 7 + }, + "msg": { "type": "task_started" } + } + }); + assert_eq!(observed, expected); + } + + #[test] + fn serialize_notification_codex_event_agent_message_delta_full_json() { + let params = CodexEventNotificationParams { + meta: None, + msg: EventMsg::AgentMessageDelta(codex_core::protocol::AgentMessageDeltaEvent { + delta: "stream...".into(), + }), + }; + + let observed = to_val(&ServerNotification::CodexEvent(params)); + let expected = json!({ + "method": "notifications/agent_message_delta", + "params": { + "msg": { "type": "agent_message_delta", "delta": "stream..." } + } + }); + assert_eq!(observed, expected); + } + + #[test] + fn serialize_notification_codex_event_agent_message_full_json() { + let params = CodexEventNotificationParams { + meta: Some(NotificationMeta { + conversation_id: Some(ConversationId(uuid!( + "67e55044-10b1-426f-9247-bb680e5fe0c8" + ))), + request_id: Some(RequestId::Integer(44)), + }), + msg: EventMsg::AgentMessage(codex_core::protocol::AgentMessageEvent { + message: "hi".into(), + }), + }; + + let observed = to_val(&ServerNotification::CodexEvent(params)); + let expected = json!({ + "method": "notifications/agent_message", + "params": { + "_meta": { + "conversationId": "67e55044-10b1-426f-9247-bb680e5fe0c8", + "requestId": 44 + }, + "msg": { "type": "agent_message", "message": "hi" } + } + }); + assert_eq!(observed, expected); + } + + #[test] + fn serialize_notification_codex_event_agent_reasoning_full_json() { + let params = CodexEventNotificationParams { + meta: None, + msg: EventMsg::AgentReasoning(codex_core::protocol::AgentReasoningEvent { + text: "thinking…".into(), + }), + }; + + let observed = to_val(&ServerNotification::CodexEvent(params)); + let expected = json!({ + "method": "notifications/agent_reasoning", + "params": { + "msg": { "type": "agent_reasoning", "text": "thinking…" } + } + }); + assert_eq!(observed, expected); + } + + #[test] + fn serialize_notification_codex_event_token_count_full_json() { + let usage = codex_core::protocol::TokenUsage { + input_tokens: 10, + cached_input_tokens: Some(2), + output_tokens: 5, + reasoning_output_tokens: Some(1), + total_tokens: 16, + }; + let params = CodexEventNotificationParams { + meta: None, + msg: EventMsg::TokenCount(usage), + }; + + let observed = to_val(&ServerNotification::CodexEvent(params)); + let expected = json!({ + "method": "notifications/token_count", + "params": { + "msg": { + "type": "token_count", + "input_tokens": 10, + "cached_input_tokens": 2, + "output_tokens": 5, + "reasoning_output_tokens": 1, + "total_tokens": 16 + } + } + }); + assert_eq!(observed, expected); + } + + #[test] + fn serialize_notification_codex_event_session_configured_full_json() { + let params = CodexEventNotificationParams { + meta: Some(NotificationMeta { + conversation_id: Some(ConversationId(uuid!( + "67e55044-10b1-426f-9247-bb680e5fe0c8" + ))), + request_id: None, + }), + msg: EventMsg::SessionConfigured(codex_core::protocol::SessionConfiguredEvent { + session_id: uuid!("67e55044-10b1-426f-9247-bb680e5fe0c8"), + model: "codex-mini-latest".into(), + history_log_id: 42, + history_entry_count: 3, + }), + }; + + let observed = to_val(&ServerNotification::CodexEvent(params)); + let expected = json!({ + "method": "notifications/session_configured", + "params": { + "_meta": { "conversationId": "67e55044-10b1-426f-9247-bb680e5fe0c8" }, + "msg": { + "type": "session_configured", + "session_id": "67e55044-10b1-426f-9247-bb680e5fe0c8", + "model": "codex-mini-latest", + "history_log_id": 42, + "history_entry_count": 3 + } + } + }); + assert_eq!(observed, expected); + } + + #[test] + fn serialize_notification_codex_event_exec_command_begin_full_json() { + let params = CodexEventNotificationParams { + meta: None, + msg: EventMsg::ExecCommandBegin(codex_core::protocol::ExecCommandBeginEvent { + call_id: "c1".into(), + command: vec!["bash".into(), "-lc".into(), "echo hi".into()], + cwd: std::path::PathBuf::from("/work"), + }), + }; + + let observed = to_val(&ServerNotification::CodexEvent(params)); + let expected = json!({ + "method": "notifications/exec_command_begin", + "params": { + "msg": { + "type": "exec_command_begin", + "call_id": "c1", + "command": ["bash", "-lc", "echo hi"], + "cwd": "/work" + } + } + }); + assert_eq!(observed, expected); + } + + #[test] + fn serialize_notification_codex_event_mcp_tool_call_begin_full_json() { + let params = CodexEventNotificationParams { + meta: None, + msg: EventMsg::McpToolCallBegin(codex_core::protocol::McpToolCallBeginEvent { + call_id: "m1".into(), + server: "calc".into(), + tool: "add".into(), + arguments: Some(json!({"a":1,"b":2})), + }), + }; + + let observed = to_val(&ServerNotification::CodexEvent(params)); + let expected = json!({ + "method": "notifications/mcp_tool_call_begin", + "params": { + "msg": { + "type": "mcp_tool_call_begin", + "call_id": "m1", + "server": "calc", + "tool": "add", + "arguments": { "a": 1, "b": 2 } + } + } + }); + assert_eq!(observed, expected); + } + + #[test] + fn serialize_notification_codex_event_patch_apply_end_full_json() { + let params = CodexEventNotificationParams { + meta: None, + msg: EventMsg::PatchApplyEnd(codex_core::protocol::PatchApplyEndEvent { + call_id: "p1".into(), + stdout: "ok".into(), + stderr: "".into(), + success: true, + }), + }; + + let observed = to_val(&ServerNotification::CodexEvent(params)); + let expected = json!({ + "method": "notifications/patch_apply_end", + "params": { + "msg": { + "type": "patch_apply_end", + "call_id": "p1", + "stdout": "ok", + "stderr": "", + "success": true + } + } + }); + assert_eq!(observed, expected); + } + + // ----- Cancelled notifications ----- + + #[test] + fn serialize_notification_cancelled_with_reason_full_json() { + let params = CancelNotificationParams { + request_id: RequestId::String("r-123".into()), + reason: Some("user_cancelled".into()), + }; + + let observed = to_val(&ClientNotification::Cancelled(params)); + let expected = json!({ + "method": "notifications/cancelled", + "params": { + "requestId": "r-123", + "reason": "user_cancelled" + } + }); + assert_eq!(observed, expected); + } + + #[test] + fn serialize_notification_cancelled_without_reason_full_json() { + let params = CancelNotificationParams { + request_id: RequestId::Integer(77), + reason: None, + }; + + let observed = to_val(&ClientNotification::Cancelled(params)); + + // Check exact structure: reason must be omitted. + assert_eq!(observed["method"], "notifications/cancelled"); + assert_eq!(observed["params"]["requestId"], 77); + assert!( + observed["params"].get("reason").is_none(), + "reason must be omitted when None" + ); + } +} From 347c81ad0049103c84e0aa2c0d7e2988db18218a Mon Sep 17 00:00:00 2001 From: Jeremy Rose <172423086+nornagon-openai@users.noreply.github.com> Date: Wed, 30 Jul 2025 10:05:40 -0700 Subject: [PATCH 52/58] remove conversation history widget (#1727) this widget is no longer used. --- codex-rs/core/src/mcp_tool_call.rs | 15 +- codex-rs/core/src/protocol.rs | 16 +- .../src/event_processor_with_human_output.rs | 94 ++-- codex-rs/tui/src/app.rs | 26 -- codex-rs/tui/src/app_event.rs | 4 - codex-rs/tui/src/cell_widget.rs | 20 - codex-rs/tui/src/chatwidget.rs | 135 +++--- .../tui/src/conversation_history_widget.rs | 429 ------------------ codex-rs/tui/src/history_cell.rs | 319 +++---------- codex-rs/tui/src/insert_history.rs | 2 +- codex-rs/tui/src/lib.rs | 3 - codex-rs/tui/src/scroll_event_helper.rs | 77 ---- codex-rs/tui/src/text_block.rs | 18 - 13 files changed, 164 insertions(+), 994 deletions(-) delete mode 100644 codex-rs/tui/src/cell_widget.rs delete mode 100644 codex-rs/tui/src/conversation_history_widget.rs delete mode 100644 codex-rs/tui/src/scroll_event_helper.rs diff --git a/codex-rs/core/src/mcp_tool_call.rs b/codex-rs/core/src/mcp_tool_call.rs index 61a51a0e7a..e92d7e8481 100644 --- a/codex-rs/core/src/mcp_tool_call.rs +++ b/codex-rs/core/src/mcp_tool_call.rs @@ -1,4 +1,5 @@ use std::time::Duration; +use std::time::Instant; use tracing::error; @@ -7,6 +8,7 @@ use crate::models::FunctionCallOutputPayload; use crate::models::ResponseInputItem; use crate::protocol::Event; use crate::protocol::EventMsg; +use crate::protocol::McpInvocation; use crate::protocol::McpToolCallBeginEvent; use crate::protocol::McpToolCallEndEvent; @@ -41,21 +43,28 @@ pub(crate) async fn handle_mcp_tool_call( } }; - let tool_call_begin_event = EventMsg::McpToolCallBegin(McpToolCallBeginEvent { - call_id: call_id.clone(), + let invocation = McpInvocation { server: server.clone(), tool: tool_name.clone(), arguments: arguments_value.clone(), + }; + + let tool_call_begin_event = EventMsg::McpToolCallBegin(McpToolCallBeginEvent { + call_id: call_id.clone(), + invocation: invocation.clone(), }); notify_mcp_tool_call_event(sess, sub_id, tool_call_begin_event).await; + let start = Instant::now(); // Perform the tool call. let result = sess - .call_tool(&server, &tool_name, arguments_value, timeout) + .call_tool(&server, &tool_name, arguments_value.clone(), timeout) .await .map_err(|e| format!("tool call error: {e}")); let tool_call_end_event = EventMsg::McpToolCallEnd(McpToolCallEndEvent { call_id: call_id.clone(), + invocation, + duration: start.elapsed(), result: result.clone(), }); diff --git a/codex-rs/core/src/protocol.rs b/codex-rs/core/src/protocol.rs index 041a8c58ce..bc922eb0e2 100644 --- a/codex-rs/core/src/protocol.rs +++ b/codex-rs/core/src/protocol.rs @@ -7,7 +7,8 @@ use std::collections::HashMap; use std::fmt; use std::path::Path; use std::path::PathBuf; -use std::str::FromStr; // Added for FinalOutput Display implementation +use std::str::FromStr; +use std::time::Duration; use mcp_types::CallToolResult; use serde::Deserialize; @@ -414,9 +415,7 @@ pub struct AgentReasoningDeltaEvent { } #[derive(Debug, Clone, Deserialize, Serialize)] -pub struct McpToolCallBeginEvent { - /// Identifier so this can be paired with the McpToolCallEnd event. - pub call_id: String, +pub struct McpInvocation { /// Name of the MCP server as defined in the config. pub server: String, /// Name of the tool as given by the MCP server. @@ -425,10 +424,19 @@ pub struct McpToolCallBeginEvent { pub arguments: Option, } +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct McpToolCallBeginEvent { + /// Identifier so this can be paired with the McpToolCallEnd event. + pub call_id: String, + pub invocation: McpInvocation, +} + #[derive(Debug, Clone, Deserialize, Serialize)] pub struct McpToolCallEndEvent { /// Identifier for the corresponding McpToolCallBegin that finished. pub call_id: String, + pub invocation: McpInvocation, + pub duration: Duration, /// Result of the tool call. Note this could be an error. pub result: Result, } 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 6c3f73f0ca..54604d538b 100644 --- a/codex-rs/exec/src/event_processor_with_human_output.rs +++ b/codex-rs/exec/src/event_processor_with_human_output.rs @@ -1,3 +1,4 @@ +use codex_common::elapsed::format_duration; use codex_common::elapsed::format_elapsed; use codex_core::config::Config; use codex_core::plan_tool::UpdatePlanArgs; @@ -11,6 +12,7 @@ use codex_core::protocol::EventMsg; use codex_core::protocol::ExecCommandBeginEvent; use codex_core::protocol::ExecCommandEndEvent; use codex_core::protocol::FileChange; +use codex_core::protocol::McpInvocation; use codex_core::protocol::McpToolCallBeginEvent; use codex_core::protocol::McpToolCallEndEvent; use codex_core::protocol::PatchApplyBeginEvent; @@ -38,11 +40,6 @@ pub(crate) struct EventProcessorWithHumanOutput { call_id_to_command: HashMap, call_id_to_patch: HashMap, - /// Tracks in-flight MCP tool calls so we can calculate duration and print - /// a concise summary when the corresponding `McpToolCallEnd` event is - /// received. - call_id_to_tool_call: HashMap, - // To ensure that --color=never is respected, ANSI escapes _must_ be added // using .style() with one of these fields. If you need a new style, add a // new field here. @@ -70,7 +67,6 @@ impl EventProcessorWithHumanOutput { ) -> Self { let call_id_to_command = HashMap::new(); let call_id_to_patch = HashMap::new(); - let call_id_to_tool_call = HashMap::new(); if with_ansi { Self { @@ -83,7 +79,6 @@ impl EventProcessorWithHumanOutput { red: Style::new().red(), green: Style::new().green(), cyan: Style::new().cyan(), - call_id_to_tool_call, show_agent_reasoning: !config.hide_agent_reasoning, answer_started: false, reasoning_started: false, @@ -100,7 +95,6 @@ impl EventProcessorWithHumanOutput { red: Style::new(), green: Style::new(), cyan: Style::new(), - call_id_to_tool_call, show_agent_reasoning: !config.hide_agent_reasoning, answer_started: false, reasoning_started: false, @@ -115,14 +109,6 @@ struct ExecCommandBegin { start_time: Instant, } -/// Metadata captured when an `McpToolCallBegin` event is received. -struct McpToolCallBegin { - /// Formatted invocation string, e.g. `server.tool({"city":"sf"})`. - invocation: String, - /// Timestamp when the call started so we can compute duration later. - start_time: Instant, -} - struct PatchApplyBegin { start_time: Instant, auto_approved: bool, @@ -292,63 +278,33 @@ impl EventProcessor for EventProcessorWithHumanOutput { println!("{}", truncated_output.style(self.dimmed)); } EventMsg::McpToolCallBegin(McpToolCallBeginEvent { - call_id, - server, - tool, - arguments, + call_id: _, + invocation, }) => { - // Build fully-qualified tool name: server.tool - let fq_tool_name = format!("{server}.{tool}"); - - // Format arguments as compact JSON so they fit on one line. - let args_str = arguments - .as_ref() - .map(|v: &serde_json::Value| { - serde_json::to_string(v).unwrap_or_else(|_| v.to_string()) - }) - .unwrap_or_default(); - - let invocation = if args_str.is_empty() { - format!("{fq_tool_name}()") - } else { - format!("{fq_tool_name}({args_str})") - }; - - self.call_id_to_tool_call.insert( - call_id.clone(), - McpToolCallBegin { - invocation: invocation.clone(), - start_time: Instant::now(), - }, - ); - ts_println!( self, "{} {}", "tool".style(self.magenta), - invocation.style(self.bold), + format_mcp_invocation(&invocation).style(self.bold), ); } EventMsg::McpToolCallEnd(tool_call_end_event) => { let is_success = tool_call_end_event.is_success(); - let McpToolCallEndEvent { call_id, result } = tool_call_end_event; - // Retrieve start time and invocation for duration calculation and labeling. - let info = self.call_id_to_tool_call.remove(&call_id); - - let (duration, invocation) = if let Some(McpToolCallBegin { + let McpToolCallEndEvent { + call_id: _, + result, invocation, - start_time, - .. - }) = info - { - (format!(" in {}", format_elapsed(start_time)), invocation) - } else { - (String::new(), format!("tool('{call_id}')")) - }; + duration, + } = tool_call_end_event; + + let duration = format!(" in {}", format_duration(duration)); let status_str = if is_success { "success" } else { "failed" }; let title_style = if is_success { self.green } else { self.red }; - let title = format!("{invocation} {status_str}{duration}:"); + let title = format!( + "{} {status_str}{duration}:", + format_mcp_invocation(&invocation) + ); ts_println!(self, "{}", title.style(title_style)); @@ -544,3 +500,21 @@ fn format_file_change(change: &FileChange) -> &'static str { } => "M", } } + +fn format_mcp_invocation(invocation: &McpInvocation) -> String { + // Build fully-qualified tool name: server.tool + let fq_tool_name = format!("{}.{}", invocation.server, invocation.tool); + + // Format arguments as compact JSON so they fit on one line. + let args_str = invocation + .arguments + .as_ref() + .map(|v: &serde_json::Value| serde_json::to_string(v).unwrap_or_else(|_| v.to_string())) + .unwrap_or_default(); + + if args_str.is_empty() { + format!("{fq_tool_name}()") + } else { + format!("{fq_tool_name}({args_str})") + } +} diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index b671075ba8..6823a83a50 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -5,7 +5,6 @@ use crate::file_search::FileSearchManager; use crate::get_git_diff::get_git_diff; use crate::git_warning_screen::GitWarningOutcome; use crate::git_warning_screen::GitWarningScreen; -use crate::scroll_event_helper::ScrollEventHelper; use crate::slash_command::SlashCommand; use crate::tui; use codex_core::config::Config; @@ -13,8 +12,6 @@ use codex_core::protocol::Event; use color_eyre::eyre::Result; use crossterm::event::KeyCode; use crossterm::event::KeyEvent; -use crossterm::event::MouseEvent; -use crossterm::event::MouseEventKind; use std::path::PathBuf; use std::sync::Arc; use std::sync::atomic::AtomicBool; @@ -77,7 +74,6 @@ impl App<'_> { let (app_event_tx, app_event_rx) = channel(); let app_event_tx = AppEventSender::new(app_event_tx); let pending_redraw = Arc::new(AtomicBool::new(false)); - let scroll_event_helper = ScrollEventHelper::new(app_event_tx.clone()); // Spawn a dedicated thread for reading the crossterm event loop and // re-publishing the events as AppEvents, as appropriate. @@ -100,18 +96,6 @@ impl App<'_> { crossterm::event::Event::Resize(_, _) => { app_event_tx.send(AppEvent::RequestRedraw); } - crossterm::event::Event::Mouse(MouseEvent { - kind: MouseEventKind::ScrollUp, - .. - }) => { - scroll_event_helper.scroll_up(); - } - crossterm::event::Event::Mouse(MouseEvent { - kind: MouseEventKind::ScrollDown, - .. - }) => { - scroll_event_helper.scroll_down(); - } crossterm::event::Event::Paste(pasted) => { // Many terminals convert newlines to \r when // pasting, e.g. [iTerm2][]. But [tui-textarea @@ -259,9 +243,6 @@ impl App<'_> { } }; } - AppEvent::Scroll(scroll_delta) => { - self.dispatch_scroll_event(scroll_delta); - } AppEvent::Paste(text) => { self.dispatch_paste_event(text); } @@ -392,13 +373,6 @@ impl App<'_> { } } - fn dispatch_scroll_event(&mut self, scroll_delta: i32) { - match &mut self.app_state { - AppState::Chat { widget } => widget.handle_scroll_delta(scroll_delta), - AppState::GitWarning { .. } => {} - } - } - fn dispatch_codex_event(&mut self, event: Event) { match &mut self.app_state { AppState::Chat { widget } => widget.handle_codex_event(event), diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs index a1f304fe42..77a600d304 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -20,10 +20,6 @@ pub(crate) enum AppEvent { /// Text pasted from the terminal clipboard. Paste(String), - /// Scroll event with a value representing the "scroll delta" as the net - /// scroll up/down events within a short time window. - Scroll(i32), - /// Request to exit the application gracefully. ExitRequest, diff --git a/codex-rs/tui/src/cell_widget.rs b/codex-rs/tui/src/cell_widget.rs deleted file mode 100644 index 8acdc0553a..0000000000 --- a/codex-rs/tui/src/cell_widget.rs +++ /dev/null @@ -1,20 +0,0 @@ -use ratatui::prelude::*; - -/// Trait implemented by every type that can live inside the conversation -/// history list. It provides two primitives that the parent scroll-view -/// needs: how *tall* the widget is at a given width and how to render an -/// arbitrary contiguous *window* of that widget. -/// -/// The `first_visible_line` argument to [`render_window`] allows partial -/// rendering when the top of the widget is scrolled off-screen. The caller -/// guarantees that `first_visible_line + area.height as usize` never exceeds -/// the total height previously returned by [`height`]. -pub(crate) trait CellWidget { - /// Total height measured in wrapped terminal lines when drawn with the - /// given *content* width (no scrollbar column included). - fn height(&self, width: u16) -> usize; - - /// Render a *window* that starts `first_visible_line` lines below the top - /// of the widget. The window’s size is given by `area`. - fn render_window(&self, first_visible_line: usize, area: Rect, buf: &mut Buffer); -} diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index a896ae37bc..fde6978634 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -1,5 +1,6 @@ use std::path::PathBuf; use std::sync::Arc; +use std::time::Duration; use codex_core::codex_wrapper::CodexConversation; use codex_core::codex_wrapper::init_codex; @@ -36,8 +37,9 @@ use crate::bottom_pane::BottomPane; use crate::bottom_pane::BottomPaneParams; use crate::bottom_pane::CancellationEvent; use crate::bottom_pane::InputResult; -use crate::conversation_history_widget::ConversationHistoryWidget; use crate::exec_command::strip_bash_lc_and_escape; +use crate::history_cell::CommandOutput; +use crate::history_cell::HistoryCell; use crate::history_cell::PatchEventType; use crate::user_approval_widget::ApprovalRequest; use codex_file_search::FileMatch; @@ -45,7 +47,6 @@ use codex_file_search::FileMatch; pub(crate) struct ChatWidget<'a> { app_event_tx: AppEventSender, codex_op_tx: UnboundedSender, - conversation_history: ConversationHistoryWidget, bottom_pane: BottomPane<'a>, config: Config, initial_user_message: Option, @@ -127,7 +128,6 @@ impl ChatWidget<'_> { Self { app_event_tx: app_event_tx.clone(), codex_op_tx, - conversation_history: ConversationHistoryWidget::new(), bottom_pane: BottomPane::new(BottomPaneParams { app_event_tx, has_input_focus: true, @@ -158,11 +158,9 @@ impl ChatWidget<'_> { self.bottom_pane.handle_paste(text); } - /// Emits the last entry's plain lines from conversation_history, if any. - fn emit_last_history_entry(&mut self) { - if let Some(lines) = self.conversation_history.last_entry_plain_lines() { - self.app_event_tx.send(AppEvent::InsertHistory(lines)); - } + fn add_to_history(&mut self, cell: HistoryCell) { + self.app_event_tx + .send(AppEvent::InsertHistory(cell.plain_lines())); } fn submit_user_message(&mut self, user_message: UserMessage) { @@ -198,28 +196,18 @@ impl ChatWidget<'_> { // Only show text portion in conversation history for now. if !text.is_empty() { - self.conversation_history.add_user_message(text.clone()); - self.emit_last_history_entry(); + self.add_to_history(HistoryCell::new_user_prompt(text.clone())); } - self.conversation_history.scroll_to_bottom(); } pub(crate) fn handle_codex_event(&mut self, event: Event) { let Event { id, msg } = event; match msg { EventMsg::SessionConfigured(event) => { - // Record session information at the top of the conversation. - self.conversation_history - .add_session_info(&self.config, event.clone()); - // Immediately surface the session banner / settings summary in - // scrollback so the user can review configuration (model, - // sandbox, approvals, etc.) before interacting. - self.emit_last_history_entry(); - - // Forward history metadata to the bottom pane so the chat - // composer can navigate through past messages. self.bottom_pane .set_history_metadata(event.history_log_id, event.history_entry_count); + // Record session information at the top of the conversation. + self.add_to_history(HistoryCell::new_session_info(&self.config, event, true)); if let Some(user_message) = self.initial_user_message.take() { // If the user provided an initial message, add it to the @@ -241,9 +229,7 @@ impl ChatWidget<'_> { message }; if !full.is_empty() { - self.conversation_history - .add_agent_message(&self.config, full); - self.emit_last_history_entry(); + self.add_to_history(HistoryCell::new_agent_message(&self.config, full)); } self.request_redraw(); } @@ -270,9 +256,7 @@ impl ChatWidget<'_> { text }; if !full.is_empty() { - self.conversation_history - .add_agent_reasoning(&self.config, full); - self.emit_last_history_entry(); + self.add_to_history(HistoryCell::new_agent_reasoning(&self.config, full)); } self.request_redraw(); } @@ -293,8 +277,7 @@ impl ChatWidget<'_> { .set_token_usage(self.token_usage.clone(), self.config.model_context_window); } EventMsg::Error(ErrorEvent { message }) => { - self.conversation_history.add_error(message.clone()); - self.emit_last_history_entry(); + self.add_to_history(HistoryCell::new_error_event(message.clone())); self.bottom_pane.set_task_running(false); } EventMsg::ExecApprovalRequest(ExecApprovalRequestEvent { @@ -313,9 +296,7 @@ impl ChatWidget<'_> { .map(|r| format!("\n{r}")) .unwrap_or_default() ); - self.conversation_history.add_background_event(text); - self.emit_last_history_entry(); - self.conversation_history.scroll_to_bottom(); + self.add_to_history(HistoryCell::new_background_event(text)); let request = ApprovalRequest::Exec { id, @@ -343,11 +324,10 @@ impl ChatWidget<'_> { // prompt before they have seen *what* is being requested. // ------------------------------------------------------------------ - self.conversation_history - .add_patch_event(PatchEventType::ApprovalRequest, changes); - self.emit_last_history_entry(); - - self.conversation_history.scroll_to_bottom(); + self.add_to_history(HistoryCell::new_patch_event( + PatchEventType::ApprovalRequest, + changes, + )); // Now surface the approval request in the BottomPane as before. let request = ApprovalRequest::ApplyPatch { @@ -359,13 +339,11 @@ impl ChatWidget<'_> { self.request_redraw(); } EventMsg::ExecCommandBegin(ExecCommandBeginEvent { - call_id, + call_id: _, command, cwd: _, }) => { - self.conversation_history - .add_active_exec_command(call_id, command); - self.emit_last_history_entry(); + self.add_to_history(HistoryCell::new_active_exec_command(command)); self.request_redraw(); } EventMsg::PatchApplyBegin(PatchApplyBeginEvent { @@ -375,12 +353,10 @@ impl ChatWidget<'_> { }) => { // Even when a patch is auto‑approved we still display the // summary so the user can follow along. - self.conversation_history - .add_patch_event(PatchEventType::ApplyBegin { auto_approved }, changes); - self.emit_last_history_entry(); - if !auto_approved { - self.conversation_history.scroll_to_bottom(); - } + self.add_to_history(HistoryCell::new_patch_event( + PatchEventType::ApplyBegin { auto_approved }, + changes, + )); self.request_redraw(); } EventMsg::ExecCommandEnd(ExecCommandEndEvent { @@ -389,27 +365,39 @@ impl ChatWidget<'_> { stdout, stderr, }) => { - self.conversation_history - .record_completed_exec_command(call_id, stdout, stderr, exit_code); - self.request_redraw(); + self.add_to_history(HistoryCell::new_completed_exec_command( + call_id, + CommandOutput { + exit_code, + stdout, + stderr, + duration: Duration::from_secs(0), + }, + )); } EventMsg::McpToolCallBegin(McpToolCallBeginEvent { - call_id, - server, - tool, - arguments, + call_id: _, + invocation, }) => { - self.conversation_history - .add_active_mcp_tool_call(call_id, server, tool, arguments); - self.emit_last_history_entry(); + self.add_to_history(HistoryCell::new_active_mcp_tool_call(invocation)); self.request_redraw(); } - EventMsg::McpToolCallEnd(mcp_tool_call_end_event) => { - let success = mcp_tool_call_end_event.is_success(); - let McpToolCallEndEvent { call_id, result } = mcp_tool_call_end_event; - self.conversation_history - .record_completed_mcp_tool_call(call_id, success, result); - self.request_redraw(); + EventMsg::McpToolCallEnd(McpToolCallEndEvent { + call_id: _, + duration, + invocation, + result, + }) => { + self.add_to_history(HistoryCell::new_completed_mcp_tool_call( + 80, + invocation, + duration, + result + .as_ref() + .map(|r| r.is_error.unwrap_or(false)) + .unwrap_or(false), + result, + )); } EventMsg::GetHistoryEntryResponse(event) => { let codex_core::protocol::GetHistoryEntryResponseEvent { @@ -426,9 +414,7 @@ impl ChatWidget<'_> { self.app_event_tx.send(AppEvent::ExitRequest); } event => { - self.conversation_history - .add_background_event(format!("{event:?}")); - self.emit_last_history_entry(); + self.add_to_history(HistoryCell::new_background_event(format!("{event:?}"))); self.request_redraw(); } } @@ -445,22 +431,7 @@ impl ChatWidget<'_> { } pub(crate) fn add_diff_output(&mut self, diff_output: String) { - self.conversation_history - .add_diff_output(diff_output.clone()); - self.emit_last_history_entry(); - self.request_redraw(); - } - - pub(crate) fn handle_scroll_delta(&mut self, scroll_delta: i32) { - // If the user is trying to scroll exactly one line, we let them, but - // otherwise we assume they are trying to scroll in larger increments. - let magnified_scroll_delta = if scroll_delta == 1 { - 1 - } else { - // Play with this: perhaps it should be non-linear? - scroll_delta * 2 - }; - self.conversation_history.scroll(magnified_scroll_delta); + self.add_to_history(HistoryCell::new_diff_output(diff_output.clone())); self.request_redraw(); } diff --git a/codex-rs/tui/src/conversation_history_widget.rs b/codex-rs/tui/src/conversation_history_widget.rs deleted file mode 100644 index dede0caf5f..0000000000 --- a/codex-rs/tui/src/conversation_history_widget.rs +++ /dev/null @@ -1,429 +0,0 @@ -use crate::cell_widget::CellWidget; -use crate::history_cell::CommandOutput; -use crate::history_cell::HistoryCell; -use crate::history_cell::PatchEventType; -use codex_core::config::Config; -use codex_core::protocol::FileChange; -use codex_core::protocol::SessionConfiguredEvent; -use ratatui::prelude::*; -use ratatui::style::Style; -use ratatui::widgets::*; -use serde_json::Value as JsonValue; -use std::cell::Cell as StdCell; -use std::cell::Cell; -use std::collections::HashMap; -use std::path::PathBuf; - -/// A single history entry plus its cached wrapped-line count. -struct Entry { - cell: HistoryCell, - line_count: Cell, -} - -pub struct ConversationHistoryWidget { - entries: Vec, - /// The width (in terminal cells/columns) that [`Entry::line_count`] was - /// computed for. When the available width changes we recompute counts. - cached_width: StdCell, - scroll_position: usize, - /// Number of lines the last time render_ref() was called - num_rendered_lines: StdCell, - /// The height of the viewport last time render_ref() was called - last_viewport_height: StdCell, - has_input_focus: bool, -} - -impl ConversationHistoryWidget { - pub fn new() -> Self { - Self { - entries: Vec::new(), - cached_width: StdCell::new(0), - scroll_position: usize::MAX, - num_rendered_lines: StdCell::new(0), - last_viewport_height: StdCell::new(0), - has_input_focus: false, - } - } - - /// Negative delta scrolls up; positive delta scrolls down. - pub(crate) fn scroll(&mut self, delta: i32) { - match delta.cmp(&0) { - std::cmp::Ordering::Less => self.scroll_up(-delta as u32), - std::cmp::Ordering::Greater => self.scroll_down(delta as u32), - std::cmp::Ordering::Equal => {} - } - } - - fn scroll_up(&mut self, num_lines: u32) { - // If a user is scrolling up from the "stick to bottom" mode, we need to - // map this to a specific scroll position so we can calculate the delta. - // This requires us to care about how tall the screen is. - if self.scroll_position == usize::MAX { - self.scroll_position = self - .num_rendered_lines - .get() - .saturating_sub(self.last_viewport_height.get()); - } - - self.scroll_position = self.scroll_position.saturating_sub(num_lines as usize); - } - - fn scroll_down(&mut self, num_lines: u32) { - // If we're already pinned to the bottom there's nothing to do. - if self.scroll_position == usize::MAX { - return; - } - - let viewport_height = self.last_viewport_height.get().max(1); - let num_rendered_lines = self.num_rendered_lines.get(); - - // Compute the maximum explicit scroll offset that still shows a full - // viewport. This mirrors the calculation in `scroll_page_down()` and - // in the render path. - let max_scroll = num_rendered_lines.saturating_sub(viewport_height); - - let new_pos = self.scroll_position.saturating_add(num_lines as usize); - - if new_pos >= max_scroll { - // Reached (or passed) the bottom – switch to stick‑to‑bottom mode - // so that additional output keeps the view pinned automatically. - self.scroll_position = usize::MAX; - } else { - self.scroll_position = new_pos; - } - } - - pub fn scroll_to_bottom(&mut self) { - self.scroll_position = usize::MAX; - } - - /// Note `model` could differ from `config.model` if the agent decided to - /// use a different model than the one requested by the user. - pub fn add_session_info(&mut self, config: &Config, event: SessionConfiguredEvent) { - // In practice, SessionConfiguredEvent should always be the first entry - // in the history, but it is possible that an error could be sent - // before the session info. - let has_welcome_message = self - .entries - .iter() - .any(|entry| matches!(entry.cell, HistoryCell::WelcomeMessage { .. })); - self.add_to_history(HistoryCell::new_session_info( - config, - event, - !has_welcome_message, - )); - } - - pub fn add_user_message(&mut self, message: String) { - self.add_to_history(HistoryCell::new_user_prompt(message)); - } - - pub fn add_agent_message(&mut self, config: &Config, message: String) { - self.add_to_history(HistoryCell::new_agent_message(config, message)); - } - - pub fn add_agent_reasoning(&mut self, config: &Config, text: String) { - self.add_to_history(HistoryCell::new_agent_reasoning(config, text)); - } - - pub fn add_background_event(&mut self, message: String) { - self.add_to_history(HistoryCell::new_background_event(message)); - } - - pub fn add_diff_output(&mut self, diff_output: String) { - self.add_to_history(HistoryCell::new_diff_output(diff_output)); - } - - pub fn add_error(&mut self, message: String) { - self.add_to_history(HistoryCell::new_error_event(message)); - } - - /// Add a pending patch entry (before user approval). - pub fn add_patch_event( - &mut self, - event_type: PatchEventType, - changes: HashMap, - ) { - self.add_to_history(HistoryCell::new_patch_event(event_type, changes)); - } - - pub fn add_active_exec_command(&mut self, call_id: String, command: Vec) { - self.add_to_history(HistoryCell::new_active_exec_command(call_id, command)); - } - - pub fn add_active_mcp_tool_call( - &mut self, - call_id: String, - server: String, - tool: String, - arguments: Option, - ) { - self.add_to_history(HistoryCell::new_active_mcp_tool_call( - call_id, server, tool, arguments, - )); - } - - fn add_to_history(&mut self, cell: HistoryCell) { - let width = self.cached_width.get(); - let count = if width > 0 { cell.height(width) } else { 0 }; - - self.entries.push(Entry { - cell, - line_count: Cell::new(count), - }); - } - - /// Return the lines for the most recently appended entry (if any) so the - /// parent widget can surface them via the new scrollback insertion path. - pub(crate) fn last_entry_plain_lines(&self) -> Option>> { - self.entries.last().map(|e| e.cell.plain_lines()) - } - - pub fn record_completed_exec_command( - &mut self, - call_id: String, - stdout: String, - stderr: String, - exit_code: i32, - ) { - let width = self.cached_width.get(); - for entry in self.entries.iter_mut() { - let cell = &mut entry.cell; - if let HistoryCell::ActiveExecCommand { - call_id: history_id, - command, - start, - .. - } = cell - { - if &call_id == history_id { - *cell = HistoryCell::new_completed_exec_command( - command.clone(), - CommandOutput { - exit_code, - stdout, - stderr, - duration: start.elapsed(), - }, - ); - - // Update cached line count. - if width > 0 { - entry.line_count.set(cell.height(width)); - } - break; - } - } - } - } - - pub fn record_completed_mcp_tool_call( - &mut self, - call_id: String, - success: bool, - result: Result, - ) { - let width = self.cached_width.get(); - for entry in self.entries.iter_mut() { - if let HistoryCell::ActiveMcpToolCall { - call_id: history_id, - invocation, - start, - .. - } = &entry.cell - { - if &call_id == history_id { - let completed = HistoryCell::new_completed_mcp_tool_call( - width, - invocation.clone(), - *start, - success, - result, - ); - entry.cell = completed; - - if width > 0 { - entry.line_count.set(entry.cell.height(width)); - } - - break; - } - } - } - } -} - -impl WidgetRef for ConversationHistoryWidget { - fn render_ref(&self, area: Rect, buf: &mut Buffer) { - let (title, border_style) = if self.has_input_focus { - ( - "Messages (↑/↓ or j/k = line, b/space = page)", - Style::default().fg(Color::LightYellow), - ) - } else { - ("Messages (tab to focus)", Style::default().dim()) - }; - - let block = Block::default() - .title(title) - .borders(Borders::ALL) - .border_type(BorderType::Rounded) - .border_style(border_style); - - // Compute the inner area that will be available for the list after - // the surrounding `Block` is drawn. - let inner = block.inner(area); - let viewport_height = inner.height as usize; - - // Cache (and if necessary recalculate) the wrapped line counts for every - // [`HistoryCell`] so that our scrolling math accounts for text - // wrapping. We always reserve one column on the right-hand side for the - // scrollbar so that the content never renders "under" the scrollbar. - let effective_width = inner.width.saturating_sub(1); - - if effective_width == 0 { - return; // Nothing to draw – avoid division by zero. - } - - // Recompute cache if the effective width changed. - let num_lines: usize = if self.cached_width.get() != effective_width { - self.cached_width.set(effective_width); - - let mut num_lines: usize = 0; - for entry in &self.entries { - let count = entry.cell.height(effective_width); - num_lines += count; - entry.line_count.set(count); - } - num_lines - } else { - self.entries.iter().map(|e| e.line_count.get()).sum() - }; - - // Determine the scroll position. Note the existing value of - // `self.scroll_position` could exceed the maximum scroll offset if the - // user made the window wider since the last render. - let max_scroll = num_lines.saturating_sub(viewport_height); - let scroll_pos = if self.scroll_position == usize::MAX { - max_scroll - } else { - self.scroll_position.min(max_scroll) - }; - - // ------------------------------------------------------------------ - // Render order: - // 1. Clear full widget area (avoid artifacts from prior frame). - // 2. Draw the surrounding Block (border and title). - // 3. Render *each* visible HistoryCell into its own sub-Rect while - // respecting partial visibility at the top and bottom. - // 4. Draw the scrollbar track / thumb in the reserved column. - // ------------------------------------------------------------------ - - // Clear entire widget area first. - Clear.render(area, buf); - - // Draw border + title. - block.render(area, buf); - - // ------------------------------------------------------------------ - // Calculate which cells are visible for the current scroll position - // and paint them one by one. - // ------------------------------------------------------------------ - - let mut y_cursor = inner.y; // first line inside viewport - let mut remaining_height = inner.height as usize; - let mut lines_to_skip = scroll_pos; // number of wrapped lines to skip (above viewport) - - for entry in &self.entries { - let cell_height = entry.line_count.get(); - - // Completely above viewport? Skip whole cell. - if lines_to_skip >= cell_height { - lines_to_skip -= cell_height; - continue; - } - - // Determine how much of this cell is visible. - let visible_height = (cell_height - lines_to_skip).min(remaining_height); - - if visible_height == 0 { - break; // no space left - } - - let cell_rect = Rect { - x: inner.x, - y: y_cursor, - width: effective_width, - height: visible_height as u16, - }; - - entry.cell.render_window(lines_to_skip, cell_rect, buf); - - // Advance cursor inside viewport. - y_cursor += visible_height as u16; - remaining_height -= visible_height; - - // After the first (possibly partially skipped) cell, we no longer - // need to skip lines at the top. - lines_to_skip = 0; - - if remaining_height == 0 { - break; // viewport filled - } - } - - // Always render a scrollbar *track* so the reserved column is filled. - let overflow = num_lines.saturating_sub(viewport_height); - - let mut scroll_state = ScrollbarState::default() - // The Scrollbar widget expects the *content* height minus the - // viewport height. When there is no overflow we still provide 0 - // so that the widget renders only the track without a thumb. - .content_length(overflow) - .position(scroll_pos); - - { - // Choose a thumb color that stands out only when this pane has focus so that the - // user's attention is naturally drawn to the active viewport. When unfocused we show - // a low-contrast thumb so the scrollbar fades into the background without becoming - // invisible. - let thumb_style = if self.has_input_focus { - Style::reset().fg(Color::LightYellow) - } else { - Style::reset().fg(Color::Gray) - }; - - // By default the Scrollbar widget inherits any style that was - // present in the underlying buffer cells. That means if a colored - // line happens to be underneath the scrollbar, the track (and - // potentially the thumb) adopt that color. Explicitly setting the - // track/thumb styles ensures we always draw the scrollbar with a - // consistent palette regardless of what content is behind it. - StatefulWidget::render( - Scrollbar::new(ScrollbarOrientation::VerticalRight) - .begin_symbol(Some("↑")) - .end_symbol(Some("↓")) - .begin_style(Style::reset().fg(Color::DarkGray)) - .end_style(Style::reset().fg(Color::DarkGray)) - .thumb_symbol("█") - .thumb_style(thumb_style) - .track_symbol(Some("│")) - .track_style(Style::reset().fg(Color::DarkGray)), - inner, - buf, - &mut scroll_state, - ); - } - - // Update auxiliary stats that the scroll handlers rely on. - self.num_rendered_lines.set(num_lines); - self.last_viewport_height.set(viewport_height); - } -} - -/// Common [`Wrap`] configuration used for both measurement and rendering so -/// they stay in sync. -#[inline] -pub(crate) const fn wrap_cfg() -> ratatui::widgets::Wrap { - ratatui::widgets::Wrap { trim: false } -} diff --git a/codex-rs/tui/src/history_cell.rs b/codex-rs/tui/src/history_cell.rs index ab657163ad..04279a01f8 100644 --- a/codex-rs/tui/src/history_cell.rs +++ b/codex-rs/tui/src/history_cell.rs @@ -1,4 +1,3 @@ -use crate::cell_widget::CellWidget; use crate::exec_command::escape_command; use crate::markdown::append_markdown; use crate::text_block::TextBlock; @@ -11,11 +10,10 @@ use codex_core::WireApi; use codex_core::config::Config; use codex_core::model_supports_reasoning_summaries; use codex_core::protocol::FileChange; +use codex_core::protocol::McpInvocation; use codex_core::protocol::SessionConfiguredEvent; use image::DynamicImage; -use image::GenericImageView; use image::ImageReader; -use lazy_static::lazy_static; use mcp_types::EmbeddedResourceResource; use mcp_types::ResourceLink; use ratatui::prelude::*; @@ -24,14 +22,10 @@ use ratatui::style::Modifier; use ratatui::style::Style; use ratatui::text::Line as RtLine; use ratatui::text::Span as RtSpan; -use ratatui_image::Image as TuiImage; -use ratatui_image::Resize as ImgResize; -use ratatui_image::picker::ProtocolType; use std::collections::HashMap; use std::io::Cursor; use std::path::PathBuf; use std::time::Duration; -use std::time::Instant; use tracing::error; pub(crate) struct CommandOutput { @@ -46,6 +40,21 @@ pub(crate) enum PatchEventType { ApplyBegin { auto_approved: bool }, } +fn span_to_static(span: &Span) -> Span<'static> { + Span { + style: span.style, + content: std::borrow::Cow::Owned(span.content.clone().into_owned()), + } +} + +fn line_to_static(line: &Line) -> Line<'static> { + Line { + style: line.style, + alignment: line.alignment, + spans: line.spans.iter().map(span_to_static).collect(), + } +} + /// Represents an event to display in the conversation history. Returns its /// `Vec>` representation to make it easier to display in a /// scrollable list. @@ -63,25 +72,13 @@ pub(crate) enum HistoryCell { AgentReasoning { view: TextBlock }, /// An exec tool call that has not finished yet. - ActiveExecCommand { - call_id: String, - /// The shell command, escaped and formatted. - command: String, - start: Instant, - view: TextBlock, - }, + ActiveExecCommand { view: TextBlock }, /// Completed exec tool call. CompletedExecCommand { view: TextBlock }, /// An MCP tool call that has not finished yet. - ActiveMcpToolCall { - call_id: String, - /// Formatted line that shows the command name and arguments - invocation: Line<'static>, - start: Instant, - view: TextBlock, - }, + ActiveMcpToolCall { view: TextBlock }, /// Completed MCP tool call where we show the result serialized as JSON. CompletedMcpToolCall { view: TextBlock }, @@ -94,13 +91,7 @@ pub(crate) enum HistoryCell { // resized version avoids doing the potentially expensive rescale twice // because the scroll-view first calls `height()` for layouting and then // `render_window()` for painting. - CompletedMcpToolCallWithImageOutput { - image: DynamicImage, - /// Cached data derived from the current terminal width. The cache is - /// invalidated whenever the width changes (e.g. when the user - /// resizes the window). - render_cache: std::cell::RefCell>, - }, + CompletedMcpToolCallWithImageOutput { _image: DynamicImage }, /// Background event. BackgroundEvent { view: TextBlock }, @@ -140,7 +131,9 @@ impl HistoryCell { | HistoryCell::CompletedMcpToolCall { view } | HistoryCell::PendingPatch { view } | HistoryCell::ActiveExecCommand { view, .. } - | HistoryCell::ActiveMcpToolCall { view, .. } => view.lines.clone(), + | HistoryCell::ActiveMcpToolCall { view, .. } => { + view.lines.iter().map(line_to_static).collect() + } HistoryCell::CompletedMcpToolCallWithImageOutput { .. } => vec![ Line::from("tool result (image output omitted)"), Line::from(""), @@ -252,9 +245,8 @@ impl HistoryCell { } } - pub(crate) fn new_active_exec_command(call_id: String, command: Vec) -> Self { + pub(crate) fn new_active_exec_command(command: Vec) -> Self { let command_escaped = escape_command(&command); - let start = Instant::now(); let lines: Vec> = vec![ Line::from(vec!["command".magenta(), " running...".dim()]), @@ -263,9 +255,6 @@ impl HistoryCell { ]; HistoryCell::ActiveExecCommand { - call_id, - command: command_escaped, - start, view: TextBlock::new(lines), } } @@ -310,41 +299,15 @@ impl HistoryCell { } } - pub(crate) fn new_active_mcp_tool_call( - call_id: String, - server: String, - tool: String, - arguments: Option, - ) -> Self { - // Format the arguments as compact JSON so they roughly fit on one - // line. If there are no arguments we keep it empty so the invocation - // mirrors a function-style call. - let args_str = arguments - .as_ref() - .map(|v| { - // Use compact form to keep things short but readable. - serde_json::to_string(v).unwrap_or_else(|_| v.to_string()) - }) - .unwrap_or_default(); - - let invocation_spans = vec![ - Span::styled(server, Style::default().fg(Color::Blue)), - Span::raw("."), - Span::styled(tool, Style::default().fg(Color::Blue)), - Span::raw("("), - Span::styled(args_str, Style::default().fg(Color::Gray)), - Span::raw(")"), - ]; - let invocation = Line::from(invocation_spans); - - let start = Instant::now(); + pub(crate) fn new_active_mcp_tool_call(invocation: McpInvocation) -> Self { let title_line = Line::from(vec!["tool".magenta(), " running...".dim()]); - let lines: Vec> = vec![title_line, invocation.clone(), Line::from("")]; + let lines: Vec = vec![ + title_line, + format_mcp_invocation(invocation.clone()), + Line::from(""), + ]; HistoryCell::ActiveMcpToolCall { - call_id, - invocation, - start, view: TextBlock::new(lines), } } @@ -382,10 +345,7 @@ impl HistoryCell { } }; - Some(HistoryCell::CompletedMcpToolCallWithImageOutput { - image, - render_cache: std::cell::RefCell::new(None), - }) + Some(HistoryCell::CompletedMcpToolCallWithImageOutput { _image: image }) } else { None } @@ -396,8 +356,8 @@ impl HistoryCell { pub(crate) fn new_completed_mcp_tool_call( num_cols: u16, - invocation: Line<'static>, - start: Instant, + invocation: McpInvocation, + duration: Duration, success: bool, result: Result, ) -> Self { @@ -405,7 +365,7 @@ impl HistoryCell { return cell; } - let duration = format_duration(start.elapsed()); + let duration = format_duration(duration); let status_str = if success { "success" } else { "failed" }; let title_line = Line::from(vec![ "tool".magenta(), @@ -420,7 +380,7 @@ impl HistoryCell { let mut lines: Vec> = Vec::new(); lines.push(title_line); - lines.push(invocation); + lines.push(format_mcp_invocation(invocation)); match result { Ok(mcp_types::CallToolResult { content, .. }) => { @@ -581,85 +541,6 @@ impl HistoryCell { } } -// --------------------------------------------------------------------------- -// `CellWidget` implementation – most variants delegate to their internal -// `TextBlock`. Variants that need custom painting can add their own logic in -// the match arms. -// --------------------------------------------------------------------------- - -impl CellWidget for HistoryCell { - fn height(&self, width: u16) -> usize { - match self { - HistoryCell::WelcomeMessage { view } - | HistoryCell::UserPrompt { view } - | HistoryCell::AgentMessage { view } - | HistoryCell::AgentReasoning { view } - | HistoryCell::BackgroundEvent { view } - | HistoryCell::GitDiffOutput { view } - | HistoryCell::ErrorEvent { view } - | HistoryCell::SessionInfo { view } - | HistoryCell::CompletedExecCommand { view } - | HistoryCell::CompletedMcpToolCall { view } - | HistoryCell::PendingPatch { view } - | HistoryCell::ActiveExecCommand { view, .. } - | HistoryCell::ActiveMcpToolCall { view, .. } => view.height(width), - HistoryCell::CompletedMcpToolCallWithImageOutput { - image, - render_cache, - } => ensure_image_cache(image, width, render_cache), - } - } - - fn render_window(&self, first_visible_line: usize, area: Rect, buf: &mut Buffer) { - match self { - HistoryCell::WelcomeMessage { view } - | HistoryCell::UserPrompt { view } - | HistoryCell::AgentMessage { view } - | HistoryCell::AgentReasoning { view } - | HistoryCell::BackgroundEvent { view } - | HistoryCell::GitDiffOutput { view } - | HistoryCell::ErrorEvent { view } - | HistoryCell::SessionInfo { view } - | HistoryCell::CompletedExecCommand { view } - | HistoryCell::CompletedMcpToolCall { view } - | HistoryCell::PendingPatch { view } - | HistoryCell::ActiveExecCommand { view, .. } - | HistoryCell::ActiveMcpToolCall { view, .. } => { - view.render_window(first_visible_line, area, buf) - } - HistoryCell::CompletedMcpToolCallWithImageOutput { - image, - render_cache, - } => { - // Ensure we have a cached, resized copy that matches the current width. - // `height()` should have prepared the cache, but if something invalidated it - // (e.g. the first `render_window()` call happens *before* `height()` after a - // resize) we rebuild it here. - - let width_cells = area.width; - - // Ensure the cache is up-to-date and extract the scaled image. - let _ = ensure_image_cache(image, width_cells, render_cache); - - let Some(resized) = render_cache - .borrow() - .as_ref() - .map(|c| c.scaled_image.clone()) - else { - return; - }; - - let picker = &*TERMINAL_PICKER; - - if let Ok(protocol) = picker.new_protocol(resized, area, ImgResize::Fit(None)) { - let img_widget = TuiImage::new(&protocol); - img_widget.render(area, buf); - } - } - } - } -} - fn create_diff_summary(changes: HashMap) -> Vec { // Build a concise, human‑readable summary list similar to the // `git status` short format so the user can reason about the @@ -692,119 +573,23 @@ fn create_diff_summary(changes: HashMap) -> Vec { summaries } -// ------------------------------------- -// Helper types for image rendering -// ------------------------------------- +fn format_mcp_invocation<'a>(invocation: McpInvocation) -> Line<'a> { + let args_str = invocation + .arguments + .as_ref() + .map(|v| { + // Use compact form to keep things short but readable. + serde_json::to_string(v).unwrap_or_else(|_| v.to_string()) + }) + .unwrap_or_default(); -/// Cached information for rendering an image inside a conversation cell. -/// -/// The cache ties the resized image to a *specific* content width (in -/// terminal cells). Whenever the terminal is resized and the width changes -/// we need to re-compute the scaled variant so that it still fits the -/// available space. Keeping the resized copy around saves a costly rescale -/// between the back-to-back `height()` and `render_window()` calls that the -/// scroll-view performs while laying out the UI. -pub(crate) struct ImageRenderCache { - /// Width in *terminal cells* the cached image was generated for. - width_cells: u16, - /// Height in *terminal rows* that the conversation cell must occupy so - /// the whole image becomes visible. - height_rows: usize, - /// The resized image that fits the given width / height constraints. - scaled_image: DynamicImage, -} - -lazy_static! { - static ref TERMINAL_PICKER: ratatui_image::picker::Picker = { - use ratatui_image::picker::Picker; - use ratatui_image::picker::cap_parser::QueryStdioOptions; - - // Ask the terminal for capabilities and explicit font size. Request the - // Kitty *text-sizing protocol* as a fallback mechanism for terminals - // (like iTerm2) that do not reply to the standard CSI 16/18 queries. - match Picker::from_query_stdio_with_options(QueryStdioOptions { - text_sizing_protocol: true, - }) { - Ok(picker) => picker, - Err(err) => { - // Fall back to the conservative default that assumes ~8×16 px cells. - // Still better than breaking the build in a headless test run. - tracing::warn!("terminal capability query failed: {err:?}; using default font size"); - Picker::from_fontsize((8, 16)) - } - } - }; -} - -/// Resize `image` to fit into `width_cells`×10-rows keeping the original aspect -/// ratio. The function updates `render_cache` and returns the number of rows -/// (<= 10) the picture will occupy. -fn ensure_image_cache( - image: &DynamicImage, - width_cells: u16, - render_cache: &std::cell::RefCell>, -) -> usize { - if let Some(cache) = render_cache.borrow().as_ref() { - if cache.width_cells == width_cells { - return cache.height_rows; - } - } - - let picker = &*TERMINAL_PICKER; - let (char_w_px, char_h_px) = picker.font_size(); - - // Heuristic to compensate for Hi-DPI terminals (iTerm2 on Retina Mac) that - // report logical pixels (≈ 8×16) while the iTerm2 graphics protocol - // expects *device* pixels. Empirically the device-pixel-ratio is almost - // always 2 on macOS Retina panels. - let hidpi_scale = if picker.protocol_type() == ProtocolType::Iterm2 { - 2.0f64 - } else { - 1.0 - }; - - // The fallback Halfblocks protocol encodes two pixel rows per cell, so each - // terminal *row* represents only half the (possibly scaled) font height. - let effective_char_h_px: f64 = if picker.protocol_type() == ProtocolType::Halfblocks { - (char_h_px as f64) * hidpi_scale / 2.0 - } else { - (char_h_px as f64) * hidpi_scale - }; - - let char_w_px_f64 = (char_w_px as f64) * hidpi_scale; - - const MAX_ROWS: f64 = 10.0; - let max_height_px: f64 = effective_char_h_px * MAX_ROWS; - - let (orig_w_px, orig_h_px) = { - let (w, h) = image.dimensions(); - (w as f64, h as f64) - }; - - if orig_w_px == 0.0 || orig_h_px == 0.0 || width_cells == 0 { - *render_cache.borrow_mut() = None; - return 0; - } - - let max_w_px = char_w_px_f64 * width_cells as f64; - let scale_w = max_w_px / orig_w_px; - let scale_h = max_height_px / orig_h_px; - let scale = scale_w.min(scale_h).min(1.0); - - use image::imageops::FilterType; - let scaled_w_px = (orig_w_px * scale).round().max(1.0) as u32; - let scaled_h_px = (orig_h_px * scale).round().max(1.0) as u32; - - let scaled_image = image.resize(scaled_w_px, scaled_h_px, FilterType::Lanczos3); - - let height_rows = ((scaled_h_px as f64 / effective_char_h_px).ceil()) as usize; - - let new_cache = ImageRenderCache { - width_cells, - height_rows, - scaled_image, - }; - *render_cache.borrow_mut() = Some(new_cache); - - height_rows + let invocation_spans = vec![ + Span::styled(invocation.server.clone(), Style::default().fg(Color::Blue)), + Span::raw("."), + Span::styled(invocation.tool.clone(), Style::default().fg(Color::Blue)), + Span::raw("("), + Span::styled(args_str, Style::default().fg(Color::Gray)), + Span::raw(")"), + ]; + Line::from(invocation_spans) } diff --git a/codex-rs/tui/src/insert_history.rs b/codex-rs/tui/src/insert_history.rs index 1e8b1f5392..32d0b4b297 100644 --- a/codex-rs/tui/src/insert_history.rs +++ b/codex-rs/tui/src/insert_history.rs @@ -21,7 +21,7 @@ use ratatui::text::Line; use ratatui::text::Span; /// Insert `lines` above the viewport. -pub(crate) fn insert_history_lines(terminal: &mut tui::Tui, lines: Vec>) { +pub(crate) fn insert_history_lines(terminal: &mut tui::Tui, lines: Vec) { let screen_size = terminal.backend().size().unwrap_or(Size::new(0, 0)); let mut area = terminal.get_frame().area(); diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index 6c6c662154..7bc041a554 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -24,11 +24,9 @@ mod app; mod app_event; mod app_event_sender; mod bottom_pane; -mod cell_widget; mod chatwidget; mod citation_regex; mod cli; -mod conversation_history_widget; mod exec_command; mod file_search; mod get_git_diff; @@ -37,7 +35,6 @@ mod history_cell; mod insert_history; mod log_layer; mod markdown; -mod scroll_event_helper; mod slash_command; mod status_indicator_widget; mod text_block; diff --git a/codex-rs/tui/src/scroll_event_helper.rs b/codex-rs/tui/src/scroll_event_helper.rs deleted file mode 100644 index ad3ae37e0d..0000000000 --- a/codex-rs/tui/src/scroll_event_helper.rs +++ /dev/null @@ -1,77 +0,0 @@ -use std::sync::Arc; -use std::sync::atomic::AtomicBool; -use std::sync::atomic::AtomicI32; -use std::sync::atomic::Ordering; - -use tokio::runtime::Handle; -use tokio::time::Duration; -use tokio::time::sleep; - -use crate::app_event::AppEvent; -use crate::app_event_sender::AppEventSender; - -pub(crate) struct ScrollEventHelper { - app_event_tx: AppEventSender, - scroll_delta: Arc, - timer_scheduled: Arc, - runtime: Handle, -} - -/// How long to wait after the first scroll event before sending the -/// accumulated scroll delta to the main thread. -const DEBOUNCE_WINDOW: Duration = Duration::from_millis(100); - -/// Utility to debounce scroll events so we can determine the **magnitude** of -/// each scroll burst by accumulating individual wheel events over a short -/// window. The debounce timer now runs on Tokio so we avoid spinning up a new -/// operating-system thread for every burst. -impl ScrollEventHelper { - pub(crate) fn new(app_event_tx: AppEventSender) -> Self { - Self { - app_event_tx, - scroll_delta: Arc::new(AtomicI32::new(0)), - timer_scheduled: Arc::new(AtomicBool::new(false)), - runtime: Handle::current(), - } - } - - pub(crate) fn scroll_up(&self) { - self.scroll_delta.fetch_sub(1, Ordering::Relaxed); - self.schedule_notification(); - } - - pub(crate) fn scroll_down(&self) { - self.scroll_delta.fetch_add(1, Ordering::Relaxed); - self.schedule_notification(); - } - - /// Starts a one-shot timer **only once** per burst of wheel events. - fn schedule_notification(&self) { - // If the timer is already scheduled, do nothing. - if self - .timer_scheduled - .compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst) - .is_err() - { - return; - } - - // Otherwise, schedule a new timer. - let tx = self.app_event_tx.clone(); - let delta = Arc::clone(&self.scroll_delta); - let timer_flag = Arc::clone(&self.timer_scheduled); - - // Use self.runtime instead of tokio::spawn() because the calling thread - // in app.rs is not part of the Tokio runtime: it is a plain OS thread. - self.runtime.spawn(async move { - sleep(DEBOUNCE_WINDOW).await; - - let accumulated = delta.swap(0, Ordering::SeqCst); - if accumulated != 0 { - tx.send(AppEvent::Scroll(accumulated)); - } - - timer_flag.store(false, Ordering::SeqCst); - }); - } -} diff --git a/codex-rs/tui/src/text_block.rs b/codex-rs/tui/src/text_block.rs index 2c68d90f11..33f326b83d 100644 --- a/codex-rs/tui/src/text_block.rs +++ b/codex-rs/tui/src/text_block.rs @@ -1,4 +1,3 @@ -use crate::cell_widget::CellWidget; use ratatui::prelude::*; /// A simple widget that just displays a list of `Line`s via a `Paragraph`. @@ -13,20 +12,3 @@ impl TextBlock { Self { lines } } } - -impl CellWidget for TextBlock { - fn height(&self, width: u16) -> usize { - // Use the same wrapping configuration as ConversationHistoryWidget so - // measurement stays in sync with rendering. - ratatui::widgets::Paragraph::new(self.lines.clone()) - .wrap(crate::conversation_history_widget::wrap_cfg()) - .line_count(width) - } - - fn render_window(&self, first_visible_line: usize, area: Rect, buf: &mut Buffer) { - ratatui::widgets::Paragraph::new(self.lines.clone()) - .wrap(crate::conversation_history_widget::wrap_cfg()) - .scroll((first_visible_line as u16, 0)) - .render(area, buf); - } -} From 93341797c4172e948fdba93c8878c3d286c36e51 Mon Sep 17 00:00:00 2001 From: aibrahim-oai Date: Wed, 30 Jul 2025 11:32:38 -0700 Subject: [PATCH 53/58] fix ci (#1739) I think this commit broke the CI because it changed the `McpToolCallBeginEvent` type: https://github.com/openai/codex/commit/347c81ad0049103c84e0aa2c0d7e2988db18218a --- codex-rs/mcp-server/src/mcp_protocol.rs | 42 ++++++++++++++----------- 1 file changed, 24 insertions(+), 18 deletions(-) diff --git a/codex-rs/mcp-server/src/mcp_protocol.rs b/codex-rs/mcp-server/src/mcp_protocol.rs index 05eb0a258a..933c145b48 100644 --- a/codex-rs/mcp-server/src/mcp_protocol.rs +++ b/codex-rs/mcp-server/src/mcp_protocol.rs @@ -219,7 +219,7 @@ pub struct ConversationSummary { pub enum ServerNotification { InitialState(InitialStateNotificationParams), StreamDisconnected(StreamDisconnectedNotificationParams), - CodexEvent(CodexEventNotificationParams), + CodexEvent(Box), } #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] @@ -304,6 +304,8 @@ pub enum ClientNotification { #[allow(clippy::unwrap_used)] mod tests { use super::*; + use codex_core::protocol::McpInvocation; + use codex_core::protocol::McpToolCallBeginEvent; use pretty_assertions::assert_eq; use serde::Serialize; use serde_json::Value; @@ -727,7 +729,7 @@ mod tests { }), }; - let observed = to_val(&ServerNotification::CodexEvent(params)); + let observed = to_val(&ServerNotification::CodexEvent(Box::new(params))); let expected = json!({ "method": "notifications/agent_message", "params": { @@ -753,7 +755,7 @@ mod tests { msg: EventMsg::TaskStarted, }; - let observed = to_val(&ServerNotification::CodexEvent(params)); + let observed = to_val(&ServerNotification::CodexEvent(Box::new(params))); let expected = json!({ "method": "notifications/task_started", "params": { @@ -776,7 +778,7 @@ mod tests { }), }; - let observed = to_val(&ServerNotification::CodexEvent(params)); + let observed = to_val(&ServerNotification::CodexEvent(Box::new(params))); let expected = json!({ "method": "notifications/agent_message_delta", "params": { @@ -800,7 +802,7 @@ mod tests { }), }; - let observed = to_val(&ServerNotification::CodexEvent(params)); + let observed = to_val(&ServerNotification::CodexEvent(Box::new(params))); let expected = json!({ "method": "notifications/agent_message", "params": { @@ -823,7 +825,7 @@ mod tests { }), }; - let observed = to_val(&ServerNotification::CodexEvent(params)); + let observed = to_val(&ServerNotification::CodexEvent(Box::new(params))); let expected = json!({ "method": "notifications/agent_reasoning", "params": { @@ -847,7 +849,7 @@ mod tests { msg: EventMsg::TokenCount(usage), }; - let observed = to_val(&ServerNotification::CodexEvent(params)); + let observed = to_val(&ServerNotification::CodexEvent(Box::new(params))); let expected = json!({ "method": "notifications/token_count", "params": { @@ -881,7 +883,7 @@ mod tests { }), }; - let observed = to_val(&ServerNotification::CodexEvent(params)); + let observed = to_val(&ServerNotification::CodexEvent(Box::new(params))); let expected = json!({ "method": "notifications/session_configured", "params": { @@ -909,7 +911,7 @@ mod tests { }), }; - let observed = to_val(&ServerNotification::CodexEvent(params)); + let observed = to_val(&ServerNotification::CodexEvent(Box::new(params))); let expected = json!({ "method": "notifications/exec_command_begin", "params": { @@ -928,24 +930,28 @@ mod tests { fn serialize_notification_codex_event_mcp_tool_call_begin_full_json() { let params = CodexEventNotificationParams { meta: None, - msg: EventMsg::McpToolCallBegin(codex_core::protocol::McpToolCallBeginEvent { + msg: EventMsg::McpToolCallBegin(McpToolCallBeginEvent { call_id: "m1".into(), - server: "calc".into(), - tool: "add".into(), - arguments: Some(json!({"a":1,"b":2})), + invocation: McpInvocation { + server: "calc".into(), + tool: "add".into(), + arguments: Some(json!({"a":1,"b":2})), + }, }), }; - let observed = to_val(&ServerNotification::CodexEvent(params)); + let observed = to_val(&ServerNotification::CodexEvent(Box::new(params))); let expected = json!({ "method": "notifications/mcp_tool_call_begin", "params": { "msg": { "type": "mcp_tool_call_begin", "call_id": "m1", - "server": "calc", - "tool": "add", - "arguments": { "a": 1, "b": 2 } + "invocation": { + "server": "calc", + "tool": "add", + "arguments": { "a": 1, "b": 2 } + } } } }); @@ -964,7 +970,7 @@ mod tests { }), }; - let observed = to_val(&ServerNotification::CodexEvent(params)); + let observed = to_val(&ServerNotification::CodexEvent(Box::new(params))); let expected = json!({ "method": "notifications/patch_apply_end", "params": { From ea01a5ffe213b2e75783fd5c1a685b3a74688437 Mon Sep 17 00:00:00 2001 From: pakrym-oai Date: Wed, 30 Jul 2025 12:40:15 -0700 Subject: [PATCH 54/58] Add support for a separate chatgpt auth endpoint (#1712) Adds a `CodexAuth` type that encapsulates information about available auth modes and logic for refreshing the token. Changes `Responses` API to send requests to different endpoints based on the auth type. Updates login_with_chatgpt to support API-less mode and skip the key exchange. --- codex-rs/Cargo.lock | 2 + codex-rs/chatgpt/src/chatgpt_client.rs | 6 +- codex-rs/chatgpt/src/chatgpt_token.rs | 7 +- codex-rs/cli/src/proto.rs | 4 +- codex-rs/config.md | 3 + codex-rs/core/Cargo.toml | 2 + codex-rs/core/src/client.rs | 47 ++++- codex-rs/core/src/codex.rs | 11 +- codex-rs/core/src/codex_wrapper.rs | 4 +- codex-rs/core/src/config.rs | 4 +- codex-rs/core/src/lib.rs | 2 +- codex-rs/core/src/model_provider_info.rs | 129 +++++++----- codex-rs/core/src/openai_api_key.rs | 24 --- codex-rs/core/tests/client.rs | 180 ++++++++++++---- codex-rs/core/tests/live_agent.rs | 2 +- codex-rs/core/tests/stream_no_completed.rs | 12 +- codex-rs/login/src/lib.rs | 232 ++++++++++++++++----- codex-rs/login/src/login_with_chatgpt.py | 150 +++++++------ codex-rs/tui/src/lib.rs | 39 ++-- 19 files changed, 575 insertions(+), 285 deletions(-) delete mode 100644 codex-rs/core/src/openai_api_key.rs diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 9abce0c3db..120050c227 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -673,7 +673,9 @@ dependencies = [ "async-channel", "base64 0.22.1", "bytes", + "chrono", "codex-apply-patch", + "codex-login", "codex-mcp-client", "core_test_support", "dirs", diff --git a/codex-rs/chatgpt/src/chatgpt_client.rs b/codex-rs/chatgpt/src/chatgpt_client.rs index 4c4cb4c4c3..907783bb81 100644 --- a/codex-rs/chatgpt/src/chatgpt_client.rs +++ b/codex-rs/chatgpt/src/chatgpt_client.rs @@ -21,10 +21,14 @@ pub(crate) async fn chatgpt_get_request( let token = get_chatgpt_token_data().ok_or_else(|| anyhow::anyhow!("ChatGPT token not available"))?; + let account_id = token.account_id.ok_or_else(|| { + anyhow::anyhow!("ChatGPT account ID not available, please re-run `codex login`") + }); + let response = client .get(&url) .bearer_auth(&token.access_token) - .header("chatgpt-account-id", &token.account_id) + .header("chatgpt-account-id", account_id?) .header("Content-Type", "application/json") .header("User-Agent", "codex-cli") .send() diff --git a/codex-rs/chatgpt/src/chatgpt_token.rs b/codex-rs/chatgpt/src/chatgpt_token.rs index adf9a6ba96..55ebc22a08 100644 --- a/codex-rs/chatgpt/src/chatgpt_token.rs +++ b/codex-rs/chatgpt/src/chatgpt_token.rs @@ -18,7 +18,10 @@ pub fn set_chatgpt_token_data(value: TokenData) { /// Initialize the ChatGPT token from auth.json file pub async fn init_chatgpt_token_from_auth(codex_home: &Path) -> std::io::Result<()> { - let auth_json = codex_login::try_read_auth_json(codex_home).await?; - set_chatgpt_token_data(auth_json.tokens.clone()); + let auth = codex_login::load_auth(codex_home)?; + if let Some(auth) = auth { + let token_data = auth.get_token_data().await?; + set_chatgpt_token_data(token_data); + } Ok(()) } diff --git a/codex-rs/cli/src/proto.rs b/codex-rs/cli/src/proto.rs index 64b292d50b..291e1680f1 100644 --- a/codex-rs/cli/src/proto.rs +++ b/codex-rs/cli/src/proto.rs @@ -9,6 +9,7 @@ use codex_core::config::Config; use codex_core::config::ConfigOverrides; use codex_core::protocol::Submission; use codex_core::util::notify_on_sigint; +use codex_login::load_auth; use tokio::io::AsyncBufReadExt; use tokio::io::BufReader; use tracing::error; @@ -35,8 +36,9 @@ pub async fn run_main(opts: ProtoCli) -> anyhow::Result<()> { .map_err(anyhow::Error::msg)?; let config = Config::load_with_cli_overrides(overrides_vec, ConfigOverrides::default())?; + let auth = load_auth(&config.codex_home)?; let ctrl_c = notify_on_sigint(); - let CodexSpawnOk { codex, .. } = Codex::spawn(config, ctrl_c.clone()).await?; + let CodexSpawnOk { codex, .. } = Codex::spawn(config, auth, ctrl_c.clone()).await?; let codex = Arc::new(codex); // Task that reads JSON lines from stdin and forwards to Submission Queue diff --git a/codex-rs/config.md b/codex-rs/config.md index c45d81180d..1a407a239b 100644 --- a/codex-rs/config.md +++ b/codex-rs/config.md @@ -110,12 +110,15 @@ stream_idle_timeout_ms = 300000 # 5m idle timeout ``` #### request_max_retries + How many times Codex will retry a failed HTTP request to the model provider. Defaults to `4`. #### stream_max_retries + Number of times Codex will attempt to reconnect when a streaming response is interrupted. Defaults to `10`. #### stream_idle_timeout_ms + How long Codex will wait for activity on a streaming response before treating the connection as lost. Defaults to `300_000` (5 minutes). ## model_provider diff --git a/codex-rs/core/Cargo.toml b/codex-rs/core/Cargo.toml index 2e0489c9b5..5ebb5ef63d 100644 --- a/codex-rs/core/Cargo.toml +++ b/codex-rs/core/Cargo.toml @@ -17,6 +17,8 @@ base64 = "0.22" bytes = "1.10.1" codex-apply-patch = { path = "../apply-patch" } codex-mcp-client = { path = "../mcp-client" } +chrono = { version = "0.4", features = ["serde"] } +codex-login = { path = "../login" } dirs = "6" env-flags = "0.1.1" eventsource-stream = "0.2.3" diff --git a/codex-rs/core/src/client.rs b/codex-rs/core/src/client.rs index aa31b67ecb..72104da254 100644 --- a/codex-rs/core/src/client.rs +++ b/codex-rs/core/src/client.rs @@ -3,6 +3,8 @@ use std::path::Path; use std::time::Duration; use bytes::Bytes; +use codex_login::AuthMode; +use codex_login::CodexAuth; use eventsource_stream::Eventsource; use futures::prelude::*; use reqwest::StatusCode; @@ -28,6 +30,7 @@ use crate::config::Config; use crate::config_types::ReasoningEffort as ReasoningEffortConfig; use crate::config_types::ReasoningSummary as ReasoningSummaryConfig; use crate::error::CodexErr; +use crate::error::EnvVarError; use crate::error::Result; use crate::flags::CODEX_RS_SSE_FIXTURE; use crate::model_provider_info::ModelProviderInfo; @@ -41,6 +44,7 @@ use std::sync::Arc; #[derive(Clone)] pub struct ModelClient { config: Arc, + auth: Option, client: reqwest::Client, provider: ModelProviderInfo, session_id: Uuid, @@ -51,6 +55,7 @@ pub struct ModelClient { impl ModelClient { pub fn new( config: Arc, + auth: Option, provider: ModelProviderInfo, effort: ReasoningEffortConfig, summary: ReasoningSummaryConfig, @@ -58,6 +63,7 @@ impl ModelClient { ) -> Self { Self { config, + auth, client: reqwest::Client::new(), provider, session_id, @@ -115,6 +121,25 @@ impl ModelClient { return stream_from_fixture(path, self.provider.clone()).await; } + let auth = self.auth.as_ref().ok_or_else(|| { + CodexErr::EnvVar(EnvVarError { + var: "OPENAI_API_KEY".to_string(), + instructions: Some("Create an API key (https://platform.openai.com) and export it as an environment variable.".to_string()), + }) + })?; + + let store = prompt.store && auth.mode != AuthMode::ChatGPT; + + let base_url = match self.provider.base_url.clone() { + Some(url) => url, + None => match auth.mode { + AuthMode::ChatGPT => "https://chatgpt.com/backend-api/codex".to_string(), + AuthMode::ApiKey => "https://api.openai.com/v1".to_string(), + }, + }; + + let token = auth.get_token().await?; + let full_instructions = prompt.get_full_instructions(&self.config.model); let tools_json = create_tools_json_for_responses_api( prompt, @@ -125,7 +150,7 @@ impl ModelClient { // Request encrypted COT if we are not storing responses, // otherwise reasoning items will be referenced by ID - let include = if !prompt.store && reasoning.is_some() { + let include: Vec = if !store && reasoning.is_some() { vec!["reasoning.encrypted_content".to_string()] } else { vec![] @@ -139,8 +164,7 @@ impl ModelClient { tool_choice: "auto", parallel_tool_calls: false, reasoning, - store: prompt.store, - // TODO: make this configurable + store, stream: true, include, }; @@ -153,17 +177,21 @@ impl ModelClient { let mut attempt = 0; let max_retries = self.provider.request_max_retries(); + loop { attempt += 1; let req_builder = self - .provider - .create_request_builder(&self.client)? + .client + .post(format!("{base_url}/responses")) .header("OpenAI-Beta", "responses=experimental") .header("session_id", self.session_id.to_string()) + .bearer_auth(&token) .header(reqwest::header::ACCEPT, "text/event-stream") .json(&payload); + let req_builder = self.provider.apply_http_headers(req_builder); + let res = req_builder.send().await; if let Ok(resp) = &res { trace!( @@ -572,7 +600,7 @@ mod tests { let provider = ModelProviderInfo { name: "test".to_string(), - base_url: "https://test.com".to_string(), + base_url: Some("https://test.com".to_string()), env_key: Some("TEST_API_KEY".to_string()), env_key_instructions: None, wire_api: WireApi::Responses, @@ -582,6 +610,7 @@ mod tests { request_max_retries: Some(0), stream_max_retries: Some(0), stream_idle_timeout_ms: Some(1000), + requires_auth: false, }; let events = collect_events( @@ -631,7 +660,7 @@ mod tests { let sse1 = format!("event: response.output_item.done\ndata: {item1}\n\n"); let provider = ModelProviderInfo { name: "test".to_string(), - base_url: "https://test.com".to_string(), + base_url: Some("https://test.com".to_string()), env_key: Some("TEST_API_KEY".to_string()), env_key_instructions: None, wire_api: WireApi::Responses, @@ -641,6 +670,7 @@ mod tests { request_max_retries: Some(0), stream_max_retries: Some(0), stream_idle_timeout_ms: Some(1000), + requires_auth: false, }; let events = collect_events(&[sse1.as_bytes()], provider).await; @@ -733,7 +763,7 @@ mod tests { let provider = ModelProviderInfo { name: "test".to_string(), - base_url: "https://test.com".to_string(), + base_url: Some("https://test.com".to_string()), env_key: Some("TEST_API_KEY".to_string()), env_key_instructions: None, wire_api: WireApi::Responses, @@ -743,6 +773,7 @@ mod tests { request_max_retries: Some(0), stream_max_retries: Some(0), stream_idle_timeout_ms: Some(1000), + requires_auth: false, }; let out = run_sse(evs, provider).await; diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 6efc878fb5..92ca7bf88b 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -15,6 +15,7 @@ use async_channel::Sender; use codex_apply_patch::ApplyPatchAction; use codex_apply_patch::MaybeApplyPatchVerified; use codex_apply_patch::maybe_parse_apply_patch_verified; +use codex_login::CodexAuth; use futures::prelude::*; use mcp_types::CallToolResult; use serde::Serialize; @@ -103,7 +104,11 @@ pub struct CodexSpawnOk { impl Codex { /// Spawn a new [`Codex`] and initialize the session. - pub async fn spawn(config: Config, ctrl_c: Arc) -> CodexResult { + pub async fn spawn( + config: Config, + auth: Option, + ctrl_c: Arc, + ) -> CodexResult { // experimental resume path (undocumented) let resume_path = config.experimental_resume.clone(); info!("resume_path: {resume_path:?}"); @@ -132,7 +137,7 @@ impl Codex { // Generate a unique ID for the lifetime of this Codex session. let session_id = Uuid::new_v4(); tokio::spawn(submission_loop( - session_id, config, rx_sub, tx_event, ctrl_c, + session_id, config, auth, rx_sub, tx_event, ctrl_c, )); let codex = Codex { next_id: AtomicU64::new(0), @@ -525,6 +530,7 @@ impl AgentTask { async fn submission_loop( mut session_id: Uuid, config: Arc, + auth: Option, rx_sub: Receiver, tx_event: Sender, ctrl_c: Arc, @@ -636,6 +642,7 @@ async fn submission_loop( let client = ModelClient::new( config.clone(), + auth.clone(), provider.clone(), model_reasoning_effort, model_reasoning_summary, diff --git a/codex-rs/core/src/codex_wrapper.rs b/codex-rs/core/src/codex_wrapper.rs index b80579297a..1e26a9ebed 100644 --- a/codex-rs/core/src/codex_wrapper.rs +++ b/codex-rs/core/src/codex_wrapper.rs @@ -6,6 +6,7 @@ use crate::config::Config; use crate::protocol::Event; use crate::protocol::EventMsg; use crate::util::notify_on_sigint; +use codex_login::load_auth; use tokio::sync::Notify; use uuid::Uuid; @@ -25,11 +26,12 @@ pub struct CodexConversation { /// that callers can surface the information to the UI. pub async fn init_codex(config: Config) -> anyhow::Result { let ctrl_c = notify_on_sigint(); + let auth = load_auth(&config.codex_home)?; let CodexSpawnOk { codex, init_id, session_id, - } = Codex::spawn(config, ctrl_c.clone()).await?; + } = Codex::spawn(config, auth, ctrl_c.clone()).await?; // The first event must be `SessionInitialized`. Validate and forward it to // the caller so that they can display it in the conversation history. diff --git a/codex-rs/core/src/config.rs b/codex-rs/core/src/config.rs index 53ca8d5ba9..a65ec09674 100644 --- a/codex-rs/core/src/config.rs +++ b/codex-rs/core/src/config.rs @@ -526,6 +526,7 @@ impl Config { .chatgpt_base_url .or(cfg.chatgpt_base_url) .unwrap_or("https://chatgpt.com/backend-api/".to_string()), + experimental_resume, include_plan_tool: include_plan_tool.unwrap_or(false), }; @@ -794,7 +795,7 @@ disable_response_storage = true let openai_chat_completions_provider = ModelProviderInfo { name: "OpenAI using Chat Completions".to_string(), - base_url: "https://api.openai.com/v1".to_string(), + base_url: Some("https://api.openai.com/v1".to_string()), env_key: Some("OPENAI_API_KEY".to_string()), wire_api: crate::WireApi::Chat, env_key_instructions: None, @@ -804,6 +805,7 @@ disable_response_storage = true request_max_retries: Some(4), stream_max_retries: Some(10), stream_idle_timeout_ms: Some(300_000), + requires_auth: false, }; let model_provider_map = { let mut model_provider_map = built_in_model_providers(); diff --git a/codex-rs/core/src/lib.rs b/codex-rs/core/src/lib.rs index b2dbded5f1..ffe64d7c90 100644 --- a/codex-rs/core/src/lib.rs +++ b/codex-rs/core/src/lib.rs @@ -30,8 +30,8 @@ mod message_history; mod model_provider_info; pub use model_provider_info::ModelProviderInfo; pub use model_provider_info::WireApi; +pub use model_provider_info::built_in_model_providers; mod models; -pub mod openai_api_key; mod openai_model_info; mod openai_tools; pub mod plan_tool; diff --git a/codex-rs/core/src/model_provider_info.rs b/codex-rs/core/src/model_provider_info.rs index 72ef58c60a..4640f53ad7 100644 --- a/codex-rs/core/src/model_provider_info.rs +++ b/codex-rs/core/src/model_provider_info.rs @@ -12,7 +12,6 @@ use std::env::VarError; use std::time::Duration; use crate::error::EnvVarError; -use crate::openai_api_key::get_openai_api_key; /// Value for the `OpenAI-Originator` header that is sent with requests to /// OpenAI. @@ -30,7 +29,7 @@ const DEFAULT_REQUEST_MAX_RETRIES: u64 = 4; #[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] pub enum WireApi { - /// The experimental "Responses" API exposed by OpenAI at `/v1/responses`. + /// The Responses API exposed by OpenAI at `/v1/responses`. Responses, /// Regular Chat Completions compatible with `/v1/chat/completions`. @@ -44,7 +43,7 @@ pub struct ModelProviderInfo { /// Friendly display name. pub name: String, /// Base URL for the provider's OpenAI-compatible API. - pub base_url: String, + pub base_url: Option, /// Environment variable that stores the user's API key for this provider. pub env_key: Option, @@ -78,6 +77,10 @@ pub struct ModelProviderInfo { /// Idle timeout (in milliseconds) to wait for activity on a streaming response before treating /// the connection as lost. pub stream_idle_timeout_ms: Option, + + /// Whether this provider requires some form of standard authentication (API key, ChatGPT token). + #[serde(default)] + pub requires_auth: bool, } impl ModelProviderInfo { @@ -93,11 +96,11 @@ impl ModelProviderInfo { &'a self, client: &'a reqwest::Client, ) -> crate::error::Result { - let api_key = self.api_key()?; - let url = self.get_full_url(); let mut builder = client.post(url); + + let api_key = self.api_key()?; if let Some(key) = api_key { builder = builder.bearer_auth(key); } @@ -117,9 +120,15 @@ impl ModelProviderInfo { .join("&"); format!("?{full_params}") }); - let base_url = &self.base_url; + let base_url = self + .base_url + .clone() + .unwrap_or("https://api.openai.com/v1".to_string()); + match self.wire_api { - WireApi::Responses => format!("{base_url}/responses{query_string}"), + WireApi::Responses => { + format!("{base_url}/responses{query_string}") + } WireApi::Chat => format!("{base_url}/chat/completions{query_string}"), } } @@ -127,7 +136,10 @@ impl ModelProviderInfo { /// Apply provider-specific HTTP headers (both static and environment-based) /// onto an existing `reqwest::RequestBuilder` and return the updated /// builder. - fn apply_http_headers(&self, mut builder: reqwest::RequestBuilder) -> reqwest::RequestBuilder { + pub fn apply_http_headers( + &self, + mut builder: reqwest::RequestBuilder, + ) -> reqwest::RequestBuilder { if let Some(extra) = &self.http_headers { for (k, v) in extra { builder = builder.header(k, v); @@ -152,11 +164,7 @@ impl ModelProviderInfo { fn api_key(&self) -> crate::error::Result> { match &self.env_key { Some(env_key) => { - let env_value = if env_key == crate::openai_api_key::OPENAI_API_KEY_ENV_VAR { - get_openai_api_key().map_or_else(|| Err(VarError::NotPresent), Ok) - } else { - std::env::var(env_key) - }; + let env_value = std::env::var(env_key); env_value .and_then(|v| { if v.trim().is_empty() { @@ -204,47 +212,51 @@ pub fn built_in_model_providers() -> HashMap { // providers are bundled with Codex CLI, so we only include the OpenAI // provider by default. Users are encouraged to add to `model_providers` // in config.toml to add their own providers. - [ - ( - "openai", - P { - name: "OpenAI".into(), - // Allow users to override the default OpenAI endpoint by - // exporting `OPENAI_BASE_URL`. This is useful when pointing - // Codex at a proxy, mock server, or Azure-style deployment - // without requiring a full TOML override for the built-in - // OpenAI provider. - base_url: std::env::var("OPENAI_BASE_URL") - .ok() - .filter(|v| !v.trim().is_empty()) - .unwrap_or_else(|| "https://api.openai.com/v1".to_string()), - env_key: Some("OPENAI_API_KEY".into()), - env_key_instructions: Some("Create an API key (https://platform.openai.com) and export it as an environment variable.".into()), - wire_api: WireApi::Responses, - query_params: None, - http_headers: Some( - [ - ("originator".to_string(), OPENAI_ORIGINATOR_HEADER.to_string()), - ("version".to_string(), env!("CARGO_PKG_VERSION").to_string()), - ] - .into_iter() - .collect(), - ), - env_http_headers: Some( - [ - ("OpenAI-Organization".to_string(), "OPENAI_ORGANIZATION".to_string()), - ("OpenAI-Project".to_string(), "OPENAI_PROJECT".to_string()), - ] - .into_iter() - .collect(), - ), - // Use global defaults for retry/timeout unless overridden in config.toml. - request_max_retries: None, - stream_max_retries: None, - stream_idle_timeout_ms: None, - }, - ), - ] + [( + "openai", + P { + name: "OpenAI".into(), + // Allow users to override the default OpenAI endpoint by + // exporting `OPENAI_BASE_URL`. This is useful when pointing + // Codex at a proxy, mock server, or Azure-style deployment + // without requiring a full TOML override for the built-in + // OpenAI provider. + base_url: std::env::var("OPENAI_BASE_URL") + .ok() + .filter(|v| !v.trim().is_empty()), + env_key: None, + env_key_instructions: None, + wire_api: WireApi::Responses, + query_params: None, + http_headers: Some( + [ + ( + "originator".to_string(), + OPENAI_ORIGINATOR_HEADER.to_string(), + ), + ("version".to_string(), env!("CARGO_PKG_VERSION").to_string()), + ] + .into_iter() + .collect(), + ), + env_http_headers: Some( + [ + ( + "OpenAI-Organization".to_string(), + "OPENAI_ORGANIZATION".to_string(), + ), + ("OpenAI-Project".to_string(), "OPENAI_PROJECT".to_string()), + ] + .into_iter() + .collect(), + ), + // Use global defaults for retry/timeout unless overridden in config.toml. + request_max_retries: None, + stream_max_retries: None, + stream_idle_timeout_ms: None, + requires_auth: true, + }, + )] .into_iter() .map(|(k, v)| (k.to_string(), v)) .collect() @@ -264,7 +276,7 @@ base_url = "http://localhost:11434/v1" "#; let expected_provider = ModelProviderInfo { name: "Ollama".into(), - base_url: "http://localhost:11434/v1".into(), + base_url: Some("http://localhost:11434/v1".into()), env_key: None, env_key_instructions: None, wire_api: WireApi::Chat, @@ -274,6 +286,7 @@ base_url = "http://localhost:11434/v1" request_max_retries: None, stream_max_retries: None, stream_idle_timeout_ms: None, + requires_auth: false, }; let provider: ModelProviderInfo = toml::from_str(azure_provider_toml).unwrap(); @@ -290,7 +303,7 @@ query_params = { api-version = "2025-04-01-preview" } "#; let expected_provider = ModelProviderInfo { name: "Azure".into(), - base_url: "https://xxxxx.openai.azure.com/openai".into(), + base_url: Some("https://xxxxx.openai.azure.com/openai".into()), env_key: Some("AZURE_OPENAI_API_KEY".into()), env_key_instructions: None, wire_api: WireApi::Chat, @@ -302,6 +315,7 @@ query_params = { api-version = "2025-04-01-preview" } request_max_retries: None, stream_max_retries: None, stream_idle_timeout_ms: None, + requires_auth: false, }; let provider: ModelProviderInfo = toml::from_str(azure_provider_toml).unwrap(); @@ -319,7 +333,7 @@ env_http_headers = { "X-Example-Env-Header" = "EXAMPLE_ENV_VAR" } "#; let expected_provider = ModelProviderInfo { name: "Example".into(), - base_url: "https://example.com".into(), + base_url: Some("https://example.com".into()), env_key: Some("API_KEY".into()), env_key_instructions: None, wire_api: WireApi::Chat, @@ -333,6 +347,7 @@ env_http_headers = { "X-Example-Env-Header" = "EXAMPLE_ENV_VAR" } request_max_retries: None, stream_max_retries: None, stream_idle_timeout_ms: None, + requires_auth: false, }; let provider: ModelProviderInfo = toml::from_str(azure_provider_toml).unwrap(); diff --git a/codex-rs/core/src/openai_api_key.rs b/codex-rs/core/src/openai_api_key.rs deleted file mode 100644 index 728914c0f2..0000000000 --- a/codex-rs/core/src/openai_api_key.rs +++ /dev/null @@ -1,24 +0,0 @@ -use std::env; -use std::sync::LazyLock; -use std::sync::RwLock; - -pub const OPENAI_API_KEY_ENV_VAR: &str = "OPENAI_API_KEY"; - -static OPENAI_API_KEY: LazyLock>> = LazyLock::new(|| { - let val = env::var(OPENAI_API_KEY_ENV_VAR) - .ok() - .and_then(|s| if s.is_empty() { None } else { Some(s) }); - RwLock::new(val) -}); - -pub fn get_openai_api_key() -> Option { - #![allow(clippy::unwrap_used)] - OPENAI_API_KEY.read().unwrap().clone() -} - -pub fn set_openai_api_key(value: String) { - #![allow(clippy::unwrap_used)] - if !value.is_empty() { - *OPENAI_API_KEY.write().unwrap() = Some(value); - } -} diff --git a/codex-rs/core/tests/client.rs b/codex-rs/core/tests/client.rs index 9de2d56036..fbe63fb326 100644 --- a/codex-rs/core/tests/client.rs +++ b/codex-rs/core/tests/client.rs @@ -1,11 +1,19 @@ +use std::path::PathBuf; + +use chrono::Utc; use codex_core::Codex; use codex_core::CodexSpawnOk; use codex_core::ModelProviderInfo; +use codex_core::built_in_model_providers; use codex_core::exec::CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR; use codex_core::protocol::EventMsg; use codex_core::protocol::InputItem; use codex_core::protocol::Op; use codex_core::protocol::SessionConfiguredEvent; +use codex_login::AuthDotJson; +use codex_login::AuthMode; +use codex_login::CodexAuth; +use codex_login::TokenData; use core_test_support::load_default_config_for_test; use core_test_support::load_sse_fixture_with_id; use core_test_support::wait_for_event; @@ -48,32 +56,23 @@ async fn includes_session_id_and_model_headers_in_request() { .await; let model_provider = ModelProviderInfo { - name: "openai".into(), - base_url: format!("{}/v1", server.uri()), - // Environment variable that should exist in the test environment. - // ModelClient will return an error if the environment variable for the - // provider is not set. - env_key: Some("PATH".into()), - env_key_instructions: None, - wire_api: codex_core::WireApi::Responses, - query_params: None, - http_headers: Some( - [("originator".to_string(), "codex_cli_rs".to_string())] - .into_iter() - .collect(), - ), - env_http_headers: None, - request_max_retries: Some(0), - stream_max_retries: Some(0), - stream_idle_timeout_ms: None, + base_url: Some(format!("{}/v1", server.uri())), + ..built_in_model_providers()["openai"].clone() }; // Init session let codex_home = TempDir::new().unwrap(); let mut config = load_default_config_for_test(&codex_home); config.model_provider = model_provider; + let ctrl_c = std::sync::Arc::new(tokio::sync::Notify::new()); - let CodexSpawnOk { codex, .. } = Codex::spawn(config, ctrl_c.clone()).await.unwrap(); + let CodexSpawnOk { codex, .. } = Codex::spawn( + config, + Some(CodexAuth::from_api_key("Test API Key".to_string())), + ctrl_c.clone(), + ) + .await + .unwrap(); codex .submit(Op::UserInput { @@ -95,15 +94,20 @@ async fn includes_session_id_and_model_headers_in_request() { // get request from the server let request = &server.received_requests().await.unwrap()[0]; - let request_body = request.headers.get("session_id").unwrap(); - let originator = request.headers.get("originator").unwrap(); + let request_session_id = request.headers.get("session_id").unwrap(); + let request_originator = request.headers.get("originator").unwrap(); + let request_authorization = request.headers.get("authorization").unwrap(); assert!(current_session_id.is_some()); assert_eq!( - request_body.to_str().unwrap(), + request_session_id.to_str().unwrap(), current_session_id.as_ref().unwrap() ); - assert_eq!(originator.to_str().unwrap(), "codex_cli_rs"); + assert_eq!(request_originator.to_str().unwrap(), "codex_cli_rs"); + assert_eq!( + request_authorization.to_str().unwrap(), + "Bearer Test API Key" + ); } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] @@ -126,22 +130,9 @@ async fn includes_base_instructions_override_in_request() { .await; let model_provider = ModelProviderInfo { - name: "openai".into(), - base_url: format!("{}/v1", server.uri()), - // Environment variable that should exist in the test environment. - // ModelClient will return an error if the environment variable for the - // provider is not set. - env_key: Some("PATH".into()), - env_key_instructions: None, - wire_api: codex_core::WireApi::Responses, - query_params: None, - http_headers: None, - env_http_headers: None, - request_max_retries: Some(0), - stream_max_retries: Some(0), - stream_idle_timeout_ms: None, + base_url: Some(format!("{}/v1", server.uri())), + ..built_in_model_providers()["openai"].clone() }; - let codex_home = TempDir::new().unwrap(); let mut config = load_default_config_for_test(&codex_home); @@ -149,7 +140,13 @@ async fn includes_base_instructions_override_in_request() { config.model_provider = model_provider; let ctrl_c = std::sync::Arc::new(tokio::sync::Notify::new()); - let CodexSpawnOk { codex, .. } = Codex::spawn(config, ctrl_c.clone()).await.unwrap(); + let CodexSpawnOk { codex, .. } = Codex::spawn( + config, + Some(CodexAuth::from_api_key("Test API Key".to_string())), + ctrl_c.clone(), + ) + .await + .unwrap(); codex .submit(Op::UserInput { @@ -172,3 +169,108 @@ async fn includes_base_instructions_override_in_request() { .contains("test instructions") ); } + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn chatgpt_auth_sends_correct_request() { + #![allow(clippy::unwrap_used)] + + if std::env::var(CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR).is_ok() { + println!( + "Skipping test because it cannot execute when network is disabled in a Codex sandbox." + ); + return; + } + + // Mock server + let server = MockServer::start().await; + + // First request – must NOT include `previous_response_id`. + let first = ResponseTemplate::new(200) + .insert_header("content-type", "text/event-stream") + .set_body_raw(sse_completed("resp1"), "text/event-stream"); + + Mock::given(method("POST")) + .and(path("/api/codex/responses")) + .respond_with(first) + .expect(1) + .mount(&server) + .await; + + let model_provider = ModelProviderInfo { + base_url: Some(format!("{}/api/codex", server.uri())), + ..built_in_model_providers()["openai"].clone() + }; + + // Init session + let codex_home = TempDir::new().unwrap(); + let mut config = load_default_config_for_test(&codex_home); + config.model_provider = model_provider; + let ctrl_c = std::sync::Arc::new(tokio::sync::Notify::new()); + let CodexSpawnOk { codex, .. } = Codex::spawn( + config, + Some(auth_from_token("Access Token".to_string())), + ctrl_c.clone(), + ) + .await + .unwrap(); + + codex + .submit(Op::UserInput { + items: vec![InputItem::Text { + text: "hello".into(), + }], + }) + .await + .unwrap(); + + let EventMsg::SessionConfigured(SessionConfiguredEvent { session_id, .. }) = + wait_for_event(&codex, |ev| matches!(ev, EventMsg::SessionConfigured(_))).await + else { + unreachable!() + }; + + let current_session_id = Some(session_id.to_string()); + wait_for_event(&codex, |ev| matches!(ev, EventMsg::TaskComplete(_))).await; + + // get request from the server + let request = &server.received_requests().await.unwrap()[0]; + let request_session_id = request.headers.get("session_id").unwrap(); + let request_originator = request.headers.get("originator").unwrap(); + let request_authorization = request.headers.get("authorization").unwrap(); + let request_body = request.body_json::().unwrap(); + + assert!(current_session_id.is_some()); + assert_eq!( + request_session_id.to_str().unwrap(), + current_session_id.as_ref().unwrap() + ); + assert_eq!(request_originator.to_str().unwrap(), "codex_cli_rs"); + assert_eq!( + request_authorization.to_str().unwrap(), + "Bearer Access Token" + ); + assert!(!request_body["store"].as_bool().unwrap()); + assert!(request_body["stream"].as_bool().unwrap()); + assert_eq!( + request_body["include"][0].as_str().unwrap(), + "reasoning.encrypted_content" + ); +} + +fn auth_from_token(id_token: String) -> CodexAuth { + CodexAuth::new( + None, + AuthMode::ChatGPT, + PathBuf::new(), + Some(AuthDotJson { + tokens: TokenData { + id_token, + access_token: "Access Token".to_string(), + refresh_token: "test".to_string(), + account_id: None, + }, + last_refresh: Utc::now(), + openai_api_key: None, + }), + ) +} diff --git a/codex-rs/core/tests/live_agent.rs b/codex-rs/core/tests/live_agent.rs index 9895343045..95408e20e5 100644 --- a/codex-rs/core/tests/live_agent.rs +++ b/codex-rs/core/tests/live_agent.rs @@ -50,7 +50,7 @@ async fn spawn_codex() -> Result { config.model_provider.request_max_retries = Some(2); config.model_provider.stream_max_retries = Some(2); let CodexSpawnOk { codex: agent, .. } = - Codex::spawn(config, std::sync::Arc::new(Notify::new())).await?; + Codex::spawn(config, None, std::sync::Arc::new(Notify::new())).await?; Ok(agent) } diff --git a/codex-rs/core/tests/stream_no_completed.rs b/codex-rs/core/tests/stream_no_completed.rs index 8e5d83a03e..d2fc035569 100644 --- a/codex-rs/core/tests/stream_no_completed.rs +++ b/codex-rs/core/tests/stream_no_completed.rs @@ -10,6 +10,7 @@ use codex_core::exec::CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR; use codex_core::protocol::EventMsg; use codex_core::protocol::InputItem; use codex_core::protocol::Op; +use codex_login::CodexAuth; use core_test_support::load_default_config_for_test; use core_test_support::load_sse_fixture; use core_test_support::load_sse_fixture_with_id; @@ -75,7 +76,7 @@ async fn retries_on_early_close() { let model_provider = ModelProviderInfo { name: "openai".into(), - base_url: format!("{}/v1", server.uri()), + base_url: Some(format!("{}/v1", server.uri())), // Environment variable that should exist in the test environment. // ModelClient will return an error if the environment variable for the // provider is not set. @@ -89,13 +90,20 @@ async fn retries_on_early_close() { request_max_retries: Some(0), stream_max_retries: Some(1), stream_idle_timeout_ms: Some(2000), + requires_auth: false, }; let ctrl_c = std::sync::Arc::new(tokio::sync::Notify::new()); let codex_home = TempDir::new().unwrap(); let mut config = load_default_config_for_test(&codex_home); config.model_provider = model_provider; - let CodexSpawnOk { codex, .. } = Codex::spawn(config, ctrl_c).await.unwrap(); + let CodexSpawnOk { codex, .. } = Codex::spawn( + config, + Some(CodexAuth::from_api_key("Test API Key".to_string())), + ctrl_c, + ) + .await + .unwrap(); codex .submit(Op::UserInput { diff --git a/codex-rs/login/src/lib.rs b/codex-rs/login/src/lib.rs index ab92ecf616..47dbbca9fb 100644 --- a/codex-rs/login/src/lib.rs +++ b/codex-rs/login/src/lib.rs @@ -1,20 +1,152 @@ use chrono::DateTime; + use chrono::Utc; use serde::Deserialize; use serde::Serialize; +use std::env; use std::fs::OpenOptions; use std::io::Read; use std::io::Write; #[cfg(unix)] use std::os::unix::fs::OpenOptionsExt; use std::path::Path; +use std::path::PathBuf; use std::process::Stdio; +use std::sync::Arc; +use std::sync::Mutex; use std::time::Duration; use tokio::process::Command; const SOURCE_FOR_PYTHON_SERVER: &str = include_str!("./login_with_chatgpt.py"); const CLIENT_ID: &str = "app_EMoamEEZ73f0CkXaXp7hrann"; +const OPENAI_API_KEY_ENV_VAR: &str = "OPENAI_API_KEY"; + +#[derive(Clone, Debug, PartialEq)] +pub enum AuthMode { + ApiKey, + ChatGPT, +} + +#[derive(Debug, Clone)] +pub struct CodexAuth { + pub api_key: Option, + pub mode: AuthMode, + auth_dot_json: Arc>>, + auth_file: PathBuf, +} + +impl PartialEq for CodexAuth { + fn eq(&self, other: &Self) -> bool { + self.mode == other.mode + } +} + +impl CodexAuth { + pub fn new( + api_key: Option, + mode: AuthMode, + auth_file: PathBuf, + auth_dot_json: Option, + ) -> Self { + let auth_dot_json = Arc::new(Mutex::new(auth_dot_json)); + Self { + api_key, + mode, + auth_file, + auth_dot_json, + } + } + + pub fn from_api_key(api_key: String) -> Self { + Self { + api_key: Some(api_key), + mode: AuthMode::ApiKey, + auth_file: PathBuf::new(), + auth_dot_json: Arc::new(Mutex::new(None)), + } + } + + pub async fn get_token_data(&self) -> Result { + #[expect(clippy::unwrap_used)] + let auth_dot_json = self.auth_dot_json.lock().unwrap().clone(); + + match auth_dot_json { + Some(auth_dot_json) => { + if auth_dot_json.last_refresh < Utc::now() - chrono::Duration::days(28) { + let refresh_response = tokio::time::timeout( + Duration::from_secs(60), + try_refresh_token(auth_dot_json.tokens.refresh_token.clone()), + ) + .await + .map_err(|_| { + std::io::Error::other("timed out while refreshing OpenAI API key") + })? + .map_err(std::io::Error::other)?; + + let updated_auth_dot_json = update_tokens( + &self.auth_file, + refresh_response.id_token, + refresh_response.access_token, + refresh_response.refresh_token, + ) + .await?; + + #[expect(clippy::unwrap_used)] + let mut auth_dot_json = self.auth_dot_json.lock().unwrap(); + *auth_dot_json = Some(updated_auth_dot_json); + } + Ok(auth_dot_json.tokens.clone()) + } + None => Err(std::io::Error::other("Token data is not available.")), + } + } + + pub async fn get_token(&self) -> Result { + match self.mode { + AuthMode::ApiKey => Ok(self.api_key.clone().unwrap_or_default()), + AuthMode::ChatGPT => { + let id_token = self.get_token_data().await?.access_token; + + Ok(id_token) + } + } + } +} + +// Loads the available auth information from the auth.json or OPENAI_API_KEY environment variable. +pub fn load_auth(codex_home: &Path) -> std::io::Result> { + let auth_file = codex_home.join("auth.json"); + + let auth_dot_json = try_read_auth_json(&auth_file).ok(); + + let auth_json_api_key = auth_dot_json + .as_ref() + .and_then(|a| a.openai_api_key.clone()) + .filter(|s| !s.is_empty()); + + let openai_api_key = env::var(OPENAI_API_KEY_ENV_VAR) + .ok() + .filter(|s| !s.is_empty()) + .or(auth_json_api_key); + + if openai_api_key.is_none() && auth_dot_json.is_none() { + return Ok(None); + } + + let mode = if openai_api_key.is_some() { + AuthMode::ApiKey + } else { + AuthMode::ChatGPT + }; + + Ok(Some(CodexAuth { + api_key: openai_api_key, + mode, + auth_file, + auth_dot_json: Arc::new(Mutex::new(auth_dot_json)), + })) +} /// Run `python3 -c {{SOURCE_FOR_PYTHON_SERVER}}` with the CODEX_HOME /// environment variable set to the provided `codex_home` path. If the @@ -25,14 +157,12 @@ const CLIENT_ID: &str = "app_EMoamEEZ73f0CkXaXp7hrann"; /// If `capture_output` is true, the subprocess's output will be captured and /// recorded in memory. Otherwise, the subprocess's output will be sent to the /// current process's stdout/stderr. -pub async fn login_with_chatgpt( - codex_home: &Path, - capture_output: bool, -) -> std::io::Result { +pub async fn login_with_chatgpt(codex_home: &Path, capture_output: bool) -> std::io::Result<()> { let child = Command::new("python3") .arg("-c") .arg(SOURCE_FOR_PYTHON_SERVER) .env("CODEX_HOME", codex_home) + .env("CODEX_CLIENT_ID", CLIENT_ID) .stdin(Stdio::null()) .stdout(if capture_output { Stdio::piped() @@ -48,7 +178,7 @@ pub async fn login_with_chatgpt( let output = child.wait_with_output().await?; if output.status.success() { - try_read_openai_api_key(codex_home).await + Ok(()) } else { let stderr = String::from_utf8_lossy(&output.stderr); Err(std::io::Error::other(format!( @@ -57,65 +187,54 @@ pub async fn login_with_chatgpt( } } -/// Attempt to read the `OPENAI_API_KEY` from the `auth.json` file in the given -/// `CODEX_HOME` directory, refreshing it, if necessary. -pub async fn try_read_openai_api_key(codex_home: &Path) -> std::io::Result { - let auth_dot_json = try_read_auth_json(codex_home).await?; - Ok(auth_dot_json.openai_api_key) -} - /// Attempt to read and refresh the `auth.json` file in the given `CODEX_HOME` directory. /// Returns the full AuthDotJson structure after refreshing if necessary. -pub async fn try_read_auth_json(codex_home: &Path) -> std::io::Result { - let auth_path = codex_home.join("auth.json"); - let mut file = std::fs::File::open(&auth_path)?; +pub fn try_read_auth_json(auth_file: &Path) -> std::io::Result { + let mut file = std::fs::File::open(auth_file)?; let mut contents = String::new(); file.read_to_string(&mut contents)?; let auth_dot_json: AuthDotJson = serde_json::from_str(&contents)?; - if is_expired(&auth_dot_json) { - let refresh_response = - tokio::time::timeout(Duration::from_secs(60), try_refresh_token(&auth_dot_json)) - .await - .map_err(|_| std::io::Error::other("timed out while refreshing OpenAI API key"))? - .map_err(std::io::Error::other)?; - let mut auth_dot_json = auth_dot_json; - auth_dot_json.tokens.id_token = refresh_response.id_token; - if let Some(refresh_token) = refresh_response.refresh_token { - auth_dot_json.tokens.refresh_token = refresh_token; - } - auth_dot_json.last_refresh = Utc::now(); + Ok(auth_dot_json) +} - let mut options = OpenOptions::new(); - options.truncate(true).write(true).create(true); - #[cfg(unix)] - { - options.mode(0o600); - } - - let json_data = serde_json::to_string(&auth_dot_json)?; - { - let mut file = options.open(&auth_path)?; - file.write_all(json_data.as_bytes())?; - file.flush()?; - } - - Ok(auth_dot_json) - } else { - Ok(auth_dot_json) +async fn update_tokens( + auth_file: &Path, + id_token: String, + access_token: Option, + refresh_token: Option, +) -> std::io::Result { + let mut options = OpenOptions::new(); + options.truncate(true).write(true).create(true); + #[cfg(unix)] + { + options.mode(0o600); } + let mut auth_dot_json = try_read_auth_json(auth_file)?; + + auth_dot_json.tokens.id_token = id_token.to_string(); + if let Some(access_token) = access_token { + auth_dot_json.tokens.access_token = access_token.to_string(); + } + if let Some(refresh_token) = refresh_token { + auth_dot_json.tokens.refresh_token = refresh_token.to_string(); + } + auth_dot_json.last_refresh = Utc::now(); + + let json_data = serde_json::to_string_pretty(&auth_dot_json)?; + { + let mut file = options.open(auth_file)?; + file.write_all(json_data.as_bytes())?; + file.flush()?; + } + Ok(auth_dot_json) } -fn is_expired(auth_dot_json: &AuthDotJson) -> bool { - let last_refresh = auth_dot_json.last_refresh; - last_refresh < Utc::now() - chrono::Duration::days(28) -} - -async fn try_refresh_token(auth_dot_json: &AuthDotJson) -> std::io::Result { +async fn try_refresh_token(refresh_token: String) -> std::io::Result { let refresh_request = RefreshRequest { client_id: CLIENT_ID, grant_type: "refresh_token", - refresh_token: auth_dot_json.tokens.refresh_token.clone(), + refresh_token, scope: "openid profile email", }; @@ -150,24 +269,25 @@ struct RefreshRequest { scope: &'static str, } -#[derive(Deserialize)] +#[derive(Deserialize, Clone)] struct RefreshResponse { id_token: String, + access_token: Option, refresh_token: Option, } /// Expected structure for $CODEX_HOME/auth.json. -#[derive(Deserialize, Serialize)] +#[derive(Deserialize, Serialize, Clone, Debug, PartialEq)] pub struct AuthDotJson { #[serde(rename = "OPENAI_API_KEY")] - pub openai_api_key: String, + pub openai_api_key: Option, pub tokens: TokenData, pub last_refresh: DateTime, } -#[derive(Deserialize, Serialize, Clone)] +#[derive(Deserialize, Serialize, Clone, Debug, PartialEq)] pub struct TokenData { /// This is a JWT. pub id_token: String, @@ -177,5 +297,5 @@ pub struct TokenData { pub refresh_token: String, - pub account_id: String, + pub account_id: Option, } diff --git a/codex-rs/login/src/login_with_chatgpt.py b/codex-rs/login/src/login_with_chatgpt.py index ccb051c0af..2dbf5be58a 100644 --- a/codex-rs/login/src/login_with_chatgpt.py +++ b/codex-rs/login/src/login_with_chatgpt.py @@ -41,7 +41,6 @@ from typing import Any, Dict # for type hints REQUIRED_PORT = 1455 URL_BASE = f"http://localhost:{REQUIRED_PORT}" DEFAULT_ISSUER = "https://auth.openai.com" -DEFAULT_CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann" EXIT_CODE_WHEN_ADDRESS_ALREADY_IN_USE = 13 @@ -58,7 +57,7 @@ class TokenData: class AuthBundle: """Aggregates authentication data produced after successful OAuth flow.""" - api_key: str + api_key: str | None token_data: TokenData last_refresh: str @@ -78,12 +77,18 @@ def main() -> None: eprint("ERROR: CODEX_HOME environment variable is not set") sys.exit(1) + client_id = os.getenv("CODEX_CLIENT_ID") + if not client_id: + eprint("ERROR: CODEX_CLIENT_ID environment variable is not set") + sys.exit(1) + # Spawn server. try: httpd = _ApiKeyHTTPServer( ("127.0.0.1", REQUIRED_PORT), _ApiKeyHTTPHandler, codex_home=codex_home, + client_id=client_id, verbose=args.verbose, ) except OSError as e: @@ -157,7 +162,7 @@ class _ApiKeyHTTPHandler(http.server.BaseHTTPRequestHandler): return try: - auth_bundle, success_url = self._exchange_code_for_api_key(code) + auth_bundle, success_url = self._exchange_code(code) except Exception as exc: # noqa: BLE001 – propagate to client self.send_error(500, f"Token exchange failed: {exc}") return @@ -211,68 +216,22 @@ class _ApiKeyHTTPHandler(http.server.BaseHTTPRequestHandler): if getattr(self.server, "verbose", False): # type: ignore[attr-defined] super().log_message(fmt, *args) - def _exchange_code_for_api_key(self, code: str) -> tuple[AuthBundle, str]: - """Perform token + token-exchange to obtain an OpenAI API key. + def _obtain_api_key( + self, + token_claims: Dict[str, Any], + access_claims: Dict[str, Any], + token_data: TokenData, + ) -> tuple[str | None, str | None]: + """Obtain an API key from the auth service. - Returns (AuthBundle, success_url). + Returns (api_key, success_url) if successful, None otherwise. """ - token_endpoint = f"{self.server.issuer}/oauth/token" - - # 1. Authorization-code -> (id_token, access_token, refresh_token) - data = urllib.parse.urlencode( - { - "grant_type": "authorization_code", - "code": code, - "redirect_uri": self.server.redirect_uri, - "client_id": self.server.client_id, - "code_verifier": self.server.pkce.code_verifier, - } - ).encode() - - token_data: TokenData - - with urllib.request.urlopen( - urllib.request.Request( - token_endpoint, - data=data, - method="POST", - headers={"Content-Type": "application/x-www-form-urlencoded"}, - ) - ) as resp: - payload = json.loads(resp.read().decode()) - - # Extract chatgpt_account_id from id_token - id_token_parts = payload["id_token"].split(".") - if len(id_token_parts) != 3: - raise ValueError("Invalid ID token") - id_token_claims = _decode_jwt_segment(id_token_parts[1]) - auth_claims = id_token_claims.get("https://api.openai.com/auth", {}) - chatgpt_account_id = auth_claims.get("chatgpt_account_id", "") - - token_data = TokenData( - id_token=payload["id_token"], - access_token=payload["access_token"], - refresh_token=payload["refresh_token"], - account_id=chatgpt_account_id, - ) - - access_token_parts = token_data.access_token.split(".") - if len(access_token_parts) != 3: - raise ValueError("Invalid access token") - - access_token_claims = _decode_jwt_segment(access_token_parts[1]) - - token_claims = id_token_claims.get("https://api.openai.com/auth", {}) - access_claims = access_token_claims.get("https://api.openai.com/auth", {}) - org_id = token_claims.get("organization_id") - if not org_id: - raise ValueError("Missing organization in id_token claims") - project_id = token_claims.get("project_id") - if not project_id: - raise ValueError("Missing project in id_token claims") + + if not org_id or not project_id: + return (None, None) random_id = secrets.token_hex(6) @@ -292,7 +251,7 @@ class _ApiKeyHTTPHandler(http.server.BaseHTTPRequestHandler): exchanged_access_token: str with urllib.request.urlopen( urllib.request.Request( - token_endpoint, + self.server.token_endpoint, data=exchange_data, method="POST", headers={"Content-Type": "application/x-www-form-urlencoded"}, @@ -340,6 +299,65 @@ class _ApiKeyHTTPHandler(http.server.BaseHTTPRequestHandler): except Exception as exc: # pragma: no cover – best-effort only eprint(f"Unable to redeem ChatGPT subscriber API credits: {exc}") + return (exchanged_access_token, success_url) + + def _exchange_code(self, code: str) -> tuple[AuthBundle, str]: + """Perform token + token-exchange to obtain an OpenAI API key. + + Returns (AuthBundle, success_url). + """ + + # 1. Authorization-code -> (id_token, access_token, refresh_token) + data = urllib.parse.urlencode( + { + "grant_type": "authorization_code", + "code": code, + "redirect_uri": self.server.redirect_uri, + "client_id": self.server.client_id, + "code_verifier": self.server.pkce.code_verifier, + } + ).encode() + + token_data: TokenData + + with urllib.request.urlopen( + urllib.request.Request( + self.server.token_endpoint, + data=data, + method="POST", + headers={"Content-Type": "application/x-www-form-urlencoded"}, + ) + ) as resp: + payload = json.loads(resp.read().decode()) + + # Extract chatgpt_account_id from id_token + id_token_parts = payload["id_token"].split(".") + if len(id_token_parts) != 3: + raise ValueError("Invalid ID token") + id_token_claims = _decode_jwt_segment(id_token_parts[1]) + auth_claims = id_token_claims.get("https://api.openai.com/auth", {}) + chatgpt_account_id = auth_claims.get("chatgpt_account_id", "") + + token_data = TokenData( + id_token=payload["id_token"], + access_token=payload["access_token"], + refresh_token=payload["refresh_token"], + account_id=chatgpt_account_id, + ) + + access_token_parts = token_data.access_token.split(".") + if len(access_token_parts) != 3: + raise ValueError("Invalid access token") + + access_token_claims = _decode_jwt_segment(access_token_parts[1]) + + token_claims = id_token_claims.get("https://api.openai.com/auth", {}) + access_claims = access_token_claims.get("https://api.openai.com/auth", {}) + + exchanged_access_token, success_url = self._obtain_api_key( + token_claims, access_claims, token_data + ) + # Persist refresh_token/id_token for future use (redeem credits etc.) last_refresh_str = ( datetime.datetime.now(datetime.timezone.utc) @@ -353,7 +371,7 @@ class _ApiKeyHTTPHandler(http.server.BaseHTTPRequestHandler): last_refresh=last_refresh_str, ) - return (auth_bundle, success_url) + return (auth_bundle, success_url or f"{URL_BASE}/success") def request_shutdown(self) -> None: # shutdown() must be invoked from another thread to avoid @@ -413,6 +431,7 @@ class _ApiKeyHTTPServer(http.server.HTTPServer): request_handler_class: type[http.server.BaseHTTPRequestHandler], *, codex_home: str, + client_id: str, verbose: bool = False, ) -> None: super().__init__(server_address, request_handler_class, bind_and_activate=True) @@ -422,7 +441,8 @@ class _ApiKeyHTTPServer(http.server.HTTPServer): self.verbose: bool = verbose self.issuer: str = DEFAULT_ISSUER - self.client_id: str = DEFAULT_CLIENT_ID + self.token_endpoint: str = f"{self.issuer}/oauth/token" + self.client_id: str = client_id port = server_address[1] self.redirect_uri: str = f"http://localhost:{port}/auth/callback" self.pkce: PkceCodes = _generate_pkce() @@ -581,8 +601,8 @@ def maybe_redeem_credits( granted = redeem_data.get("granted_chatgpt_subscriber_api_credits", 0) if granted and granted > 0: eprint( - f"""Thanks for being a ChatGPT {'Plus' if plan_type=='plus' else 'Pro'} subscriber! -If you haven't already redeemed, you should receive {'$5' if plan_type=='plus' else '$50'} in API credits. + f"""Thanks for being a ChatGPT {"Plus" if plan_type == "plus" else "Pro"} subscriber! +If you haven't already redeemed, you should receive {"$5" if plan_type == "plus" else "$50"} in API credits. Credits: https://platform.openai.com/settings/organization/billing/credit-grants More info: https://help.openai.com/en/articles/11381614""", diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index 7bc041a554..424b5ac2fc 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -6,16 +6,14 @@ use app::App; use codex_core::config::Config; use codex_core::config::ConfigOverrides; use codex_core::config_types::SandboxMode; -use codex_core::openai_api_key::OPENAI_API_KEY_ENV_VAR; -use codex_core::openai_api_key::get_openai_api_key; -use codex_core::openai_api_key::set_openai_api_key; use codex_core::protocol::AskForApproval; use codex_core::util::is_inside_git_repo; -use codex_login::try_read_openai_api_key; +use codex_login::load_auth; use log_layer::TuiLogLayer; use std::fs::OpenOptions; use std::io::Write; use std::path::PathBuf; +use tracing::error; use tracing_appender::non_blocking; use tracing_subscriber::EnvFilter; use tracing_subscriber::prelude::*; @@ -140,7 +138,7 @@ pub async fn run_main( .with(tui_layer) .try_init(); - let show_login_screen = should_show_login_screen(&config).await; + let show_login_screen = should_show_login_screen(&config); if show_login_screen { std::io::stdout() .write_all(b"No API key detected.\nLogin with your ChatGPT account? [Yn] ")?; @@ -153,8 +151,8 @@ pub async fn run_main( } // Spawn a task to run the login command. // Block until the login command is finished. - let new_key = codex_login::login_with_chatgpt(&config.codex_home, false).await?; - set_openai_api_key(new_key); + codex_login::login_with_chatgpt(&config.codex_home, false).await?; + std::io::stdout().write_all(b"Login successful.\n")?; } @@ -217,28 +215,21 @@ fn restore() { } } -async fn should_show_login_screen(config: &Config) -> bool { - if is_in_need_of_openai_api_key(config) { +#[allow(clippy::unwrap_used)] +fn should_show_login_screen(config: &Config) -> bool { + if config.model_provider.requires_auth { // Reading the OpenAI API key is an async operation because it may need // to refresh the token. Block on it. let codex_home = config.codex_home.clone(); - if let Ok(openai_api_key) = try_read_openai_api_key(&codex_home).await { - set_openai_api_key(openai_api_key); - false - } else { - true + match load_auth(&codex_home) { + Ok(Some(_)) => false, + Ok(None) => true, + Err(err) => { + error!("Failed to read auth.json: {err}"); + true + } } } else { false } } - -fn is_in_need_of_openai_api_key(config: &Config) -> bool { - let is_using_openai_key = config - .model_provider - .env_key - .as_ref() - .map(|s| s == OPENAI_API_KEY_ENV_VAR) - .unwrap_or(false); - is_using_openai_key && get_openai_api_key().is_none() -} From 2f5557056d259873062509ca47162cc696257aa9 Mon Sep 17 00:00:00 2001 From: aibrahim-oai Date: Wed, 30 Jul 2025 13:43:08 -0700 Subject: [PATCH 55/58] moving input item from MCP Protocol back to core Protocol (#1740) - Currently we have duplicate input item. Let's have one source of truth in the core. - Used Requestid type --- codex-rs/mcp-server/src/mcp_protocol.rs | 140 +++++++----------------- 1 file changed, 38 insertions(+), 102 deletions(-) diff --git a/codex-rs/mcp-server/src/mcp_protocol.rs b/codex-rs/mcp-server/src/mcp_protocol.rs index 933c145b48..e507376c16 100644 --- a/codex-rs/mcp-server/src/mcp_protocol.rs +++ b/codex-rs/mcp-server/src/mcp_protocol.rs @@ -1,6 +1,7 @@ use codex_core::config_types::SandboxMode; use codex_core::protocol::AskForApproval; use codex_core::protocol::EventMsg; +use codex_core::protocol::InputItem; use serde::Deserialize; use serde::Serialize; use strum_macros::Display; @@ -21,7 +22,7 @@ pub struct MessageId(pub Uuid); pub struct ToolCallRequest { #[serde(rename = "jsonrpc")] pub jsonrpc: &'static str, - pub id: u64, + pub id: RequestId, pub method: &'static str, pub params: ToolCallRequestParams, } @@ -38,7 +39,7 @@ pub enum ToolCallRequestParams { impl ToolCallRequestParams { /// Wrap this request in a JSON-RPC request. #[allow(dead_code)] - pub fn into_request(self, id: u64) -> ToolCallRequest { + pub fn into_request(self, id: RequestId) -> ToolCallRequest { ToolCallRequest { jsonrpc: "2.0", id, @@ -95,70 +96,13 @@ pub struct ConversationStreamArgs { #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct ConversationSendMessageArgs { pub conversation_id: ConversationId, - pub content: Vec, + pub content: Vec, #[serde(default, skip_serializing_if = "Option::is_none")] pub parent_message_id: Option, #[serde(default, skip_serializing_if = "Option::is_none")] #[serde(flatten)] pub conversation_overrides: Option, } - -/// Input items for a message. -/// Following OpenAI's Responses API: https://platform.openai.com/docs/api-reference/responses -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[serde(tag = "type", rename_all = "camelCase")] -pub enum MessageInputItem { - Text { - text: String, - }, - Image { - #[serde(flatten)] - source: ImageSource, - #[serde(default, skip_serializing_if = "Option::is_none")] - detail: Option, - }, - File { - #[serde(flatten)] - source: FileSource, - }, -} - -/// Source of an image. -/// Following OpenAI's API: https://platform.openai.com/docs/guides/images-vision#giving-a-model-images-as-input -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[serde(untagged)] -pub enum ImageSource { - ImageUrl { image_url: String }, - FileId { file_id: String }, -} - -/// Source of a file. -/// Following OpenAI's Responses API: https://platform.openai.com/docs/guides/pdf-files?api-mode=responses#uploading-files -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[serde(untagged)] -pub enum FileSource { - Url { - file_url: String, - }, - Id { - file_id: String, - }, - Base64 { - #[serde(default, skip_serializing_if = "Option::is_none")] - filename: Option, - // Base64-encoded file contents. - file_data: String, - }, -} - -#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub enum ImageDetail { - Low, - High, - Auto, -} - #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct ConversationsListArgs { #[serde(default, skip_serializing_if = "Option::is_none")] @@ -303,6 +247,8 @@ pub enum ClientNotification { #[allow(clippy::expect_used)] #[allow(clippy::unwrap_used)] mod tests { + use std::path::PathBuf; + use super::*; use codex_core::protocol::McpInvocation; use codex_core::protocol::McpToolCallBeginEvent; @@ -331,7 +277,7 @@ mod tests { base_instructions: None, }); - let observed = to_val(&req.into_request(2)); + let observed = to_val(&req.into_request(mcp_types::RequestId::Integer(2))); let expected = json!({ "jsonrpc": "2.0", "id": 2, @@ -354,18 +300,12 @@ mod tests { let req = ToolCallRequestParams::ConversationSendMessage(ConversationSendMessageArgs { conversation_id: ConversationId(uuid!("d0f6ecbe-84a2-41c1-b23d-b20473b25eab")), content: vec![ - MessageInputItem::Text { text: "Hi".into() }, - MessageInputItem::Image { - source: ImageSource::ImageUrl { - image_url: "https://example.com/cat.jpg".into(), - }, - detail: Some(ImageDetail::High), + InputItem::Text { text: "Hi".into() }, + InputItem::Image { + image_url: "https://example.com/cat.jpg".into(), }, - MessageInputItem::File { - source: FileSource::Base64 { - filename: Some("notes.txt".into()), - file_data: "Zm9vYmFy".into(), - }, + InputItem::LocalImage { + path: "notes.txt".into(), }, ], parent_message_id: Some(MessageId(uuid!("67e55044-10b1-426f-9247-bb680e5fe0c8"))), @@ -380,7 +320,7 @@ mod tests { }), }); - let observed = to_val(&req.into_request(2)); + let observed = to_val(&req.into_request(mcp_types::RequestId::Integer(2))); let expected = json!({ "jsonrpc": "2.0", "id": 2, @@ -391,8 +331,8 @@ mod tests { "conversation_id": "d0f6ecbe-84a2-41c1-b23d-b20473b25eab", "content": [ { "type": "text", "text": "Hi" }, - { "type": "image", "image_url": "https://example.com/cat.jpg", "detail": "high" }, - { "type": "file", "filename": "notes.txt", "file_data": "Zm9vYmFy" } + { "type": "image", "image_url": "https://example.com/cat.jpg" }, + { "type": "local_image", "path": "notes.txt" } ], "parent_message_id": "67e55044-10b1-426f-9247-bb680e5fe0c8", "model": "o4-mini", @@ -414,7 +354,7 @@ mod tests { cursor: Some("abc".into()), }); - let observed = to_val(&req.into_request(2)); + let observed = to_val(&req.into_request(RequestId::Integer(2))); let expected = json!({ "jsonrpc": "2.0", "id": 2, @@ -436,7 +376,7 @@ mod tests { conversation_id: ConversationId(uuid!("67e55044-10b1-426f-9247-bb680e5fe0c8")), }); - let observed = to_val(&req.into_request(2)); + let observed = to_val(&req.into_request(mcp_types::RequestId::Integer(2))); let expected = json!({ "jsonrpc": "2.0", "id": 2, @@ -454,48 +394,44 @@ mod tests { // ----- Message inputs / sources ----- #[test] - fn serialize_message_input_image_file_id_auto_detail() { - let item = MessageInputItem::Image { - source: ImageSource::FileId { - file_id: "file_123".into(), - }, - detail: Some(ImageDetail::Auto), + fn serialize_message_input_image_url() { + let item = InputItem::Image { + image_url: "https://example.com/x.png".into(), }; let observed = to_val(&item); let expected = json!({ "type": "image", - "file_id": "file_123", - "detail": "auto" + "image_url": "https://example.com/x.png" }); assert_eq!(observed, expected); } #[test] - fn serialize_message_input_file_url_and_id_variants() { - let url = MessageInputItem::File { - source: FileSource::Url { - file_url: "https://example.com/a.pdf".into(), - }, + fn serialize_message_input_local_image_path() { + let url = InputItem::LocalImage { + path: PathBuf::from("https://example.com/a.pdf"), }; - let id = MessageInputItem::File { - source: FileSource::Id { - file_id: "file_456".into(), - }, + let id = InputItem::LocalImage { + path: PathBuf::from("file_456"), }; + let observed_url = to_val(&url); + let expected_url = json!({"type":"local_image","path":"https://example.com/a.pdf"}); assert_eq!( - to_val(&url), - json!({"type":"file","file_url":"https://example.com/a.pdf"}) + observed_url, expected_url, + "LocalImage with URL path should serialize as image_url" + ); + let observed_id = to_val(&id); + let expected_id = json!({"type":"local_image","path":"file_456"}); + assert_eq!( + observed_id, expected_id, + "LocalImage with file id should serialize as image_url" ); - assert_eq!(to_val(&id), json!({"type":"file","file_id":"file_456"})); } #[test] fn serialize_message_input_image_url_without_detail() { - let item = MessageInputItem::Image { - source: ImageSource::ImageUrl { - image_url: "https://example.com/x.png".into(), - }, - detail: None, + let item = InputItem::Image { + image_url: "https://example.com/x.png".into(), }; let observed = to_val(&item); let expected = json!({ From e0e245cc1c2527d4ab788cc4b954d223112553cd Mon Sep 17 00:00:00 2001 From: pakrym-oai Date: Wed, 30 Jul 2025 13:56:24 -0700 Subject: [PATCH 56/58] Send AGENTS.md as a separate user message (#1737) --- codex-rs/core/src/chat_completions.rs | 4 ++ codex-rs/core/src/client.rs | 13 +++++- codex-rs/core/src/client_common.rs | 19 ++++++-- codex-rs/core/tests/client.rs | 64 +++++++++++++++++++++++++++ 4 files changed, 96 insertions(+), 4 deletions(-) diff --git a/codex-rs/core/src/chat_completions.rs b/codex-rs/core/src/chat_completions.rs index 3042ec452a..5ede774b1c 100644 --- a/codex-rs/core/src/chat_completions.rs +++ b/codex-rs/core/src/chat_completions.rs @@ -40,6 +40,10 @@ pub(crate) async fn stream_chat_completions( let full_instructions = prompt.get_full_instructions(model); messages.push(json!({"role": "system", "content": full_instructions})); + if let Some(instr) = &prompt.user_instructions { + messages.push(json!({"role": "user", "content": instr})); + } + for item in &prompt.input { match item { ResponseItem::Message { role, content, .. } => { diff --git a/codex-rs/core/src/client.rs b/codex-rs/core/src/client.rs index 72104da254..4e0e62c0f7 100644 --- a/codex-rs/core/src/client.rs +++ b/codex-rs/core/src/client.rs @@ -35,6 +35,7 @@ use crate::error::Result; use crate::flags::CODEX_RS_SSE_FIXTURE; use crate::model_provider_info::ModelProviderInfo; use crate::model_provider_info::WireApi; +use crate::models::ContentItem; use crate::models::ResponseItem; use crate::openai_tools::create_tools_json_for_responses_api; use crate::protocol::TokenUsage; @@ -156,10 +157,20 @@ impl ModelClient { vec![] }; + let mut input_with_instructions = Vec::with_capacity(prompt.input.len() + 1); + if let Some(ui) = &prompt.user_instructions { + input_with_instructions.push(ResponseItem::Message { + id: None, + role: "user".to_string(), + content: vec![ContentItem::InputText { text: ui.clone() }], + }); + } + input_with_instructions.extend(prompt.input.clone()); + let payload = ResponsesApiRequest { model: &self.config.model, instructions: &full_instructions, - input: &prompt.input, + input: &input_with_instructions, tools: &tools_json, tool_choice: "auto", parallel_tool_calls: false, diff --git a/codex-rs/core/src/client_common.rs b/codex-rs/core/src/client_common.rs index afd2f04556..157f35872a 100644 --- a/codex-rs/core/src/client_common.rs +++ b/codex-rs/core/src/client_common.rs @@ -44,9 +44,6 @@ impl Prompt { .as_deref() .unwrap_or(BASE_INSTRUCTIONS); let mut sections: Vec<&str> = vec![base]; - if let Some(ref user) = self.user_instructions { - sections.push(user); - } if model.starts_with("gpt-4.1") { sections.push(APPLY_PATCH_TOOL_INSTRUCTIONS); } @@ -188,3 +185,19 @@ impl Stream for ResponseStream { self.rx_event.poll_recv(cx) } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn get_full_instructions_no_user_content() { + let prompt = Prompt { + user_instructions: Some("custom instruction".to_string()), + ..Default::default() + }; + let expected = format!("{BASE_INSTRUCTIONS}\n{APPLY_PATCH_TOOL_INSTRUCTIONS}"); + let full = prompt.get_full_instructions("gpt-4.1"); + assert_eq!(full, expected); + } +} diff --git a/codex-rs/core/tests/client.rs b/codex-rs/core/tests/client.rs index fbe63fb326..67d95cb8f6 100644 --- a/codex-rs/core/tests/client.rs +++ b/codex-rs/core/tests/client.rs @@ -257,6 +257,70 @@ async fn chatgpt_auth_sends_correct_request() { ); } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn includes_user_instructions_message_in_request() { + #![allow(clippy::unwrap_used)] + + let server = MockServer::start().await; + + let first = ResponseTemplate::new(200) + .insert_header("content-type", "text/event-stream") + .set_body_raw(sse_completed("resp1"), "text/event-stream"); + + Mock::given(method("POST")) + .and(path("/v1/responses")) + .respond_with(first) + .expect(1) + .mount(&server) + .await; + + let model_provider = ModelProviderInfo { + base_url: Some(format!("{}/v1", server.uri())), + ..built_in_model_providers()["openai"].clone() + }; + + let codex_home = TempDir::new().unwrap(); + let mut config = load_default_config_for_test(&codex_home); + config.model_provider = model_provider; + config.user_instructions = Some("be nice".to_string()); + + let ctrl_c = std::sync::Arc::new(tokio::sync::Notify::new()); + let CodexSpawnOk { codex, .. } = Codex::spawn( + config, + Some(CodexAuth::from_api_key("Test API Key".to_string())), + ctrl_c.clone(), + ) + .await + .unwrap(); + + codex + .submit(Op::UserInput { + items: vec![InputItem::Text { + text: "hello".into(), + }], + }) + .await + .unwrap(); + + wait_for_event(&codex, |ev| matches!(ev, EventMsg::TaskComplete(_))).await; + + let request = &server.received_requests().await.unwrap()[0]; + let request_body = request.body_json::().unwrap(); + + assert!( + !request_body["instructions"] + .as_str() + .unwrap() + .contains("be nice") + ); + assert_eq!(request_body["input"][0]["role"], "user"); + assert!( + request_body["input"][0]["content"][0]["text"] + .as_str() + .unwrap() + .starts_with("be nice") + ); +} fn auth_from_token(id_token: String) -> CodexAuth { CodexAuth::new( None, From 301ec72107f107ac5c6103660e276a25bc18273e Mon Sep 17 00:00:00 2001 From: pakrym-oai Date: Wed, 30 Jul 2025 14:09:26 -0700 Subject: [PATCH 57/58] Add login status command (#1716) Print the current login mode, sanitized key and return an appropriate status. --- README.md | 6 +++ codex-rs/cli/src/login.rs | 93 ++++++++++++++++++++++++++++++++------- codex-rs/cli/src/main.rs | 21 ++++++++- 3 files changed, 102 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index 60e44298a3..c7f6a1d595 100644 --- a/README.md +++ b/README.md @@ -95,6 +95,12 @@ codex login If you complete the process successfully, you should have a `~/.codex/auth.json` file that contains the credentials that Codex will use. +To verify whether you are currently logged in, run: + +``` +codex login status +``` + If you encounter problems with the login flow, please comment on .
diff --git a/codex-rs/cli/src/login.rs b/codex-rs/cli/src/login.rs index af3fb667f6..390c310030 100644 --- a/codex-rs/cli/src/login.rs +++ b/codex-rs/cli/src/login.rs @@ -1,25 +1,12 @@ use codex_common::CliConfigOverrides; use codex_core::config::Config; use codex_core::config::ConfigOverrides; +use codex_login::AuthMode; +use codex_login::load_auth; use codex_login::login_with_chatgpt; pub async fn run_login_with_chatgpt(cli_config_overrides: CliConfigOverrides) -> ! { - let cli_overrides = match cli_config_overrides.parse_overrides() { - Ok(v) => v, - Err(e) => { - eprintln!("Error parsing -c overrides: {e}"); - std::process::exit(1); - } - }; - - let config_overrides = ConfigOverrides::default(); - let config = match Config::load_with_cli_overrides(cli_overrides, config_overrides) { - Ok(config) => config, - Err(e) => { - eprintln!("Error loading configuration: {e}"); - std::process::exit(1); - } - }; + let config = load_config_or_exit(cli_config_overrides); let capture_output = false; match login_with_chatgpt(&config.codex_home, capture_output).await { @@ -33,3 +20,77 @@ pub async fn run_login_with_chatgpt(cli_config_overrides: CliConfigOverrides) -> } } } + +pub async fn run_login_status(cli_config_overrides: CliConfigOverrides) -> ! { + let config = load_config_or_exit(cli_config_overrides); + + match load_auth(&config.codex_home) { + Ok(Some(auth)) => match auth.mode { + AuthMode::ApiKey => { + if let Some(api_key) = auth.api_key.as_deref() { + eprintln!("Logged in using an API key - {}", safe_format_key(api_key)); + } else { + eprintln!("Logged in using an API key"); + } + std::process::exit(0); + } + AuthMode::ChatGPT => { + eprintln!("Logged in using ChatGPT"); + std::process::exit(0); + } + }, + Ok(None) => { + eprintln!("Not logged in"); + std::process::exit(1); + } + Err(e) => { + eprintln!("Error checking login status: {e}"); + std::process::exit(1); + } + } +} + +fn load_config_or_exit(cli_config_overrides: CliConfigOverrides) -> Config { + let cli_overrides = match cli_config_overrides.parse_overrides() { + Ok(v) => v, + Err(e) => { + eprintln!("Error parsing -c overrides: {e}"); + std::process::exit(1); + } + }; + + let config_overrides = ConfigOverrides::default(); + match Config::load_with_cli_overrides(cli_overrides, config_overrides) { + Ok(config) => config, + Err(e) => { + eprintln!("Error loading configuration: {e}"); + std::process::exit(1); + } + } +} + +fn safe_format_key(key: &str) -> String { + if key.len() <= 13 { + return "***".to_string(); + } + let prefix = &key[..8]; + let suffix = &key[key.len() - 5..]; + format!("{prefix}***{suffix}") +} + +#[cfg(test)] +mod tests { + use super::safe_format_key; + + #[test] + fn formats_long_key() { + let key = "sk-proj-1234567890ABCDE"; + assert_eq!(safe_format_key(key), "sk-proj-***ABCDE"); + } + + #[test] + fn short_key_returns_stars() { + let key = "sk-proj-12345"; + assert_eq!(safe_format_key(key), "***"); + } +} diff --git a/codex-rs/cli/src/main.rs b/codex-rs/cli/src/main.rs index 6dd596ff9f..c5fd69f9cd 100644 --- a/codex-rs/cli/src/main.rs +++ b/codex-rs/cli/src/main.rs @@ -7,6 +7,7 @@ use codex_chatgpt::apply_command::ApplyCommand; use codex_chatgpt::apply_command::run_apply_command; use codex_cli::LandlockCommand; use codex_cli::SeatbeltCommand; +use codex_cli::login::run_login_status; use codex_cli::login::run_login_with_chatgpt; use codex_cli::proto; use codex_common::CliConfigOverrides; @@ -43,7 +44,7 @@ enum Subcommand { #[clap(visible_alias = "e")] Exec(ExecCli), - /// Login with ChatGPT. + /// Manage login. Login(LoginCommand), /// Experimental: run Codex as an MCP server. @@ -90,6 +91,15 @@ enum DebugCommand { struct LoginCommand { #[clap(skip)] config_overrides: CliConfigOverrides, + + #[command(subcommand)] + action: Option, +} + +#[derive(Debug, clap::Subcommand)] +enum LoginSubcommand { + /// Show login status. + Status, } fn main() -> anyhow::Result<()> { @@ -118,7 +128,14 @@ async fn cli_main(codex_linux_sandbox_exe: Option) -> anyhow::Result<() } Some(Subcommand::Login(mut login_cli)) => { prepend_config_flags(&mut login_cli.config_overrides, cli.config_overrides); - run_login_with_chatgpt(login_cli.config_overrides).await; + match login_cli.action { + Some(LoginSubcommand::Status) => { + run_login_status(login_cli.config_overrides).await; + } + None => { + run_login_with_chatgpt(login_cli.config_overrides).await; + } + } } Some(Subcommand::Proto(mut proto_cli)) => { prepend_config_flags(&mut proto_cli.config_overrides, cli.config_overrides); From 5626a47042865041fa4c3772d20f5c0b1934a61b Mon Sep 17 00:00:00 2001 From: Ahmed Ibrahim Date: Wed, 30 Jul 2025 15:08:44 -0700 Subject: [PATCH 58/58] revive --- codex-rs/core/src/codex.rs | 1 + codex-rs/core/tests/summarize_context.rs | 7 +++---- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index d8e1c65523..46de14efbb 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -857,6 +857,7 @@ async fn submission_loop( let task = AgentTask::spawn(sess.clone(), sub.id, items); sess.set_task(task); } + } Op::Shutdown => { info!("Shutting down Codex instance"); diff --git a/codex-rs/core/tests/summarize_context.rs b/codex-rs/core/tests/summarize_context.rs index ecf84c4fdf..3eb562588a 100644 --- a/codex-rs/core/tests/summarize_context.rs +++ b/codex-rs/core/tests/summarize_context.rs @@ -8,17 +8,16 @@ use std::time::Duration; use codex_core::Codex; use codex_core::protocol::EventMsg; use codex_core::protocol::Op; -mod test_support; +use core_test_support::load_default_config_for_test; use tempfile::TempDir; -use test_support::load_default_config_for_test; use tokio::time::timeout; /// Helper function to set up a codex session and wait for it to be configured async fn setup_configured_codex_session() -> Codex { let codex_home = TempDir::new().unwrap(); let config = load_default_config_for_test(&codex_home); - let (codex, _, _) = codex_core::codex_wrapper::init_codex(config).await.unwrap(); - codex + let codex_conversation = codex_core::codex_wrapper::init_codex(config).await.unwrap(); + codex_conversation.codex } #[tokio::test]