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)
[](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)
[](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
[](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
[](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
[](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
[](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
[](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
---
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.
---
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