From e85742635f8e958c22ad46b5428f16b973c6b38d Mon Sep 17 00:00:00 2001 From: pakrym-oai Date: Wed, 24 Sep 2025 15:00:06 -0700 Subject: [PATCH 1/7] Send text parameter for non-gpt-5 models (#4195) We had a hardcoded check for gpt-5 before. Fixes: https://github.com/openai/codex/issues/4181 --- codex-rs/code | 0 codex-rs/core/src/client.rs | 24 ++++++++++++++---------- codex-rs/core/tests/suite/json_result.rs | 13 +++++++++++-- 3 files changed, 25 insertions(+), 12 deletions(-) create mode 100644 codex-rs/code diff --git a/codex-rs/code b/codex-rs/code new file mode 100644 index 0000000000..e69de29bb2 diff --git a/codex-rs/core/src/client.rs b/codex-rs/core/src/client.rs index e088f9c3ab..39a6aff8da 100644 --- a/codex-rs/core/src/client.rs +++ b/codex-rs/core/src/client.rs @@ -184,19 +184,23 @@ impl ModelClient { let input_with_instructions = prompt.get_formatted_input(); - // Only include `text.verbosity` for GPT-5 family models - let text = if self.config.model_family.family == "gpt-5" { - create_text_param_for_request(self.config.model_verbosity, &prompt.output_schema) - } else { - if self.config.model_verbosity.is_some() { - warn!( - "model_verbosity is set but ignored for non-gpt-5 model family: {}", - self.config.model_family.family - ); + let verbosity = match &self.config.model_family.family { + family if family == "gpt-5" => self.config.model_verbosity, + _ => { + if self.config.model_verbosity.is_some() { + warn!( + "model_verbosity is set but ignored for non-gpt-5 model family: {}", + self.config.model_family.family + ); + } + + None } - None }; + // Only include `text.verbosity` for GPT-5 family models + let text = create_text_param_for_request(verbosity, &prompt.output_schema); + // In general, we want to explicitly send `store: false` when using the Responses API, // but in practice, the Azure Responses API rejects `store: false`: // diff --git a/codex-rs/core/tests/suite/json_result.rs b/codex-rs/core/tests/suite/json_result.rs index 43d5cb4b6a..86da33f7a0 100644 --- a/codex-rs/core/tests/suite/json_result.rs +++ b/codex-rs/core/tests/suite/json_result.rs @@ -30,7 +30,16 @@ const SCHEMA: &str = r#" "#; #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn codex_returns_json_result() -> anyhow::Result<()> { +async fn codex_returns_json_result_for_gpt5() -> anyhow::Result<()> { + codex_returns_json_result("gpt-5".to_string()).await +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn codex_returns_json_result_for_gpt5_codex() -> anyhow::Result<()> { + codex_returns_json_result("gpt-5-codex".to_string()).await +} + +async fn codex_returns_json_result(model: String) -> anyhow::Result<()> { non_sandbox_test!(result); let server = start_mock_server().await; @@ -72,7 +81,7 @@ async fn codex_returns_json_result() -> anyhow::Result<()> { cwd: cwd.path().to_path_buf(), approval_policy: AskForApproval::Never, sandbox_policy: SandboxPolicy::DangerFullAccess, - model: "gpt-5".to_string(), + model, effort: None, summary: ReasoningSummary::Auto, }) From d1ed3a4cef6d02b127525935238556bfd4cd831e Mon Sep 17 00:00:00 2001 From: pakrym-oai Date: Wed, 24 Sep 2025 21:05:00 -0700 Subject: [PATCH 2/7] github: update codespell action to v2.1 in workflow (#4205) Old version fails to find python 3.8 docker image --- .github/workflows/codespell.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/codespell.yml b/.github/workflows/codespell.yml index ebf55649b8..3d49986791 100644 --- a/.github/workflows/codespell.yml +++ b/.github/workflows/codespell.yml @@ -22,7 +22,7 @@ jobs: - name: Annotate locations with typos uses: codespell-project/codespell-problem-matcher@b80729f885d32f78a716c2f107b4db1025001c42 # v1 - name: Codespell - uses: codespell-project/actions-codespell@406322ec52dd7b488e48c1c4b82e2a8b3a1bf630 # v2 + uses: codespell-project/actions-codespell@406322ec52dd7b488e48c1c4b82e2a8b3a1bf630 # v2.1 with: ignore_words_file: .codespellignore skip: frame*.txt From 250b244ab4223817b8b4f28d827bfd892153ae3e Mon Sep 17 00:00:00 2001 From: jif-oai Date: Thu, 25 Sep 2025 11:16:06 +0100 Subject: [PATCH 3/7] ref: full state refactor (#4174) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Current State Observations - `Session` currently holds many unrelated responsibilities (history, approval queues, task handles, rollout recorder, shell discovery, token tracking, etc.), making it hard to reason about ownership and lifetimes. - The anonymous `State` struct inside `codex.rs` mixes session-long data with turn-scoped queues and approval bookkeeping. - Turn execution (`run_task`) relies on ad-hoc local variables that should conceptually belong to a per-turn state object. - External modules (`codex::compact`, tests) frequently poke the raw `Session.state` mutex, which couples them to implementation details. - Interrupts, approvals, and rollout persistence all have bespoke cleanup paths, contributing to subtle bugs when a turn is aborted mid-flight. ## Desired End State - Keep a slim `Session` object that acts as the orchestrator and faรงade. It should expose a focused API (submit, approvals, interrupts, event emission) without storing unrelated fields directly. - Introduce a `state` module that encapsulates all mutable data structures: - `SessionState`: session-persistent data (history, approved commands, token/rate-limit info, maybe user preferences). - `ActiveTurn`: metadata for the currently running turn (sub-id, task kind, abort handle) and an `Arc`. - `TurnState`: all turn-scoped pieces (pending inputs, approval waiters, diff tracker, review history, auto-compact flags, last agent message, outstanding tool call bookkeeping). - Group long-lived helpers/managers into a dedicated `SessionServices` struct so `Session` does not accumulate "random" fields. - Provide clear, lock-safe APIs so other modules never touch raw mutexes. - Ensure every turn creates/drops a `TurnState` and that interrupts/finishes delegate cleanup to it. --- codex-rs/core/src/codex.rs | 236 +++++++++++++++++------------ codex-rs/core/src/codex/compact.rs | 13 +- codex-rs/core/src/lib.rs | 1 + codex-rs/core/src/state/mod.rs | 8 + codex-rs/core/src/state/service.rs | 18 +++ codex-rs/core/src/state/session.rs | 82 ++++++++++ codex-rs/core/src/state/turn.rs | 60 ++++++++ 7 files changed, 315 insertions(+), 103 deletions(-) create mode 100644 codex-rs/core/src/state/mod.rs create mode 100644 codex-rs/core/src/state/service.rs create mode 100644 codex-rs/core/src/state/session.rs create mode 100644 codex-rs/core/src/state/turn.rs diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 84a04d7533..48aa3dbd7c 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -1,6 +1,5 @@ use std::borrow::Cow; use std::collections::HashMap; -use std::collections::HashSet; use std::path::Path; use std::path::PathBuf; use std::sync::Arc; @@ -111,7 +110,6 @@ use crate::protocol::Submission; use crate::protocol::TaskCompleteEvent; use crate::protocol::TokenCountEvent; use crate::protocol::TokenUsage; -use crate::protocol::TokenUsageInfo; use crate::protocol::TurnDiffEvent; use crate::protocol::WebSearchBeginEvent; use crate::rollout::RolloutRecorder; @@ -120,6 +118,8 @@ use crate::safety::SafetyCheck; use crate::safety::assess_command_safety; use crate::safety::assess_safety_for_untrusted_command; use crate::shell; +use crate::state::ActiveTurn; +use crate::state::SessionServices; use crate::turn_diff_tracker::TurnDiffTracker; use crate::unified_exec::UnifiedExecSessionManager; use crate::user_instructions::UserInstructions; @@ -253,17 +253,7 @@ impl Codex { } } -/// Mutable state of the agent -#[derive(Default)] -struct State { - approved_commands: HashSet>, - current_task: Option, - pending_approvals: HashMap>, - pending_input: Vec, - history: ConversationHistory, - token_info: Option, - latest_rate_limits: Option, -} +use crate::state::SessionState; /// Context for an initialized model agent /// @@ -271,21 +261,9 @@ struct State { pub(crate) struct Session { conversation_id: ConversationId, tx_event: Sender, - - /// Manager for external MCP servers/tools. - mcp_connection_manager: McpConnectionManager, - session_manager: ExecSessionManager, - unified_exec_manager: UnifiedExecSessionManager, - - notifier: UserNotifier, - - /// Optional rollout recorder for persisting the conversation transcript so - /// sessions can be replayed or inspected later. - rollout: Mutex>, - state: Mutex, - codex_linux_sandbox_exe: Option, - user_shell: shell::Shell, - show_raw_agent_reasoning: bool, + state: Mutex, + active_turn: Mutex>, + services: SessionServices, next_internal_sub_id: AtomicU64, } @@ -413,10 +391,7 @@ impl Session { })?; let rollout_path = rollout_recorder.rollout_path.clone(); // Create the mutable state for the Session. - let state = State { - history: ConversationHistory::new(), - ..Default::default() - }; + let state = SessionState::new(); // Handle MCP manager result and record any startup failures. let (mcp_connection_manager, failed_clients) = match mcp_res { @@ -474,18 +449,23 @@ impl Session { is_review_mode: false, final_output_json_schema: None, }; - let sess = Arc::new(Session { - conversation_id, - tx_event: tx_event.clone(), + let services = SessionServices { mcp_connection_manager, session_manager: ExecSessionManager::default(), unified_exec_manager: UnifiedExecSessionManager::default(), notifier: notify, - state: Mutex::new(state), rollout: Mutex::new(Some(rollout_recorder)), codex_linux_sandbox_exe: config.codex_linux_sandbox_exe.clone(), user_shell: default_shell, show_raw_agent_reasoning: config.show_raw_agent_reasoning, + }; + + let sess = Arc::new(Session { + conversation_id, + tx_event: tx_event.clone(), + state: Mutex::new(state), + active_turn: Mutex::new(None), + services, next_internal_sub_id: AtomicU64::new(0), }); @@ -521,6 +501,15 @@ impl Session { current_task.abort(TurnAbortReason::Replaced); } state.current_task = Some(task); + if let Some(current_task) = &state.current_task { + let mut active = self.active_turn.lock().await; + *active = Some(ActiveTurn { + sub_id: current_task.sub_id.clone(), + turn_state: std::sync::Arc::new(tokio::sync::Mutex::new( + crate::state::TurnState::default(), + )), + }); + } } pub async fn remove_task(&self, sub_id: &str) { @@ -530,6 +519,12 @@ impl Session { { state.current_task.take(); } + let mut active = self.active_turn.lock().await; + if let Some(at) = &*active + && at.sub_id == sub_id + { + *active = None; + } } fn next_internal_sub_id(&self) -> String { @@ -591,8 +586,14 @@ impl Session { let (tx_approve, rx_approve) = oneshot::channel(); let event_id = sub_id.clone(); let prev_entry = { - let mut state = self.state.lock().await; - state.pending_approvals.insert(sub_id, tx_approve) + let mut active = self.active_turn.lock().await; + match active.as_mut() { + Some(at) => { + let mut ts = at.turn_state.lock().await; + ts.insert_pending_approval(sub_id, tx_approve) + } + None => None, + } }; if prev_entry.is_some() { warn!("Overwriting existing pending approval for sub_id: {event_id}"); @@ -623,8 +624,14 @@ impl Session { let (tx_approve, rx_approve) = oneshot::channel(); let event_id = sub_id.clone(); let prev_entry = { - let mut state = self.state.lock().await; - state.pending_approvals.insert(sub_id, tx_approve) + let mut active = self.active_turn.lock().await; + match active.as_mut() { + Some(at) => { + let mut ts = at.turn_state.lock().await; + ts.insert_pending_approval(sub_id, tx_approve) + } + None => None, + } }; if prev_entry.is_some() { warn!("Overwriting existing pending approval for sub_id: {event_id}"); @@ -645,8 +652,14 @@ impl Session { pub async fn notify_approval(&self, sub_id: &str, decision: ReviewDecision) { let entry = { - let mut state = self.state.lock().await; - state.pending_approvals.remove(sub_id) + let mut active = self.active_turn.lock().await; + match active.as_mut() { + Some(at) => { + let mut ts = at.turn_state.lock().await; + ts.remove_pending_approval(sub_id) + } + None => None, + } }; match entry { Some(tx_approve) => { @@ -660,7 +673,7 @@ impl Session { pub async fn add_approved_command(&self, cmd: Vec) { let mut state = self.state.lock().await; - state.approved_commands.insert(cmd); + state.add_approved_command(cmd); } /// Records input items: always append to conversation history and @@ -700,7 +713,12 @@ impl Session { /// Append ResponseItems to the in-memory conversation history only. async fn record_into_history(&self, items: &[ResponseItem]) { let mut state = self.state.lock().await; - state.history.record_items(items.iter()); + state.record_items(items.iter()); + } + + async fn replace_history(&self, items: Vec) { + let mut state = self.state.lock().await; + state.replace_history(items); } async fn persist_rollout_response_items(&self, items: &[ResponseItem]) { @@ -721,14 +739,14 @@ impl Session { Some(turn_context.cwd.clone()), Some(turn_context.approval_policy), Some(turn_context.sandbox_policy.clone()), - Some(self.user_shell.clone()), + Some(self.user_shell().clone()), ))); items } async fn persist_rollout_items(&self, items: &[RolloutItem]) { let recorder = { - let guard = self.rollout.lock().await; + let guard = self.services.rollout.lock().await; guard.clone() }; if let Some(rec) = recorder @@ -738,6 +756,11 @@ impl Session { } } + pub(crate) async fn history_snapshot(&self) -> Vec { + let state = self.state.lock().await; + state.history_snapshot() + } + async fn update_token_usage_info( &self, sub_id: &str, @@ -747,12 +770,10 @@ impl Session { { let mut state = self.state.lock().await; if let Some(token_usage) = token_usage { - let info = TokenUsageInfo::new_or_append( - &state.token_info, - &Some(token_usage.clone()), + state.update_token_info_from_usage( + token_usage, turn_context.client.get_model_context_window(), ); - state.token_info = info; } } self.send_token_count_event(sub_id).await; @@ -761,7 +782,7 @@ impl Session { async fn update_rate_limits(&self, sub_id: &str, new_rate_limits: RateLimitSnapshot) { { let mut state = self.state.lock().await; - state.latest_rate_limits = Some(new_rate_limits); + state.set_rate_limits(new_rate_limits); } self.send_token_count_event(sub_id).await; } @@ -769,7 +790,7 @@ impl Session { async fn send_token_count_event(&self, sub_id: &str) { let (info, rate_limits) = { let state = self.state.lock().await; - (state.token_info.clone(), state.latest_rate_limits.clone()) + state.token_info_and_rate_limits() }; let event = Event { id: sub_id.to_string(), @@ -788,7 +809,7 @@ impl Session { // Derive user message events and persist only UserMessage to rollout let msgs = - map_response_item_to_event_messages(&response_item, self.show_raw_agent_reasoning); + map_response_item_to_event_messages(&response_item, self.show_raw_agent_reasoning()); let user_msgs: Vec = msgs .into_iter() .filter_map(|m| match m { @@ -987,16 +1008,20 @@ impl Session { pub async fn turn_input_with_history(&self, extra: Vec) -> Vec { let history = { let state = self.state.lock().await; - state.history.contents() + state.history_snapshot() }; [history, extra].concat() } /// Returns the input if there was no task running to inject into pub async fn inject_input(&self, input: Vec) -> Result<(), Vec> { - let mut state = self.state.lock().await; + let state = self.state.lock().await; if state.current_task.is_some() { - state.pending_input.push(input.into()); + let mut active = self.active_turn.lock().await; + if let Some(at) = active.as_mut() { + let mut ts = at.turn_state.lock().await; + ts.push_pending_input(input.into()); + } Ok(()) } else { Err(input) @@ -1004,13 +1029,12 @@ impl Session { } pub async fn get_pending_input(&self) -> Vec { - let mut state = self.state.lock().await; - if state.pending_input.is_empty() { - Vec::with_capacity(0) + let mut active = self.active_turn.lock().await; + if let Some(at) = active.as_mut() { + let mut ts = at.turn_state.lock().await; + ts.take_pending_input() } else { - let mut ret = Vec::new(); - std::mem::swap(&mut ret, &mut state.pending_input); - ret + Vec::with_capacity(0) } } @@ -1020,7 +1044,8 @@ impl Session { tool: &str, arguments: Option, ) -> anyhow::Result { - self.mcp_connection_manager + self.services + .mcp_connection_manager .call_tool(server, tool, arguments) .await } @@ -1028,8 +1053,11 @@ impl Session { pub async fn interrupt_task(&self) { info!("interrupt received: abort current task, if any"); let mut state = self.state.lock().await; - state.pending_approvals.clear(); - state.pending_input.clear(); + let mut active = self.active_turn.lock().await; + if let Some(at) = active.as_mut() { + let mut ts = at.turn_state.lock().await; + ts.clear_pending(); + } if let Some(task) = state.current_task.take() { task.abort(TurnAbortReason::Interrupted); } @@ -1037,8 +1065,12 @@ impl Session { fn interrupt_task_sync(&self) { if let Ok(mut state) = self.state.try_lock() { - state.pending_approvals.clear(); - state.pending_input.clear(); + if let Ok(mut active) = self.active_turn.try_lock() + && let Some(at) = active.as_mut() + && let Ok(mut ts) = at.turn_state.try_lock() + { + ts.clear_pending(); + } if let Some(task) = state.current_task.take() { task.abort(TurnAbortReason::Interrupted); } @@ -1046,7 +1078,15 @@ impl Session { } pub(crate) fn notifier(&self) -> &UserNotifier { - &self.notifier + &self.services.notifier + } + + fn user_shell(&self) -> &shell::Shell { + &self.services.user_shell + } + + fn show_raw_agent_reasoning(&self) -> bool { + self.services.show_raw_agent_reasoning } } @@ -1152,15 +1192,19 @@ impl AgentTask { // TOCTOU? if !self.handle.is_finished() { self.handle.abort(); + let sub_id = self.sub_id.clone(); + let is_review = self.kind == AgentTaskKind::Review; + let sess = self.sess; let event = Event { - id: self.sub_id.clone(), + id: sub_id.clone(), msg: EventMsg::TurnAborted(TurnAbortedEvent { reason }), }; - let sess = self.sess; tokio::spawn(async move { - if self.kind == AgentTaskKind::Review { - exit_review_mode(sess.clone(), self.sub_id, None).await; + if is_review { + exit_review_mode(sess.clone(), sub_id.clone(), None).await; } + // Ensure active turn state is cleared when a task is aborted. + sess.remove_task(&sub_id).await; sess.send_event(event).await; }); } @@ -1418,7 +1462,7 @@ async fn submission_loop( let sub_id = sub.id.clone(); // This is a cheap lookup from the connection manager's cache. - let tools = sess.mcp_connection_manager.list_all_tools(); + let tools = sess.services.mcp_connection_manager.list_all_tools(); let event = Event { id: sub_id, msg: EventMsg::McpListToolsResponse( @@ -1468,7 +1512,7 @@ async fn submission_loop( // Gracefully flush and shutdown rollout recorder on session end so tests // that inspect the rollout file do not race with the background writer. let recorder_opt = { - let mut guard = sess.rollout.lock().await; + let mut guard = sess.services.rollout.lock().await; guard.take() }; if let Some(rec) = recorder_opt @@ -1495,7 +1539,7 @@ async fn submission_loop( let sub_id = sub.id.clone(); // Flush rollout writes before returning the path so readers observe a consistent file. let (path, rec_opt) = { - let guard = sess.rollout.lock().await; + let guard = sess.services.rollout.lock().await; match guard.as_ref() { Some(rec) => (rec.get_rollout_path(), Some(rec.clone())), None => { @@ -1953,7 +1997,7 @@ async fn run_turn( ) -> CodexResult { let tools = get_openai_tools( &turn_context.tools_config, - Some(sess.mcp_connection_manager.list_all_tools()), + Some(sess.services.mcp_connection_manager.list_all_tools()), ); let prompt = Prompt { @@ -2203,7 +2247,7 @@ async fn try_run_turn( sess.send_event(event).await; } ResponseEvent::ReasoningContentDelta(delta) => { - if sess.show_raw_agent_reasoning { + if sess.show_raw_agent_reasoning() { let event = Event { id: sub_id.to_string(), msg: EventMsg::AgentReasoningRawContentDelta( @@ -2233,7 +2277,9 @@ async fn handle_response_item( .. } => { info!("FunctionCall: {name}({arguments})"); - if let Some((server, tool_name)) = sess.mcp_connection_manager.parse_tool_name(&name) { + if let Some((server, tool_name)) = + sess.services.mcp_connection_manager.parse_tool_name(&name) + { let resp = handle_mcp_tool_call( sess, sub_id, @@ -2369,7 +2415,7 @@ async fn handle_response_item( trace!("suppressing assistant Message in review mode"); Vec::new() } - _ => map_response_item_to_event_messages(&item, sess.show_raw_agent_reasoning), + _ => map_response_item_to_event_messages(&item, sess.show_raw_agent_reasoning()), }; for msg in msgs { let event = Event { @@ -2411,6 +2457,7 @@ async fn handle_unified_exec_tool_call( }; let value = sess + .services .unified_exec_manager .handle_request(request) .await @@ -2529,6 +2576,7 @@ async fn handle_function_call( )) })?; let result = sess + .services .session_manager .handle_exec_command_request(exec_params) .await; @@ -2546,6 +2594,7 @@ async fn handle_function_call( })?; let result = sess + .services .session_manager .handle_write_stdin_request(write_stdin_params) .await @@ -2636,12 +2685,12 @@ fn maybe_translate_shell_command( sess: &Session, turn_context: &TurnContext, ) -> ExecParams { - let should_translate = matches!(sess.user_shell, crate::shell::Shell::PowerShell(_)) + let should_translate = matches!(sess.user_shell(), crate::shell::Shell::PowerShell(_)) || turn_context.shell_environment_policy.use_profile; if should_translate && let Some(command) = sess - .user_shell + .user_shell() .format_default_shell_invocation(params.command.clone()) { return ExecParams { command, ..params }; @@ -2741,7 +2790,7 @@ async fn handle_container_exec_with_params( ¶ms.command, turn_context.approval_policy, &turn_context.sandbox_policy, - &state.approved_commands, + state.approved_commands_ref(), params.with_escalated_permissions.unwrap_or(false), ) }; @@ -2812,7 +2861,7 @@ async fn handle_container_exec_with_params( sandbox_type, sandbox_policy: &turn_context.sandbox_policy, sandbox_cwd: &turn_context.cwd, - codex_linux_sandbox_exe: &sess.codex_linux_sandbox_exe, + codex_linux_sandbox_exe: &sess.services.codex_linux_sandbox_exe, stdout_stream: if exec_command_context.apply_patch.is_some() { None } else { @@ -2927,7 +2976,7 @@ async fn handle_sandbox_error( sandbox_type: SandboxType::None, sandbox_policy: &turn_context.sandbox_policy, sandbox_cwd: &turn_context.cwd, - codex_linux_sandbox_exe: &sess.codex_linux_sandbox_exe, + codex_linux_sandbox_exe: &sess.services.codex_linux_sandbox_exe, stdout_stream: if exec_command_context.apply_patch.is_some() { None } else { @@ -3268,7 +3317,7 @@ mod tests { }), )); - let actual = tokio_test::block_on(async { session.state.lock().await.history.contents() }); + let actual = tokio_test::block_on(async { session.state.lock().await.history_snapshot() }); assert_eq!(expected, actual); } @@ -3281,7 +3330,7 @@ mod tests { session.record_initial_history(&turn_context, InitialHistory::Forked(rollout_items)), ); - let actual = tokio_test::block_on(async { session.state.lock().await.history.contents() }); + let actual = tokio_test::block_on(async { session.state.lock().await.history_snapshot() }); assert_eq!(expected, actual); } @@ -3507,21 +3556,22 @@ mod tests { is_review_mode: false, final_output_json_schema: None, }; - let session = Session { - conversation_id, - tx_event, + let services = SessionServices { mcp_connection_manager: McpConnectionManager::default(), session_manager: ExecSessionManager::default(), unified_exec_manager: UnifiedExecSessionManager::default(), notifier: UserNotifier::default(), rollout: Mutex::new(None), - state: Mutex::new(State { - history: ConversationHistory::new(), - ..Default::default() - }), codex_linux_sandbox_exe: None, user_shell: shell::Shell::Unknown, show_raw_agent_reasoning: config.show_raw_agent_reasoning, + }; + let session = Session { + conversation_id, + tx_event, + state: Mutex::new(SessionState::new()), + active_turn: Mutex::new(None), + services, next_internal_sub_id: AtomicU64::new(0), }; (session, turn_context) diff --git a/codex-rs/core/src/codex/compact.rs b/codex-rs/core/src/codex/compact.rs index 8f213d4e7e..4facd45d5d 100644 --- a/codex-rs/core/src/codex/compact.rs +++ b/codex-rs/core/src/codex/compact.rs @@ -151,18 +151,12 @@ async fn run_compact_task_inner( if remove_task_on_completion { sess.remove_task(&sub_id).await; } - let history_snapshot = { - let state = sess.state.lock().await; - state.history.contents() - }; + let history_snapshot = sess.history_snapshot().await; let summary_text = get_last_assistant_message_from_turn(&history_snapshot).unwrap_or_default(); let user_messages = collect_user_messages(&history_snapshot); let initial_context = sess.build_initial_context(turn_context.as_ref()); let new_history = build_compacted_history(initial_context, &user_messages, &summary_text); - { - let mut state = sess.state.lock().await; - state.history.replace(new_history); - } + sess.replace_history(new_history).await; let rollout_item = RolloutItem::Compacted(CompactedItem { message: summary_text.clone(), @@ -270,8 +264,7 @@ async fn drain_to_completed( }; match event { Ok(ResponseEvent::OutputItemDone(item)) => { - let mut state = sess.state.lock().await; - state.history.record_items(std::slice::from_ref(&item)); + sess.record_into_history(std::slice::from_ref(&item)).await; } Ok(ResponseEvent::Completed { .. }) => { return Ok(()); diff --git a/codex-rs/core/src/lib.rs b/codex-rs/core/src/lib.rs index 2db1e6e763..36287c1af1 100644 --- a/codex-rs/core/src/lib.rs +++ b/codex-rs/core/src/lib.rs @@ -76,6 +76,7 @@ pub use rollout::list::ConversationItem; pub use rollout::list::ConversationsPage; pub use rollout::list::Cursor; mod function_tool; +mod state; mod user_notification; pub mod util; diff --git a/codex-rs/core/src/state/mod.rs b/codex-rs/core/src/state/mod.rs new file mode 100644 index 0000000000..927f598146 --- /dev/null +++ b/codex-rs/core/src/state/mod.rs @@ -0,0 +1,8 @@ +mod service; +mod session; +mod turn; + +pub(crate) use service::SessionServices; +pub(crate) use session::SessionState; +pub(crate) use turn::ActiveTurn; +pub(crate) use turn::TurnState; diff --git a/codex-rs/core/src/state/service.rs b/codex-rs/core/src/state/service.rs new file mode 100644 index 0000000000..a67b9dda93 --- /dev/null +++ b/codex-rs/core/src/state/service.rs @@ -0,0 +1,18 @@ +use crate::RolloutRecorder; +use crate::exec_command::ExecSessionManager; +use crate::mcp_connection_manager::McpConnectionManager; +use crate::unified_exec::UnifiedExecSessionManager; +use crate::user_notification::UserNotifier; +use std::path::PathBuf; +use tokio::sync::Mutex; + +pub(crate) struct SessionServices { + pub(crate) mcp_connection_manager: McpConnectionManager, + pub(crate) session_manager: ExecSessionManager, + pub(crate) unified_exec_manager: UnifiedExecSessionManager, + pub(crate) notifier: UserNotifier, + pub(crate) rollout: Mutex>, + pub(crate) codex_linux_sandbox_exe: Option, + pub(crate) user_shell: crate::shell::Shell, + pub(crate) show_raw_agent_reasoning: bool, +} diff --git a/codex-rs/core/src/state/session.rs b/codex-rs/core/src/state/session.rs new file mode 100644 index 0000000000..f8afbdeccf --- /dev/null +++ b/codex-rs/core/src/state/session.rs @@ -0,0 +1,82 @@ +//! Session-wide mutable state. + +use std::collections::HashSet; + +use codex_protocol::models::ResponseItem; + +use crate::codex::AgentTask; +use crate::conversation_history::ConversationHistory; +use crate::protocol::RateLimitSnapshot; +use crate::protocol::TokenUsage; +use crate::protocol::TokenUsageInfo; + +/// Persistent, session-scoped state previously stored directly on `Session`. +#[derive(Default)] +pub(crate) struct SessionState { + pub(crate) approved_commands: HashSet>, + pub(crate) current_task: Option, + pub(crate) history: ConversationHistory, + pub(crate) token_info: Option, + pub(crate) latest_rate_limits: Option, +} + +impl SessionState { + /// Create a new session state mirroring previous `State::default()` semantics. + pub(crate) fn new() -> Self { + Self { + history: ConversationHistory::new(), + ..Default::default() + } + } + + // History helpers + pub(crate) fn record_items(&mut self, items: I) + where + I: IntoIterator, + I::Item: std::ops::Deref, + { + self.history.record_items(items) + } + + pub(crate) fn history_snapshot(&self) -> Vec { + self.history.contents() + } + + pub(crate) fn replace_history(&mut self, items: Vec) { + self.history.replace(items); + } + + // Approved command helpers + pub(crate) fn add_approved_command(&mut self, cmd: Vec) { + self.approved_commands.insert(cmd); + } + + pub(crate) fn approved_commands_ref(&self) -> &HashSet> { + &self.approved_commands + } + + // Token/rate limit helpers + pub(crate) fn update_token_info_from_usage( + &mut self, + usage: &TokenUsage, + model_context_window: Option, + ) { + self.token_info = TokenUsageInfo::new_or_append( + &self.token_info, + &Some(usage.clone()), + model_context_window, + ); + } + + pub(crate) fn set_rate_limits(&mut self, snapshot: RateLimitSnapshot) { + self.latest_rate_limits = Some(snapshot); + } + + pub(crate) fn token_info_and_rate_limits( + &self, + ) -> (Option, Option) { + (self.token_info.clone(), self.latest_rate_limits.clone()) + } + + // Pending input/approval moved to TurnState. +} diff --git a/codex-rs/core/src/state/turn.rs b/codex-rs/core/src/state/turn.rs new file mode 100644 index 0000000000..b49c86b546 --- /dev/null +++ b/codex-rs/core/src/state/turn.rs @@ -0,0 +1,60 @@ +//! Turn-scoped state and active turn metadata scaffolding. + +use std::collections::HashMap; +use std::sync::Arc; +use tokio::sync::Mutex; + +use codex_protocol::models::ResponseInputItem; +use tokio::sync::oneshot; + +use crate::protocol::ReviewDecision; + +/// Metadata about the currently running turn. +#[derive(Default)] +pub(crate) struct ActiveTurn { + pub(crate) sub_id: String, + pub(crate) turn_state: Arc>, +} + +/// Mutable state for a single turn. +#[derive(Default)] +pub(crate) struct TurnState { + pending_approvals: HashMap>, + pending_input: Vec, +} + +impl TurnState { + pub(crate) fn insert_pending_approval( + &mut self, + key: String, + tx: oneshot::Sender, + ) -> Option> { + self.pending_approvals.insert(key, tx) + } + + pub(crate) fn remove_pending_approval( + &mut self, + key: &str, + ) -> Option> { + self.pending_approvals.remove(key) + } + + pub(crate) fn clear_pending(&mut self) { + self.pending_approvals.clear(); + self.pending_input.clear(); + } + + pub(crate) fn push_pending_input(&mut self, input: ResponseInputItem) { + self.pending_input.push(input); + } + + pub(crate) fn take_pending_input(&mut self) -> Vec { + if self.pending_input.is_empty() { + Vec::with_capacity(0) + } else { + let mut ret = Vec::new(); + std::mem::swap(&mut ret, &mut self.pending_input); + ret + } + } +} From e363dac2493c2a9d9d1969ee5bbaf069c0d8894c Mon Sep 17 00:00:00 2001 From: Ahmed Ibrahim Date: Thu, 25 Sep 2025 08:38:50 -0700 Subject: [PATCH 4/7] revamp /status (#4196) image --- codex-rs/tui/src/history_cell.rs | 384 ++---------------- codex-rs/tui/src/lib.rs | 1 + ...s_snapshot_includes_reasoning_details.snap | 19 + ...snapshot_truncates_in_narrow_terminal.snap | 19 + codex-rs/tui/src/status/account.rs | 8 + codex-rs/tui/src/status/card.rs | 280 +++++++++++++ codex-rs/tui/src/status/format.rs | 144 +++++++ codex-rs/tui/src/status/helpers.rs | 180 ++++++++ codex-rs/tui/src/status/mod.rs | 12 + codex-rs/tui/src/status/rate_limits.rs | 117 ++++++ ...s_snapshot_includes_reasoning_details.snap | 19 + ...snapshot_truncates_in_narrow_terminal.snap | 19 + codex-rs/tui/src/status/tests.rs | 183 +++++++++ 13 files changed, 1044 insertions(+), 341 deletions(-) create mode 100644 codex-rs/tui/src/snapshots/codex_tui__status__tests__status_snapshot_includes_reasoning_details.snap create mode 100644 codex-rs/tui/src/snapshots/codex_tui__status__tests__status_snapshot_truncates_in_narrow_terminal.snap create mode 100644 codex-rs/tui/src/status/account.rs create mode 100644 codex-rs/tui/src/status/card.rs create mode 100644 codex-rs/tui/src/status/format.rs create mode 100644 codex-rs/tui/src/status/helpers.rs create mode 100644 codex-rs/tui/src/status/mod.rs create mode 100644 codex-rs/tui/src/status/rate_limits.rs create mode 100644 codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_includes_reasoning_details.snap create mode 100644 codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_truncates_in_narrow_terminal.snap create mode 100644 codex-rs/tui/src/status/tests.rs diff --git a/codex-rs/tui/src/history_cell.rs b/codex-rs/tui/src/history_cell.rs index a63da9ead1..3ae0d177b4 100644 --- a/codex-rs/tui/src/history_cell.rs +++ b/codex-rs/tui/src/history_cell.rs @@ -5,36 +5,26 @@ use crate::markdown::append_markdown; use crate::render::line_utils::line_to_static; use crate::render::line_utils::prefix_lines; use crate::render::line_utils::push_owned_lines; +pub(crate) use crate::status::RateLimitSnapshotDisplay; +pub(crate) use crate::status::new_status_output; +pub(crate) use crate::status::rate_limit_snapshot_display; use crate::text_formatting::format_and_truncate_tool_result; use crate::ui_consts::LIVE_PREFIX_COLS; use crate::wrapping::RtOptions; use crate::wrapping::word_wrap_line; use crate::wrapping::word_wrap_lines; use base64::Engine; -use chrono::DateTime; -use chrono::Duration as ChronoDuration; -use chrono::Local; use codex_ansi_escape::ansi_escape_line; -use codex_common::create_config_summary_entries; use codex_common::elapsed::format_duration; -use codex_core::auth::get_auth_file; -use codex_core::auth::try_read_auth_json; use codex_core::config::Config; use codex_core::config_types::ReasoningSummaryFormat; use codex_core::plan_tool::PlanItemArg; use codex_core::plan_tool::StepStatus; use codex_core::plan_tool::UpdatePlanArgs; -use codex_core::project_doc::discover_project_doc_paths; use codex_core::protocol::FileChange; use codex_core::protocol::McpInvocation; -use codex_core::protocol::RateLimitSnapshot; -use codex_core::protocol::RateLimitWindow; -use codex_core::protocol::SandboxPolicy; use codex_core::protocol::SessionConfiguredEvent; -use codex_core::protocol::TokenUsage; use codex_core::protocol_config_types::ReasoningEffort as ReasoningEffortConfig; -use codex_protocol::mcp_protocol::ConversationId; -use codex_protocol::num_format::format_with_separators; use codex_protocol::parse_command::ParsedCommand; use image::DynamicImage; use image::ImageReader; @@ -51,7 +41,6 @@ use ratatui::widgets::WidgetRef; use ratatui::widgets::Wrap; use std::any::Any; use std::collections::HashMap; -use std::convert::TryFrom; use std::io::Cursor; use std::path::Path; use std::path::PathBuf; @@ -60,10 +49,6 @@ use std::time::Instant; use tracing::error; use unicode_width::UnicodeWidthStr; -const STATUS_LIMIT_BAR_SEGMENTS: usize = 20; -const STATUS_LIMIT_BAR_FILLED: &str = "โ–ˆ"; -const STATUS_LIMIT_BAR_EMPTY: &str = " "; - #[derive(Clone, Debug)] pub(crate) struct CommandOutput { pub(crate) exit_code: i32, @@ -229,6 +214,12 @@ pub(crate) struct PlainHistoryCell { lines: Vec>, } +impl PlainHistoryCell { + pub(crate) fn new(lines: Vec>) -> Self { + Self { lines } + } +} + impl HistoryCell for PlainHistoryCell { fn display_lines(&self, _width: u16) -> Vec> { self.lines.clone() @@ -637,9 +628,9 @@ impl HistoryCell for CompletedMcpToolCallWithImageOutput { } const TOOL_CALL_MAX_LINES: usize = 5; -const SESSION_HEADER_MAX_INNER_WIDTH: usize = 56; // Just an eyeballed value +pub(crate) const SESSION_HEADER_MAX_INNER_WIDTH: usize = 56; // Just an eyeballed value -fn card_inner_width(width: u16, max_inner_width: usize) -> Option { +pub(crate) fn card_inner_width(width: u16, max_inner_width: usize) -> Option { if width < 4 { return None; } @@ -647,8 +638,28 @@ fn card_inner_width(width: u16, max_inner_width: usize) -> Option { Some(inner_width) } -fn with_border(lines: Vec>) -> Vec> { - let content_width = lines +/// Render `lines` inside a border sized to the widest span in the content. +pub(crate) fn with_border(lines: Vec>) -> Vec> { + with_border_internal(lines, None) +} + +/// Render `lines` inside a border whose inner width is at least `inner_width`. +/// +/// This is useful when callers have already clamped their content to a +/// specific width and want the border math centralized here instead of +/// duplicating padding logic in the TUI widgets themselves. +pub(crate) fn with_border_with_inner_width( + lines: Vec>, + inner_width: usize, +) -> Vec> { + with_border_internal(lines, Some(inner_width)) +} + +fn with_border_internal( + lines: Vec>, + forced_inner_width: Option, +) -> Vec> { + let max_line_width = lines .iter() .map(|line| { line.iter() @@ -657,6 +668,9 @@ fn with_border(lines: Vec>) -> Vec> { }) .max() .unwrap_or(0); + let content_width = forced_inner_width + .unwrap_or(max_line_width) + .max(max_line_width); let mut out = Vec::with_capacity(lines.len() + 2); let border_inner_width = content_width + 2; @@ -683,30 +697,10 @@ fn with_border(lines: Vec>) -> Vec> { out } -fn title_case(s: &str) -> String { - if s.is_empty() { - return String::new(); - } - let mut chars = s.chars(); - let first = match chars.next() { - Some(c) => c, - None => return String::new(), - }; - let rest: String = chars.as_str().to_ascii_lowercase(); - first.to_uppercase().collect::() + &rest -} - -fn pretty_provider_name(id: &str) -> String { - if id.eq_ignore_ascii_case("openai") { - "OpenAI".to_string() - } else { - title_case(id) - } -} /// Return the emoji followed by a hair space (U+200A). /// Using only the hair space avoids excessive padding after the emoji while /// still providing a small visual gap across terminals. -fn padded_emoji(emoji: &str) -> String { +pub(crate) fn padded_emoji(emoji: &str) -> String { format!("{emoji}\u{200A}") } @@ -925,6 +919,12 @@ pub(crate) struct CompositeHistoryCell { parts: Vec>, } +impl CompositeHistoryCell { + pub(crate) fn new(parts: Vec>) -> Self { + Self { parts } + } +} + impl HistoryCell for CompositeHistoryCell { fn display_lines(&self, width: u16) -> Vec> { let mut out: Vec> = Vec::new(); @@ -1184,228 +1184,6 @@ pub(crate) fn new_warning_event(message: String) -> PlainHistoryCell { } } -#[derive(Debug, Clone)] -pub(crate) struct RateLimitWindowDisplay { - pub used_percent: f64, - pub resets_at: Option, -} - -impl RateLimitWindowDisplay { - fn from_window(window: &RateLimitWindow, captured_at: DateTime) -> Self { - let resets_at = window - .resets_in_seconds - .and_then(|seconds| i64::try_from(seconds).ok()) - .and_then(|secs| captured_at.checked_add_signed(ChronoDuration::seconds(secs))) - .map(|dt| dt.format("%b %-d, %Y %-I:%M %p").to_string()); - - Self { - used_percent: window.used_percent, - resets_at, - } - } -} - -#[derive(Debug, Clone)] -pub(crate) struct RateLimitSnapshotDisplay { - pub primary: Option, - pub secondary: Option, -} - -pub(crate) fn rate_limit_snapshot_display( - snapshot: &RateLimitSnapshot, - captured_at: DateTime, -) -> RateLimitSnapshotDisplay { - RateLimitSnapshotDisplay { - primary: snapshot - .primary - .as_ref() - .map(|window| RateLimitWindowDisplay::from_window(window, captured_at)), - secondary: snapshot - .secondary - .as_ref() - .map(|window| RateLimitWindowDisplay::from_window(window, captured_at)), - } -} - -pub(crate) fn new_status_output( - config: &Config, - usage: &TokenUsage, - session_id: &Option, - rate_limits: Option<&RateLimitSnapshotDisplay>, -) -> PlainHistoryCell { - let mut lines: Vec> = Vec::new(); - lines.push("/status".magenta().into()); - - let config_entries = create_config_summary_entries(config); - let lookup = |k: &str| -> String { - config_entries - .iter() - .find(|(key, _)| *key == k) - .map(|(_, v)| v.clone()) - .unwrap_or_default() - }; - - // ๐Ÿ“‚ Workspace - lines.push(vec![padded_emoji("๐Ÿ“‚").into(), "Workspace".bold()].into()); - // Path (home-relative, e.g., ~/code/project) - let cwd_str = match relativize_to_home(&config.cwd) { - Some(rel) if !rel.as_os_str().is_empty() => { - let sep = std::path::MAIN_SEPARATOR; - format!("~{sep}{}", rel.display()) - } - Some(_) => "~".to_string(), - None => config.cwd.display().to_string(), - }; - lines.push(vec![" โ€ข Path: ".into(), cwd_str.into()].into()); - // Approval mode (as-is) - lines.push(vec![" โ€ข Approval Mode: ".into(), lookup("approval").into()].into()); - // Sandbox (simplified name only) - let sandbox_name = match &config.sandbox_policy { - SandboxPolicy::DangerFullAccess => "danger-full-access", - SandboxPolicy::ReadOnly => "read-only", - SandboxPolicy::WorkspaceWrite { .. } => "workspace-write", - }; - lines.push(vec![" โ€ข Sandbox: ".into(), sandbox_name.into()].into()); - - // AGENTS.md files discovered via core's project_doc logic - let agents_list = { - match discover_project_doc_paths(config) { - Ok(paths) => { - let mut rels: Vec = Vec::new(); - for p in paths { - let display = if let Some(parent) = p.parent() { - if parent == config.cwd { - "AGENTS.md".to_string() - } else { - let mut cur = config.cwd.as_path(); - let mut ups = 0usize; - let mut reached = false; - while let Some(c) = cur.parent() { - if cur == parent { - reached = true; - break; - } - cur = c; - ups += 1; - } - if reached { - let up = format!("..{}", std::path::MAIN_SEPARATOR); - format!("{}AGENTS.md", up.repeat(ups)) - } else if let Ok(stripped) = p.strip_prefix(&config.cwd) { - stripped.display().to_string() - } else { - p.display().to_string() - } - } - } else { - p.display().to_string() - }; - rels.push(display); - } - rels - } - Err(_) => Vec::new(), - } - }; - if agents_list.is_empty() { - lines.push(" โ€ข AGENTS files: (none)".into()); - } else { - lines.push(vec![" โ€ข AGENTS files: ".into(), agents_list.join(", ").into()].into()); - } - lines.push("".into()); - - // ๐Ÿ‘ค Account (only if ChatGPT tokens exist), shown under the first block - let auth_file = get_auth_file(&config.codex_home); - let auth = try_read_auth_json(&auth_file).ok(); - let is_chatgpt_auth = auth - .as_ref() - .and_then(|auth| auth.tokens.as_ref()) - .is_some(); - if is_chatgpt_auth - && let Some(auth) = auth.as_ref() - && let Some(tokens) = auth.tokens.clone() - { - lines.push(vec![padded_emoji("๐Ÿ‘ค").into(), "Account".bold()].into()); - lines.push(" โ€ข Signed in with ChatGPT".into()); - - let info = tokens.id_token; - if let Some(email) = &info.email { - lines.push(vec![" โ€ข Login: ".into(), email.clone().into()].into()); - } - - match auth.openai_api_key.as_deref() { - Some(key) if !key.is_empty() => { - lines.push(" โ€ข Using API key. Run codex login to use ChatGPT plan".into()); - } - _ => { - let plan_text = info - .get_chatgpt_plan_type() - .map(|s| title_case(&s)) - .unwrap_or_else(|| "Unknown".to_string()); - lines.push(vec![" โ€ข Plan: ".into(), plan_text.into()].into()); - } - } - - lines.push("".into()); - } - - // ๐Ÿง  Model - lines.push(vec![padded_emoji("๐Ÿง ").into(), "Model".bold()].into()); - lines.push(vec![" โ€ข Name: ".into(), config.model.clone().into()].into()); - let provider_disp = pretty_provider_name(&config.model_provider_id); - lines.push(vec![" โ€ข Provider: ".into(), provider_disp.into()].into()); - // Only show Reasoning fields if present in config summary - let reff = lookup("reasoning effort"); - if !reff.is_empty() { - lines.push(vec![" โ€ข Reasoning Effort: ".into(), title_case(&reff).into()].into()); - } - let rsum = lookup("reasoning summaries"); - if !rsum.is_empty() { - lines.push(vec![" โ€ข Reasoning Summaries: ".into(), title_case(&rsum).into()].into()); - } - - lines.push("".into()); - - // ๐Ÿ’ป Client - let cli_version = crate::version::CODEX_CLI_VERSION; - lines.push(vec![padded_emoji("๐Ÿ’ป").into(), "Client".bold()].into()); - lines.push(vec![" โ€ข CLI Version: ".into(), cli_version.into()].into()); - lines.push("".into()); - - // ๐Ÿ“Š Token Usage - lines.push(vec!["๐Ÿ“Š ".into(), "Token Usage".bold()].into()); - if let Some(session_id) = session_id { - lines.push(vec![" โ€ข Session ID: ".into(), session_id.to_string().into()].into()); - } - // Input: [+ cached] - let mut input_line_spans: Vec> = vec![ - " โ€ข Input: ".into(), - format_with_separators(usage.non_cached_input()).into(), - ]; - if usage.cached_input_tokens > 0 { - let cached = usage.cached_input_tokens; - input_line_spans.push(format!(" (+ {cached} cached)").into()); - } - lines.push(Line::from(input_line_spans)); - // Output: - lines.push(Line::from(vec![ - " โ€ข Output: ".into(), - format_with_separators(usage.output_tokens).into(), - ])); - // Total: - lines.push(Line::from(vec![ - " โ€ข Total: ".into(), - format_with_separators(usage.blended_total()).into(), - ])); - - if is_chatgpt_auth { - lines.push("".into()); - lines.extend(build_status_limit_lines(rate_limits)); - } - - PlainHistoryCell { lines } -} - /// Render a summary of configured MCP servers from the current `Config`. pub(crate) fn empty_mcp_output() -> PlainHistoryCell { let lines: Vec> = vec![ @@ -1760,82 +1538,6 @@ fn format_mcp_invocation<'a>(invocation: McpInvocation) -> Line<'a> { invocation_spans.into() } -fn build_status_limit_lines(snapshot: Option<&RateLimitSnapshotDisplay>) -> Vec> { - let mut lines: Vec> = - vec![vec![padded_emoji("โฑ๏ธ").into(), "Usage Limits".bold()].into()]; - - match snapshot { - Some(snapshot) => { - let mut windows: Vec<(&str, &RateLimitWindowDisplay)> = Vec::new(); - if let Some(primary) = snapshot.primary.as_ref() { - windows.push(("5h limit", primary)); - } - if let Some(secondary) = snapshot.secondary.as_ref() { - windows.push(("Weekly limit", secondary)); - } - - if windows.is_empty() { - lines.push(" โ€ข No rate limit data available.".into()); - } else { - let label_width = windows - .iter() - .map(|(label, _)| UnicodeWidthStr::width(*label)) - .max() - .unwrap_or(0); - - for (label, window) in windows { - lines.push(build_status_limit_line( - label, - window.used_percent, - label_width, - )); - if let Some(resets_at) = window.resets_at.as_deref() { - lines.push(build_status_reset_line(resets_at)); - } - } - } - } - None => lines.push(" โ€ข Send a message to load usage data.".into()), - } - - lines -} - -fn build_status_limit_line(label: &str, percent_used: f64, label_width: usize) -> Line<'static> { - let clamped_percent = percent_used.clamp(0.0, 100.0); - let progress = render_status_limit_progress_bar(clamped_percent); - let summary = format_status_limit_summary(clamped_percent); - - let mut spans: Vec> = Vec::with_capacity(5); - let padded_label = format!("{label: Line<'static> { - vec![" ".into(), format!("Resets at: {resets_at}").dim()].into() -} - -fn render_status_limit_progress_bar(percent_used: f64) -> String { - let ratio = (percent_used / 100.0).clamp(0.0, 1.0); - let filled = (ratio * STATUS_LIMIT_BAR_SEGMENTS as f64).round() as usize; - let filled = filled.min(STATUS_LIMIT_BAR_SEGMENTS); - let empty = STATUS_LIMIT_BAR_SEGMENTS.saturating_sub(filled); - format!( - "[{}{}]", - STATUS_LIMIT_BAR_FILLED.repeat(filled), - STATUS_LIMIT_BAR_EMPTY.repeat(empty) - ) -} - -fn format_status_limit_summary(percent_used: f64) -> String { - format!("{percent_used:.0}% used") -} - #[cfg(test)] mod tests { use super::*; diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index 0791a1cc7e..1453971ca4 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -60,6 +60,7 @@ mod resume_picker; mod session_log; mod shimmer; mod slash_command; +mod status; mod status_indicator_widget; mod streaming; mod text_formatting; diff --git a/codex-rs/tui/src/snapshots/codex_tui__status__tests__status_snapshot_includes_reasoning_details.snap b/codex-rs/tui/src/snapshots/codex_tui__status__tests__status_snapshot_includes_reasoning_details.snap new file mode 100644 index 0000000000..1615305f99 --- /dev/null +++ b/codex-rs/tui/src/snapshots/codex_tui__status__tests__status_snapshot_includes_reasoning_details.snap @@ -0,0 +1,19 @@ +--- +source: tui/src/status.rs +expression: sanitized +--- +/status + +โ•ญโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฎ +โ”‚ >_ OpenAI Codex (v0.0.0) โ”‚ +โ”‚ โ”‚ +โ”‚ Model : gpt-5-codex (reasoning high, summaries detailed) โ”‚ +โ”‚ Directory : /workspace/tests โ”‚ +โ”‚ Approval : on-request โ”‚ +โ”‚ Sandbox : workspace-write โ”‚ +โ”‚ Agents.md : โ”‚ +โ”‚ โ”‚ +โ”‚ Token Usage : 1.9K total (1K input + 900 output) โ”‚ +โ”‚ 5h limit : [โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–‘โ–‘โ–‘โ–‘โ–‘] 72% used ยท resets 03:14 โ”‚ +โ”‚ Weekly limit : [โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘] 45% used ยท resets 03:24 โ”‚ +โ•ฐโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฏ diff --git a/codex-rs/tui/src/snapshots/codex_tui__status__tests__status_snapshot_truncates_in_narrow_terminal.snap b/codex-rs/tui/src/snapshots/codex_tui__status__tests__status_snapshot_truncates_in_narrow_terminal.snap new file mode 100644 index 0000000000..44cad15213 --- /dev/null +++ b/codex-rs/tui/src/snapshots/codex_tui__status__tests__status_snapshot_truncates_in_narrow_terminal.snap @@ -0,0 +1,19 @@ +--- +source: tui/src/status.rs +expression: sanitized +--- +/status + +โ•ญโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฎ +โ”‚ >_ OpenAI Codex (v0.0.0) โ”‚ +โ”‚ โ”‚ +โ”‚ Model : gpt-5-codex (reasoning high โ”‚ +โ”‚ Directory : /workspace/tests โ”‚ +โ”‚ Approval : on-request โ”‚ +โ”‚ Sandbox : read-only โ”‚ +โ”‚ Agents.md : โ”‚ +โ”‚ โ”‚ +โ”‚ Token Usage : 1.9K total (1K input + 900 โ”‚ +โ”‚ 5h limit : [โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–‘โ–‘โ–‘โ–‘โ–‘] 72% โ”‚ +โ”‚ ยท resets 03:14 โ”‚ +โ•ฐโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฏ diff --git a/codex-rs/tui/src/status/account.rs b/codex-rs/tui/src/status/account.rs new file mode 100644 index 0000000000..23255f4379 --- /dev/null +++ b/codex-rs/tui/src/status/account.rs @@ -0,0 +1,8 @@ +#[derive(Debug, Clone)] +pub(crate) enum StatusAccountDisplay { + ChatGpt { + email: Option, + plan: Option, + }, + ApiKey, +} diff --git a/codex-rs/tui/src/status/card.rs b/codex-rs/tui/src/status/card.rs new file mode 100644 index 0000000000..a977240bd1 --- /dev/null +++ b/codex-rs/tui/src/status/card.rs @@ -0,0 +1,280 @@ +use crate::history_cell::CompositeHistoryCell; +use crate::history_cell::HistoryCell; +use crate::history_cell::PlainHistoryCell; +use crate::history_cell::with_border_with_inner_width; +use crate::version::CODEX_CLI_VERSION; +use codex_common::create_config_summary_entries; +use codex_core::config::Config; +use codex_core::protocol::SandboxPolicy; +use codex_core::protocol::TokenUsage; +use codex_protocol::mcp_protocol::ConversationId; +use ratatui::prelude::*; +use ratatui::style::Stylize; +use std::collections::BTreeSet; +use std::path::PathBuf; + +use super::account::StatusAccountDisplay; +use super::format::FieldFormatter; +use super::format::line_display_width; +use super::format::push_label; +use super::format::truncate_line_to_width; +use super::helpers::compose_account_display; +use super::helpers::compose_agents_summary; +use super::helpers::compose_model_display; +use super::helpers::format_directory_display; +use super::helpers::format_tokens_compact; +use super::rate_limits::RESET_BULLET; +use super::rate_limits::RateLimitSnapshotDisplay; +use super::rate_limits::StatusRateLimitData; +use super::rate_limits::compose_rate_limit_data; +use super::rate_limits::format_status_limit_summary; +use super::rate_limits::render_status_limit_progress_bar; + +#[derive(Debug, Clone)] +pub(crate) struct StatusTokenUsageData { + total: u64, + input: u64, + output: u64, +} + +#[derive(Debug)] +struct StatusHistoryCell { + model_name: String, + model_details: Vec, + directory: PathBuf, + approval: String, + sandbox: String, + agents_summary: String, + account: Option, + session_id: Option, + token_usage: StatusTokenUsageData, + rate_limits: StatusRateLimitData, +} + +pub(crate) fn new_status_output( + config: &Config, + usage: &TokenUsage, + session_id: &Option, + rate_limits: Option<&RateLimitSnapshotDisplay>, +) -> CompositeHistoryCell { + let command = PlainHistoryCell::new(vec!["/status".magenta().into()]); + let card = StatusHistoryCell::new(config, usage, session_id, rate_limits); + + CompositeHistoryCell::new(vec![Box::new(command), Box::new(card)]) +} + +impl StatusHistoryCell { + fn new( + config: &Config, + usage: &TokenUsage, + session_id: &Option, + rate_limits: Option<&RateLimitSnapshotDisplay>, + ) -> Self { + let config_entries = create_config_summary_entries(config); + let (model_name, model_details) = compose_model_display(config, &config_entries); + let approval = config_entries + .iter() + .find(|(k, _)| *k == "approval") + .map(|(_, v)| v.clone()) + .unwrap_or_else(|| "".to_string()); + let sandbox = match &config.sandbox_policy { + SandboxPolicy::DangerFullAccess => "danger-full-access".to_string(), + SandboxPolicy::ReadOnly => "read-only".to_string(), + SandboxPolicy::WorkspaceWrite { .. } => "workspace-write".to_string(), + }; + let agents_summary = compose_agents_summary(config); + let account = compose_account_display(config); + let session_id = session_id.as_ref().map(std::string::ToString::to_string); + let token_usage = StatusTokenUsageData { + total: usage.blended_total(), + input: usage.non_cached_input(), + output: usage.output_tokens, + }; + let rate_limits = compose_rate_limit_data(rate_limits); + + Self { + model_name, + model_details, + directory: config.cwd.clone(), + approval, + sandbox, + agents_summary, + account, + session_id, + token_usage, + rate_limits, + } + } + + fn token_usage_spans(&self) -> Vec> { + let total_fmt = format_tokens_compact(self.token_usage.total); + let input_fmt = format_tokens_compact(self.token_usage.input); + let output_fmt = format_tokens_compact(self.token_usage.output); + + vec![ + Span::from(total_fmt), + Span::from(" total "), + Span::from(" (").dim(), + Span::from(input_fmt).dim(), + Span::from(" input").dim(), + Span::from(" + ").dim(), + Span::from(output_fmt).dim(), + Span::from(" output").dim(), + Span::from(")").dim(), + ] + } + + fn rate_limit_lines( + &self, + available_inner_width: usize, + formatter: &FieldFormatter, + ) -> Vec> { + match &self.rate_limits { + StatusRateLimitData::Available(rows_data) => { + if rows_data.is_empty() { + return vec![ + formatter.line("Limits", vec![Span::from("data not available yet").dim()]), + ]; + } + + let mut lines = Vec::with_capacity(rows_data.len() * 2); + + for row in rows_data { + let value_spans = vec![ + Span::from(render_status_limit_progress_bar(row.percent_used)), + Span::from(" "), + Span::from(format_status_limit_summary(row.percent_used)), + ]; + let base_spans = formatter.full_spans(row.label, value_spans); + let base_line = Line::from(base_spans.clone()); + + if let Some(resets_at) = row.resets_at.as_ref() { + let resets_span = + Span::from(format!("{RESET_BULLET} resets {resets_at}")).dim(); + let mut inline_spans = base_spans.clone(); + inline_spans.push(Span::from(" ").dim()); + inline_spans.push(resets_span.clone()); + + if line_display_width(&Line::from(inline_spans.clone())) + <= available_inner_width + { + lines.push(Line::from(inline_spans)); + } else { + lines.push(base_line); + lines.push(formatter.continuation(vec![resets_span])); + } + } else { + lines.push(base_line); + } + } + + lines + } + StatusRateLimitData::Missing => { + vec![formatter.line("Limits", vec![Span::from("data not available yet").dim()])] + } + } + } + + fn collect_rate_limit_labels( + &self, + seen: &mut BTreeSet<&'static str>, + labels: &mut Vec<&'static str>, + ) { + match &self.rate_limits { + StatusRateLimitData::Available(rows) => { + if rows.is_empty() { + push_label(labels, seen, "Limits"); + } else { + for row in rows { + push_label(labels, seen, row.label); + } + } + } + StatusRateLimitData::Missing => push_label(labels, seen, "Limits"), + } + } +} + +impl HistoryCell for StatusHistoryCell { + fn display_lines(&self, width: u16) -> Vec> { + let mut lines: Vec> = Vec::new(); + lines.push(Line::from(vec![ + Span::from(format!("{}>_ ", FieldFormatter::INDENT)).dim(), + Span::from("OpenAI Codex").bold(), + Span::from(" ").dim(), + Span::from(format!("(v{CODEX_CLI_VERSION})")).dim(), + ])); + lines.push(Line::from(Vec::>::new())); + + let available_inner_width = usize::from(width.saturating_sub(4)); + if available_inner_width == 0 { + return Vec::new(); + } + + let account_value = self.account.as_ref().map(|account| match account { + StatusAccountDisplay::ChatGpt { email, plan } => match (email, plan) { + (Some(email), Some(plan)) => format!("{email} ({plan})"), + (Some(email), None) => email.clone(), + (None, Some(plan)) => plan.clone(), + (None, None) => "ChatGPT".to_string(), + }, + StatusAccountDisplay::ApiKey => { + "API key configured (run codex login to use ChatGPT)".to_string() + } + }); + + let mut labels: Vec<&'static str> = + vec!["Model", "Directory", "Approval", "Sandbox", "Agents.md"]; + let mut seen: BTreeSet<&'static str> = labels.iter().copied().collect(); + + if account_value.is_some() { + push_label(&mut labels, &mut seen, "Account"); + } + if self.session_id.is_some() { + push_label(&mut labels, &mut seen, "Session"); + } + push_label(&mut labels, &mut seen, "Token Usage"); + self.collect_rate_limit_labels(&mut seen, &mut labels); + + let formatter = FieldFormatter::from_labels(labels.iter().copied()); + let value_width = formatter.value_width(available_inner_width); + + let mut model_spans = vec![Span::from(self.model_name.clone())]; + if !self.model_details.is_empty() { + model_spans.push(Span::from(" (").dim()); + model_spans.push(Span::from(self.model_details.join(", ")).dim()); + model_spans.push(Span::from(")").dim()); + } + + let directory_value = format_directory_display(&self.directory, Some(value_width)); + + lines.push(formatter.line("Model", model_spans)); + lines.push(formatter.line("Directory", vec![Span::from(directory_value)])); + lines.push(formatter.line("Approval", vec![Span::from(self.approval.clone())])); + lines.push(formatter.line("Sandbox", vec![Span::from(self.sandbox.clone())])); + lines.push(formatter.line("Agents.md", vec![Span::from(self.agents_summary.clone())])); + + if let Some(account_value) = account_value { + lines.push(formatter.line("Account", vec![Span::from(account_value)])); + } + + if let Some(session) = self.session_id.as_ref() { + lines.push(formatter.line("Session", vec![Span::from(session.clone())])); + } + + lines.push(Line::from(Vec::>::new())); + lines.push(formatter.line("Token Usage", self.token_usage_spans())); + + lines.extend(self.rate_limit_lines(available_inner_width, &formatter)); + + let content_width = lines.iter().map(line_display_width).max().unwrap_or(0); + let inner_width = content_width.min(available_inner_width); + let truncated_lines: Vec> = lines + .into_iter() + .map(|line| truncate_line_to_width(line, inner_width)) + .collect(); + + with_border_with_inner_width(truncated_lines, inner_width) + } +} diff --git a/codex-rs/tui/src/status/format.rs b/codex-rs/tui/src/status/format.rs new file mode 100644 index 0000000000..556e5bd1b5 --- /dev/null +++ b/codex-rs/tui/src/status/format.rs @@ -0,0 +1,144 @@ +use ratatui::prelude::*; +use ratatui::style::Stylize; +use std::collections::BTreeSet; +use unicode_width::UnicodeWidthChar; +use unicode_width::UnicodeWidthStr; + +#[derive(Debug, Clone)] +pub(crate) struct FieldFormatter { + indent: &'static str, + label_width: usize, + value_offset: usize, + value_indent: String, +} + +impl FieldFormatter { + pub(crate) const INDENT: &'static str = " "; + + pub(crate) fn from_labels(labels: impl IntoIterator) -> Self { + let label_width = labels + .into_iter() + .map(UnicodeWidthStr::width) + .max() + .unwrap_or(0); + let indent_width = UnicodeWidthStr::width(Self::INDENT); + let value_offset = indent_width + label_width + 1 + 3; + + Self { + indent: Self::INDENT, + label_width, + value_offset, + value_indent: " ".repeat(value_offset), + } + } + + pub(crate) fn line( + &self, + label: &'static str, + value_spans: Vec>, + ) -> Line<'static> { + Line::from(self.full_spans(label, value_spans)) + } + + pub(crate) fn continuation(&self, mut spans: Vec>) -> Line<'static> { + let mut all_spans = Vec::with_capacity(spans.len() + 1); + all_spans.push(Span::from(self.value_indent.clone()).dim()); + all_spans.append(&mut spans); + Line::from(all_spans) + } + + pub(crate) fn value_width(&self, available_inner_width: usize) -> usize { + available_inner_width.saturating_sub(self.value_offset) + } + + pub(crate) fn full_spans( + &self, + label: &'static str, + mut value_spans: Vec>, + ) -> Vec> { + let mut spans = Vec::with_capacity(value_spans.len() + 1); + spans.push(self.label_span(label)); + spans.append(&mut value_spans); + spans + } + + fn label_span(&self, label: &str) -> Span<'static> { + let mut buf = String::with_capacity(self.value_offset); + buf.push_str(self.indent); + + buf.push_str(label); + buf.push(':'); + + let label_width = UnicodeWidthStr::width(label); + let padding = 3 + self.label_width.saturating_sub(label_width); + for _ in 0..padding { + buf.push(' '); + } + + Span::from(buf).dim() + } +} + +pub(crate) fn push_label( + labels: &mut Vec<&'static str>, + seen: &mut BTreeSet<&'static str>, + label: &'static str, +) { + if seen.insert(label) { + labels.push(label); + } +} + +pub(crate) fn line_display_width(line: &Line<'static>) -> usize { + line.iter() + .map(|span| UnicodeWidthStr::width(span.content.as_ref())) + .sum() +} + +pub(crate) fn truncate_line_to_width(line: Line<'static>, max_width: usize) -> Line<'static> { + if max_width == 0 { + return Line::from(Vec::>::new()); + } + + let mut used = 0usize; + let mut spans_out: Vec> = Vec::new(); + + for span in line.spans { + let text = span.content.into_owned(); + let style = span.style; + let span_width = UnicodeWidthStr::width(text.as_str()); + + if span_width == 0 { + spans_out.push(Span::styled(text, style)); + continue; + } + + if used >= max_width { + break; + } + + if used + span_width <= max_width { + used += span_width; + spans_out.push(Span::styled(text, style)); + continue; + } + + let mut truncated = String::new(); + for ch in text.chars() { + let ch_width = UnicodeWidthChar::width(ch).unwrap_or(0); + if used + ch_width > max_width { + break; + } + truncated.push(ch); + used += ch_width; + } + + if !truncated.is_empty() { + spans_out.push(Span::styled(truncated, style)); + } + + break; + } + + Line::from(spans_out) +} diff --git a/codex-rs/tui/src/status/helpers.rs b/codex-rs/tui/src/status/helpers.rs new file mode 100644 index 0000000000..46185e1c0f --- /dev/null +++ b/codex-rs/tui/src/status/helpers.rs @@ -0,0 +1,180 @@ +use crate::exec_command::relativize_to_home; +use crate::text_formatting; +use chrono::DateTime; +use chrono::Local; +use codex_core::auth::get_auth_file; +use codex_core::auth::try_read_auth_json; +use codex_core::config::Config; +use codex_core::project_doc::discover_project_doc_paths; +use std::path::Path; +use unicode_width::UnicodeWidthStr; + +use super::account::StatusAccountDisplay; + +pub(crate) fn compose_model_display( + config: &Config, + entries: &[(&str, String)], +) -> (String, Vec) { + let mut details: Vec = Vec::new(); + if let Some((_, effort)) = entries.iter().find(|(k, _)| *k == "reasoning effort") { + details.push(format!("reasoning {}", effort.to_ascii_lowercase())); + } + if let Some((_, summary)) = entries.iter().find(|(k, _)| *k == "reasoning summaries") { + let summary = summary.trim(); + if summary.eq_ignore_ascii_case("none") || summary.eq_ignore_ascii_case("off") { + details.push("summaries off".to_string()); + } else if !summary.is_empty() { + details.push(format!("summaries {}", summary.to_ascii_lowercase())); + } + } + + (config.model.clone(), details) +} + +pub(crate) fn compose_agents_summary(config: &Config) -> String { + match discover_project_doc_paths(config) { + Ok(paths) => { + let mut rels: Vec = Vec::new(); + for p in paths { + let display = if let Some(parent) = p.parent() { + if parent == config.cwd { + "AGENTS.md".to_string() + } else { + let mut cur = config.cwd.as_path(); + let mut ups = 0usize; + let mut reached = false; + while let Some(c) = cur.parent() { + if cur == parent { + reached = true; + break; + } + cur = c; + ups += 1; + } + if reached { + let up = format!("..{}", std::path::MAIN_SEPARATOR); + format!("{}AGENTS.md", up.repeat(ups)) + } else if let Ok(stripped) = p.strip_prefix(&config.cwd) { + stripped.display().to_string() + } else { + p.display().to_string() + } + } + } else { + p.display().to_string() + }; + rels.push(display); + } + if rels.is_empty() { + "".to_string() + } else { + rels.join(", ") + } + } + Err(_) => "".to_string(), + } +} + +pub(crate) fn compose_account_display(config: &Config) -> Option { + let auth_file = get_auth_file(&config.codex_home); + let auth = try_read_auth_json(&auth_file).ok()?; + + if let Some(tokens) = auth.tokens.as_ref() { + let info = &tokens.id_token; + let email = info.email.clone(); + let plan = info.get_chatgpt_plan_type().map(|plan| title_case(&plan)); + return Some(StatusAccountDisplay::ChatGpt { email, plan }); + } + + if let Some(key) = auth.openai_api_key + && !key.is_empty() + { + return Some(StatusAccountDisplay::ApiKey); + } + + None +} + +pub(crate) fn format_tokens_compact(value: u64) -> String { + if value == 0 { + return "0".to_string(); + } + if value < 1_000 { + return value.to_string(); + } + + let (scaled, suffix) = if value >= 1_000_000_000_000 { + (value as f64 / 1_000_000_000_000.0, "T") + } else if value >= 1_000_000_000 { + (value as f64 / 1_000_000_000.0, "B") + } else if value >= 1_000_000 { + (value as f64 / 1_000_000.0, "M") + } else { + (value as f64 / 1_000.0, "K") + }; + + let decimals = if scaled < 10.0 { + 2 + } else if scaled < 100.0 { + 1 + } else { + 0 + }; + + let mut formatted = format!("{scaled:.decimals$}"); + if formatted.contains('.') { + while formatted.ends_with('0') { + formatted.pop(); + } + if formatted.ends_with('.') { + formatted.pop(); + } + } + + format!("{formatted}{suffix}") +} + +pub(crate) fn format_directory_display(directory: &Path, max_width: Option) -> String { + let formatted = if let Some(rel) = relativize_to_home(directory) { + if rel.as_os_str().is_empty() { + "~".to_string() + } else { + format!("~{}{}", std::path::MAIN_SEPARATOR, rel.display()) + } + } else { + directory.display().to_string() + }; + + if let Some(max_width) = max_width { + if max_width == 0 { + return String::new(); + } + if UnicodeWidthStr::width(formatted.as_str()) > max_width { + return text_formatting::center_truncate_path(&formatted, max_width); + } + } + + formatted +} + +pub(crate) fn format_reset_timestamp(dt: DateTime, captured_at: DateTime) -> String { + let time = dt.format("%H:%M").to_string(); + if dt.date_naive() == captured_at.date_naive() { + time + } else { + format!("{} ({time})", dt.format("%-d %b")) + } +} + +pub(crate) fn title_case(s: &str) -> String { + if s.is_empty() { + return String::new(); + } + let mut chars = s.chars(); + let first = match chars.next() { + Some(c) => c, + None => return String::new(), + }; + let rest: String = chars.as_str().to_ascii_lowercase(); + first.to_uppercase().collect::() + &rest +} diff --git a/codex-rs/tui/src/status/mod.rs b/codex-rs/tui/src/status/mod.rs new file mode 100644 index 0000000000..eccb6b72b5 --- /dev/null +++ b/codex-rs/tui/src/status/mod.rs @@ -0,0 +1,12 @@ +mod account; +mod card; +mod format; +mod helpers; +mod rate_limits; + +pub(crate) use card::new_status_output; +pub(crate) use rate_limits::RateLimitSnapshotDisplay; +pub(crate) use rate_limits::rate_limit_snapshot_display; + +#[cfg(test)] +mod tests; diff --git a/codex-rs/tui/src/status/rate_limits.rs b/codex-rs/tui/src/status/rate_limits.rs new file mode 100644 index 0000000000..17d2f1706e --- /dev/null +++ b/codex-rs/tui/src/status/rate_limits.rs @@ -0,0 +1,117 @@ +use super::helpers::format_reset_timestamp; +use chrono::DateTime; +use chrono::Duration as ChronoDuration; +use chrono::Local; +use codex_core::protocol::RateLimitSnapshot; +use codex_core::protocol::RateLimitWindow; +use std::convert::TryFrom; + +const STATUS_LIMIT_BAR_SEGMENTS: usize = 20; +const STATUS_LIMIT_BAR_FILLED: &str = "โ–ˆ"; +const STATUS_LIMIT_BAR_EMPTY: &str = "โ–‘"; +pub(crate) const RESET_BULLET: &str = "ยท"; + +#[derive(Debug, Clone)] +pub(crate) struct StatusRateLimitRow { + pub label: &'static str, + pub percent_used: f64, + pub resets_at: Option, +} + +#[derive(Debug, Clone)] +pub(crate) enum StatusRateLimitData { + Available(Vec), + Missing, +} + +#[derive(Debug, Clone)] +pub(crate) struct RateLimitWindowDisplay { + pub used_percent: f64, + pub resets_at: Option, +} + +impl RateLimitWindowDisplay { + fn from_window(window: &RateLimitWindow, captured_at: DateTime) -> Self { + let resets_at = window + .resets_in_seconds + .and_then(|seconds| i64::try_from(seconds).ok()) + .and_then(|secs| captured_at.checked_add_signed(ChronoDuration::seconds(secs))) + .map(|dt| format_reset_timestamp(dt, captured_at)); + + Self { + used_percent: window.used_percent, + resets_at, + } + } +} + +#[derive(Debug, Clone)] +pub(crate) struct RateLimitSnapshotDisplay { + pub primary: Option, + pub secondary: Option, +} + +pub(crate) fn rate_limit_snapshot_display( + snapshot: &RateLimitSnapshot, + captured_at: DateTime, +) -> RateLimitSnapshotDisplay { + RateLimitSnapshotDisplay { + primary: snapshot + .primary + .as_ref() + .map(|window| RateLimitWindowDisplay::from_window(window, captured_at)), + secondary: snapshot + .secondary + .as_ref() + .map(|window| RateLimitWindowDisplay::from_window(window, captured_at)), + } +} + +pub(crate) fn compose_rate_limit_data( + snapshot: Option<&RateLimitSnapshotDisplay>, +) -> StatusRateLimitData { + match snapshot { + Some(snapshot) => { + let mut rows = Vec::with_capacity(2); + + if let Some(primary) = snapshot.primary.as_ref() { + rows.push(StatusRateLimitRow { + label: "5h limit", + percent_used: primary.used_percent, + resets_at: primary.resets_at.clone(), + }); + } + + if let Some(secondary) = snapshot.secondary.as_ref() { + rows.push(StatusRateLimitRow { + label: "Weekly limit", + percent_used: secondary.used_percent, + resets_at: secondary.resets_at.clone(), + }); + } + + if rows.is_empty() { + StatusRateLimitData::Missing + } else { + StatusRateLimitData::Available(rows) + } + } + None => StatusRateLimitData::Missing, + } +} + +pub(crate) fn render_status_limit_progress_bar(percent_used: f64) -> String { + let ratio = (percent_used / 100.0).clamp(0.0, 1.0); + let filled = (ratio * STATUS_LIMIT_BAR_SEGMENTS as f64).round() as usize; + let filled = filled.min(STATUS_LIMIT_BAR_SEGMENTS); + let empty = STATUS_LIMIT_BAR_SEGMENTS.saturating_sub(filled); + format!( + "[{}{}]", + STATUS_LIMIT_BAR_FILLED.repeat(filled), + STATUS_LIMIT_BAR_EMPTY.repeat(empty) + ) +} + +pub(crate) fn format_status_limit_summary(percent_used: f64) -> String { + format!("{percent_used:.0}% used") +} diff --git a/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_includes_reasoning_details.snap b/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_includes_reasoning_details.snap new file mode 100644 index 0000000000..fc929e04c5 --- /dev/null +++ b/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_includes_reasoning_details.snap @@ -0,0 +1,19 @@ +--- +source: tui/src/status/tests.rs +expression: sanitized +--- +/status + +โ•ญโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฎ +โ”‚ >_ OpenAI Codex (v0.0.0) โ”‚ +โ”‚ โ”‚ +โ”‚ Model: gpt-5-codex (reasoning high, summaries detailed) โ”‚ +โ”‚ Directory: [[workspace]] โ”‚ +โ”‚ Approval: on-request โ”‚ +โ”‚ Sandbox: workspace-write โ”‚ +โ”‚ Agents.md: โ”‚ +โ”‚ โ”‚ +โ”‚ Token Usage: 1.9K total (1K input + 900 output) โ”‚ +โ”‚ 5h limit: [โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–‘โ–‘โ–‘โ–‘โ–‘] 72% used ยท resets 03:14 โ”‚ +โ”‚ Weekly limit: [โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘] 45% used ยท resets 03:24 โ”‚ +โ•ฐโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฏ diff --git a/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_truncates_in_narrow_terminal.snap b/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_truncates_in_narrow_terminal.snap new file mode 100644 index 0000000000..9f7e1a45f9 --- /dev/null +++ b/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_truncates_in_narrow_terminal.snap @@ -0,0 +1,19 @@ +--- +source: tui/src/status/tests.rs +expression: sanitized +--- +/status + +โ•ญโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฎ +โ”‚ >_ OpenAI Codex (v0.0.0) โ”‚ +โ”‚ โ”‚ +โ”‚ Model: gpt-5-codex (reasoning hig โ”‚ +โ”‚ Directory: [[workspace]] โ”‚ +โ”‚ Approval: on-request โ”‚ +โ”‚ Sandbox: read-only โ”‚ +โ”‚ Agents.md: โ”‚ +โ”‚ โ”‚ +โ”‚ Token Usage: 1.9K total (1K input + 90 โ”‚ +โ”‚ 5h limit: [โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–‘โ–‘โ–‘โ–‘โ–‘] 72% โ”‚ +โ”‚ ยท resets 03:14 โ”‚ +โ•ฐโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฏ diff --git a/codex-rs/tui/src/status/tests.rs b/codex-rs/tui/src/status/tests.rs new file mode 100644 index 0000000000..467b9dd746 --- /dev/null +++ b/codex-rs/tui/src/status/tests.rs @@ -0,0 +1,183 @@ +use super::new_status_output; +use super::rate_limit_snapshot_display; +use crate::history_cell::HistoryCell; +use chrono::TimeZone; +use codex_core::config::Config; +use codex_core::config::ConfigOverrides; +use codex_core::config::ConfigToml; +use codex_core::protocol::RateLimitSnapshot; +use codex_core::protocol::RateLimitWindow; +use codex_core::protocol::SandboxPolicy; +use codex_core::protocol::TokenUsage; +use codex_protocol::config_types::ReasoningEffort; +use codex_protocol::config_types::ReasoningSummary; +use insta::assert_snapshot; +use ratatui::prelude::*; +use std::path::PathBuf; +use tempfile::TempDir; + +fn test_config(temp_home: &TempDir) -> Config { + Config::load_from_base_config_with_overrides( + ConfigToml::default(), + ConfigOverrides::default(), + temp_home.path().to_path_buf(), + ) + .expect("load config") +} + +fn render_lines(lines: &[Line<'static>]) -> Vec { + lines + .iter() + .map(|line| { + line.spans + .iter() + .map(|span| span.content.as_ref()) + .collect::() + }) + .collect() +} + +fn sanitize_directory(lines: Vec) -> Vec { + lines + .into_iter() + .map(|line| { + if let (Some(dir_pos), Some(pipe_idx)) = (line.find("Directory: "), line.rfind('โ”‚')) { + let prefix = &line[..dir_pos + "Directory: ".len()]; + let suffix = &line[pipe_idx..]; + let content_width = pipe_idx.saturating_sub(dir_pos + "Directory: ".len()); + let replacement = "[[workspace]]"; + let mut rebuilt = prefix.to_string(); + rebuilt.push_str(replacement); + if content_width > replacement.len() { + rebuilt.push_str(&" ".repeat(content_width - replacement.len())); + } + rebuilt.push_str(suffix); + rebuilt + } else { + line + } + }) + .collect() +} + +#[test] +fn status_snapshot_includes_reasoning_details() { + let temp_home = TempDir::new().expect("temp home"); + let mut config = test_config(&temp_home); + config.model = "gpt-5-codex".to_string(); + config.model_provider_id = "openai".to_string(); + config.model_reasoning_effort = Some(ReasoningEffort::High); + config.model_reasoning_summary = ReasoningSummary::Detailed; + config.sandbox_policy = SandboxPolicy::WorkspaceWrite { + writable_roots: Vec::new(), + network_access: false, + exclude_tmpdir_env_var: false, + exclude_slash_tmp: false, + }; + + config.cwd = PathBuf::from("/workspace/tests"); + + let usage = TokenUsage { + input_tokens: 1_200, + cached_input_tokens: 200, + output_tokens: 900, + reasoning_output_tokens: 150, + total_tokens: 2_250, + }; + + let snapshot = RateLimitSnapshot { + primary: Some(RateLimitWindow { + used_percent: 72.5, + window_minutes: Some(300), + resets_in_seconds: Some(600), + }), + secondary: Some(RateLimitWindow { + used_percent: 45.0, + window_minutes: Some(1_440), + resets_in_seconds: Some(1_200), + }), + }; + let captured_at = chrono::Local + .with_ymd_and_hms(2024, 1, 2, 3, 4, 5) + .single() + .expect("timestamp"); + let rate_display = rate_limit_snapshot_display(&snapshot, captured_at); + + let composite = new_status_output(&config, &usage, &None, Some(&rate_display)); + let mut rendered_lines = render_lines(&composite.display_lines(80)); + if cfg!(windows) { + for line in &mut rendered_lines { + *line = line.replace('\\', "/"); + } + } + let sanitized = sanitize_directory(rendered_lines).join("\n"); + assert_snapshot!(sanitized); +} + +#[test] +fn status_card_token_usage_excludes_cached_tokens() { + let temp_home = TempDir::new().expect("temp home"); + let mut config = test_config(&temp_home); + config.model = "gpt-5-codex".to_string(); + config.cwd = PathBuf::from("/workspace/tests"); + + let usage = TokenUsage { + input_tokens: 1_200, + cached_input_tokens: 200, + output_tokens: 900, + reasoning_output_tokens: 0, + total_tokens: 2_100, + }; + + let composite = new_status_output(&config, &usage, &None, None); + let rendered = render_lines(&composite.display_lines(120)); + + assert!( + rendered.iter().all(|line| !line.contains("cached")), + "cached tokens should not be displayed, got: {rendered:?}" + ); +} + +#[test] +fn status_snapshot_truncates_in_narrow_terminal() { + let temp_home = TempDir::new().expect("temp home"); + let mut config = test_config(&temp_home); + config.model = "gpt-5-codex".to_string(); + config.model_provider_id = "openai".to_string(); + config.model_reasoning_effort = Some(ReasoningEffort::High); + config.model_reasoning_summary = ReasoningSummary::Detailed; + config.cwd = PathBuf::from("/workspace/tests"); + + let usage = TokenUsage { + input_tokens: 1_200, + cached_input_tokens: 200, + output_tokens: 900, + reasoning_output_tokens: 150, + total_tokens: 2_250, + }; + + let snapshot = RateLimitSnapshot { + primary: Some(RateLimitWindow { + used_percent: 72.5, + window_minutes: Some(300), + resets_in_seconds: Some(600), + }), + secondary: None, + }; + let captured_at = chrono::Local + .with_ymd_and_hms(2024, 1, 2, 3, 4, 5) + .single() + .expect("timestamp"); + let rate_display = rate_limit_snapshot_display(&snapshot, captured_at); + + let composite = new_status_output(&config, &usage, &None, Some(&rate_display)); + let mut rendered_lines = render_lines(&composite.display_lines(46)); + if cfg!(windows) { + for line in &mut rendered_lines { + *line = line.replace('\\', "/"); + } + } + let sanitized = sanitize_directory(rendered_lines).join("\n"); + + assert_snapshot!(sanitized); +} From d61dea6fe6c1bc4ec466a58b9ea27e98e5bc52f2 Mon Sep 17 00:00:00 2001 From: Michael Bolin Date: Thu, 25 Sep 2025 10:02:28 -0700 Subject: [PATCH 5/7] feat: add support for CODEX_SECURE_MODE=1 to restrict process observability (#4220) Because the `codex` process could contain sensitive information in memory, such as API keys, we add logic so that when `CODEX_SECURE_MODE=1` is specified, we avail ourselves of whatever the operating system provides to restrict observability/tampering, which includes: - disabling `ptrace(2)`, so it is not possible to attach to the process with a debugger, such as `gdb` - disabling core dumps Admittedly, a user with root privileges can defeat these safeguards. For now, we only add support for this in the `codex` multitool, but we may ultimately want to support this in some of the smaller CLIs that are buildable out of our Cargo workspace. --- codex-rs/Cargo.lock | 35 ++++++++- codex-rs/Cargo.toml | 1 + codex-rs/cli/Cargo.toml | 10 +++ codex-rs/cli/src/main.rs | 29 ++++++++ codex-rs/cli/src/pre_main_hardening.rs | 98 ++++++++++++++++++++++++++ 5 files changed, 172 insertions(+), 1 deletion(-) create mode 100644 codex-rs/cli/src/pre_main_hardening.rs diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 5b74ec3263..5ad73350c9 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -56,7 +56,7 @@ checksum = "8fac2ce611db8b8cee9b2aa886ca03c924e9da5e5295d0dbd0526e5d0b0710f7" dependencies = [ "allocative_derive", "bumpalo", - "ctor", + "ctor 0.1.26", "hashbrown 0.14.5", "num-bigint", ] @@ -641,6 +641,8 @@ dependencies = [ "codex-protocol", "codex-protocol-ts", "codex-tui", + "ctor 0.5.0", + "libc", "owo-colors", "predicates", "pretty_assertions", @@ -1213,6 +1215,22 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "ctor" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67773048316103656a637612c4a62477603b777d91d9c62ff2290f9cde178fdb" +dependencies = [ + "ctor-proc-macro", + "dtor", +] + +[[package]] +name = "ctor-proc-macro" +version = "0.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2931af7e13dc045d8e9d26afccc6fa115d64e115c9c84b1166288b46f6782c2" + [[package]] name = "darling" version = "0.20.11" @@ -1463,6 +1481,21 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" +[[package]] +name = "dtor" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e58a0764cddb55ab28955347b45be00ade43d4d6f3ba4bf3dc354e4ec9432934" +dependencies = [ + "dtor-proc-macro", +] + +[[package]] +name = "dtor-proc-macro" +version = "0.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f678cf4a922c215c63e0de95eb1ff08a958a81d47e485cf9da1e27bf6305cfa5" + [[package]] name = "dupe" version = "0.9.1" diff --git a/codex-rs/Cargo.toml b/codex-rs/Cargo.toml index 57e606cbb9..7b4db5fcc5 100644 --- a/codex-rs/Cargo.toml +++ b/codex-rs/Cargo.toml @@ -72,6 +72,7 @@ clap = "4" clap_complete = "4" color-eyre = "0.6.3" crossterm = "0.28.1" +ctor = "0.5.0" derive_more = "2" diffy = "0.4.2" dirs = "6" diff --git a/codex-rs/cli/Cargo.toml b/codex-rs/cli/Cargo.toml index c410e09a82..44217891a1 100644 --- a/codex-rs/cli/Cargo.toml +++ b/codex-rs/cli/Cargo.toml @@ -28,6 +28,7 @@ codex-mcp-server = { workspace = true } codex-protocol = { workspace = true } codex-protocol-ts = { workspace = true } codex-tui = { workspace = true } +ctor = { workspace = true } owo-colors = { workspace = true } serde_json = { workspace = true } supports-color = { workspace = true } @@ -41,6 +42,15 @@ tokio = { workspace = true, features = [ tracing = { workspace = true } tracing-subscriber = { workspace = true } +[target.'cfg(target_os = "linux")'.dependencies] +libc = { workspace = true } + +[target.'cfg(target_os = "android")'.dependencies] +libc = { workspace = true } + +[target.'cfg(target_os = "macos")'.dependencies] +libc = { workspace = true } + [dev-dependencies] assert_cmd = { workspace = true } predicates = { workspace = true } diff --git a/codex-rs/cli/src/main.rs b/codex-rs/cli/src/main.rs index df757b0cc5..b1e9601c8c 100644 --- a/codex-rs/cli/src/main.rs +++ b/codex-rs/cli/src/main.rs @@ -21,6 +21,7 @@ use std::path::PathBuf; use supports_color::Stream; mod mcp_cmd; +mod pre_main_hardening; use crate::mcp_cmd::McpCli; use crate::proto::ProtoCli; @@ -194,6 +195,34 @@ fn print_exit_messages(exit_info: AppExitInfo) { } } +pub(crate) const CODEX_SECURE_MODE_ENV_VAR: &str = "CODEX_SECURE_MODE"; + +/// As early as possible in the process lifecycle, apply hardening measures +/// if the CODEX_SECURE_MODE environment variable is set to "1". +#[ctor::ctor] +fn pre_main_hardening() { + let secure_mode = match std::env::var(CODEX_SECURE_MODE_ENV_VAR) { + Ok(value) => value, + Err(_) => return, + }; + + if secure_mode == "1" { + #[cfg(any(target_os = "linux", target_os = "android"))] + crate::pre_main_hardening::pre_main_hardening_linux(); + + #[cfg(target_os = "macos")] + crate::pre_main_hardening::pre_main_hardening_macos(); + + #[cfg(windows)] + crate::pre_main_hardening::pre_main_hardening_windows(); + } + + // Always clear this env var so child processes don't inherit it. + unsafe { + std::env::remove_var(CODEX_SECURE_MODE_ENV_VAR); + } +} + fn main() -> anyhow::Result<()> { arg0_dispatch_or_else(|codex_linux_sandbox_exe| async move { cli_main(codex_linux_sandbox_exe).await?; diff --git a/codex-rs/cli/src/pre_main_hardening.rs b/codex-rs/cli/src/pre_main_hardening.rs new file mode 100644 index 0000000000..a75cb3561c --- /dev/null +++ b/codex-rs/cli/src/pre_main_hardening.rs @@ -0,0 +1,98 @@ +#[cfg(any(target_os = "linux", target_os = "android"))] +const PRCTL_FAILED_EXIT_CODE: i32 = 5; + +#[cfg(target_os = "macos")] +const PTRACE_DENY_ATTACH_FAILED_EXIT_CODE: i32 = 6; + +#[cfg(any(target_os = "linux", target_os = "android", target_os = "macos"))] +const SET_RLIMIT_CORE_FAILED_EXIT_CODE: i32 = 7; + +#[cfg(any(target_os = "linux", target_os = "android"))] +pub(crate) fn pre_main_hardening_linux() { + // Disable ptrace attach / mark process non-dumpable. + let ret_code = unsafe { libc::prctl(libc::PR_SET_DUMPABLE, 0, 0, 0, 0) }; + if ret_code != 0 { + eprintln!( + "ERROR: prctl(PR_SET_DUMPABLE, 0) failed: {}", + std::io::Error::last_os_error() + ); + std::process::exit(PRCTL_FAILED_EXIT_CODE); + } + + // For "defense in depth," set the core file size limit to 0. + set_core_file_size_limit_to_zero(); + + // Official Codex releases are MUSL-linked, which means that variables such + // as LD_PRELOAD are ignored anyway, but just to be sure, clear them here. + let ld_keys: Vec = std::env::vars() + .filter_map(|(key, _)| { + if key.starts_with("LD_") { + Some(key) + } else { + None + } + }) + .collect(); + + for key in ld_keys { + unsafe { + std::env::remove_var(key); + } + } +} + +#[cfg(target_os = "macos")] +pub(crate) fn pre_main_hardening_macos() { + // Prevent debuggers from attaching to this process. + let ret_code = unsafe { libc::ptrace(libc::PT_DENY_ATTACH, 0, std::ptr::null_mut(), 0) }; + if ret_code == -1 { + eprintln!( + "ERROR: ptrace(PT_DENY_ATTACH) failed: {}", + std::io::Error::last_os_error() + ); + std::process::exit(PTRACE_DENY_ATTACH_FAILED_EXIT_CODE); + } + + // Set the core file size limit to 0 to prevent core dumps. + set_core_file_size_limit_to_zero(); + + // Remove all DYLD_ environment variables, which can be used to subvert + // library loading. + let dyld_keys: Vec = std::env::vars() + .filter_map(|(key, _)| { + if key.starts_with("DYLD_") { + Some(key) + } else { + None + } + }) + .collect(); + + for key in dyld_keys { + unsafe { + std::env::remove_var(key); + } + } +} + +#[cfg(unix)] +fn set_core_file_size_limit_to_zero() { + let rlim = libc::rlimit { + rlim_cur: 0, + rlim_max: 0, + }; + + let ret_code = unsafe { libc::setrlimit(libc::RLIMIT_CORE, &rlim) }; + if ret_code != 0 { + eprintln!( + "ERROR: setrlimit(RLIMIT_CORE) failed: {}", + std::io::Error::last_os_error() + ); + std::process::exit(SET_RLIMIT_CORE_FAILED_EXIT_CODE); + } +} + +#[cfg(windows)] +pub(crate) fn pre_main_hardening_windows() { + // TODO(mbolin): Perform the appropriate configuration for Windows. +} From 103adcdf2d05fac262e67f244360ab1aef2f58ee Mon Sep 17 00:00:00 2001 From: Jeremy Rose <172423086+nornagon-openai@users.noreply.github.com> Date: Thu, 25 Sep 2025 10:07:27 -0700 Subject: [PATCH 6/7] fix: esc w/ queued messages overwrites draft in composer (#4237) Instead of overwriting the contents of the composer when pressing Esc when there's a queued message, prepend the queued message(s) to the composer draft. --- codex-rs/tui/src/bottom_pane/chat_composer.rs | 1 - codex-rs/tui/src/bottom_pane/mod.rs | 1 - codex-rs/tui/src/chatwidget.rs | 10 +++++- codex-rs/tui/src/chatwidget/tests.rs | 34 +++++++++++++++++++ 4 files changed, 43 insertions(+), 3 deletions(-) diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index 65203b5193..eb65481592 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -264,7 +264,6 @@ impl ChatComposer { } /// Get the current composer text. - #[cfg(test)] pub(crate) fn current_text(&self) -> String { self.textarea.text().to_string() } diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index fc9f06c39d..bd26581b47 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -262,7 +262,6 @@ impl BottomPane { } /// Get the current composer text (for tests and programmatic checks). - #[cfg(test)] pub(crate) fn composer_text(&self) -> String { self.composer.current_text() } diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index ec749f4b7b..26d813a814 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -427,12 +427,20 @@ impl ChatWidget { // If any messages were queued during the task, restore them into the composer. if !self.queued_user_messages.is_empty() { - let combined = self + let queued_text = self .queued_user_messages .iter() .map(|m| m.text.clone()) .collect::>() .join("\n"); + let existing_text = self.bottom_pane.composer_text(); + let combined = if existing_text.is_empty() { + queued_text + } else if queued_text.is_empty() { + existing_text + } else { + format!("{queued_text}\n{existing_text}") + }; self.bottom_pane.set_composer_text(combined); // Clear the queue and update the status indicator list. self.queued_user_messages.clear(); diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index 7805e07e7b..18bc515b0b 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -1299,6 +1299,40 @@ fn interrupt_restores_queued_messages_into_composer() { let _ = drain_insert_history(&mut rx); } +#[test] +fn interrupt_prepends_queued_messages_before_existing_composer_text() { + let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(); + + chat.bottom_pane.set_task_running(true); + chat.bottom_pane + .set_composer_text("current draft".to_string()); + + chat.queued_user_messages + .push_back(UserMessage::from("first queued".to_string())); + chat.queued_user_messages + .push_back(UserMessage::from("second queued".to_string())); + chat.refresh_queued_user_messages(); + + chat.handle_codex_event(Event { + id: "turn-1".into(), + msg: EventMsg::TurnAborted(codex_core::protocol::TurnAbortedEvent { + reason: TurnAbortReason::Interrupted, + }), + }); + + assert_eq!( + chat.bottom_pane.composer_text(), + "first queued\nsecond queued\ncurrent draft" + ); + assert!(chat.queued_user_messages.is_empty()); + assert!( + op_rx.try_recv().is_err(), + "unexpected outbound op after interrupt" + ); + + let _ = drain_insert_history(&mut rx); +} + // Snapshot test: ChatWidget at very small heights (idle) // Ensures overall layout behaves when terminal height is extremely constrained. #[test] From 4670b0c561a97e402b38e2df3d5e1b4de9779b77 Mon Sep 17 00:00:00 2001 From: Michael Bolin Date: Thu, 25 Sep 2025 12:03:27 -0700 Subject: [PATCH 7/7] feat: remove OPENAI_API_KEY from env and mlock if set --- codex-rs/Cargo.lock | 1 + codex-rs/arg0/Cargo.toml | 1 + codex-rs/arg0/src/lib.rs | 36 +++++++++-- codex-rs/arg0/src/openai_api_key_env_var.rs | 69 +++++++++++++++++++++ codex-rs/cli/src/main.rs | 12 +++- codex-rs/exec/src/main.rs | 7 ++- codex-rs/mcp-server/src/main.rs | 7 ++- codex-rs/tui/src/main.rs | 7 ++- 8 files changed, 129 insertions(+), 11 deletions(-) create mode 100644 codex-rs/arg0/src/openai_api_key_env_var.rs diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 5ad73350c9..41d7527832 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -605,6 +605,7 @@ dependencies = [ "codex-core", "codex-linux-sandbox", "dotenvy", + "libc", "tempfile", "tokio", ] diff --git a/codex-rs/arg0/Cargo.toml b/codex-rs/arg0/Cargo.toml index 10d09e4a4b..5e8dbbafab 100644 --- a/codex-rs/arg0/Cargo.toml +++ b/codex-rs/arg0/Cargo.toml @@ -16,5 +16,6 @@ codex-apply-patch = { workspace = true } codex-core = { workspace = true } codex-linux-sandbox = { workspace = true } dotenvy = { workspace = true } +libc = { workspace = true } tempfile = { workspace = true } tokio = { workspace = true, features = ["rt-multi-thread"] } diff --git a/codex-rs/arg0/src/lib.rs b/codex-rs/arg0/src/lib.rs index e70ff2df64..c04622ffcc 100644 --- a/codex-rs/arg0/src/lib.rs +++ b/codex-rs/arg0/src/lib.rs @@ -7,10 +7,28 @@ use codex_core::CODEX_APPLY_PATCH_ARG1; use std::os::unix::fs::symlink; use tempfile::TempDir; +mod openai_api_key_env_var; +use openai_api_key_env_var::extract_locked_openai_api_key; + const LINUX_SANDBOX_ARG0: &str = "codex-linux-sandbox"; const APPLY_PATCH_ARG0: &str = "apply_patch"; const MISSPELLED_APPLY_PATCH_ARG0: &str = "applypatch"; +/// Arguments supplied to the async entrypoint invoked by [`arg0_dispatch_or_else`]. +/// +#[derive(Debug)] +pub struct PreMainArgs { + pub codex_linux_sandbox_exe: Option, + + /// Value of the `OPENAI_API_KEY` environment variable that was set at + /// startup, if any. + /// + /// If `Some`, the key has already been removed from the environment and an + /// attempt was made to `mlock(2)` the string's allocation to keep it + /// resident in memory. + pub openai_api_key: Option, +} + /// 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 @@ -26,15 +44,15 @@ const MISSPELLED_APPLY_PATCH_ARG0: &str = "applypatch"; /// 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`]. +/// error. The closure receives [`PreMainArgs`], which includes the optional +/// path to the linux sandbox helper as well as the OpenAI API key if the +/// caller requested it. /// /// 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, + F: FnOnce(PreMainArgs) -> Fut, Fut: Future>, { // Determine if we were invoked via the special alias. @@ -76,6 +94,9 @@ where // before creating any threads/the Tokio runtime. load_dotenv(); + // Perform the OPENAI_API_KEY check after loading the .env file. + let openai_api_key = extract_locked_openai_api_key(); + // Retain the TempDir so it exists for the lifetime of the invocation of // this executable. Admittedly, we could invoke `keep()` on it, but it // would be nice to avoid leaving temporary directories behind, if possible. @@ -99,7 +120,12 @@ where None }; - main_fn(codex_linux_sandbox_exe).await + let args = PreMainArgs { + codex_linux_sandbox_exe, + openai_api_key, + }; + + main_fn(args).await }) } diff --git a/codex-rs/arg0/src/openai_api_key_env_var.rs b/codex-rs/arg0/src/openai_api_key_env_var.rs new file mode 100644 index 0000000000..3eaf1f672e --- /dev/null +++ b/codex-rs/arg0/src/openai_api_key_env_var.rs @@ -0,0 +1,69 @@ +const OPENAI_API_KEY_ENV_VAR: &str = "OPENAI_API_KEY"; + +pub(crate) fn extract_locked_openai_api_key() -> Option { + match std::env::var(OPENAI_API_KEY_ENV_VAR) { + Ok(key) => { + if key.is_empty() { + return None; + } + + mlock_str(&key); + + // Safety: modifying environment variables is only done before new + // threads are spawned. + unsafe { + std::env::remove_var(OPENAI_API_KEY_ENV_VAR); + } + Some(key) + } + Err(std::env::VarError::NotPresent) => None, + Err(std::env::VarError::NotUnicode(_)) => { + // Cannot possibly be a valid API key, so ignore it. + None + } + } +} + +#[cfg(unix)] +fn mlock_str(value: &str) { + use libc::_SC_PAGESIZE; + use libc::c_void; + use libc::mlock; + use libc::sysconf; + + if value.is_empty() { + return; + } + + // Safety: we only read the pointer and length for mlock bookkeeping. + let page_size = unsafe { sysconf(_SC_PAGESIZE) }; + if page_size <= 0 { + return; + } + let page_size = page_size as usize; + if page_size == 0 { + return; + } + + let addr = value.as_ptr() as usize; + let len = value.len(); + let start = addr & !(page_size - 1); + let addr_end = match addr.checked_add(len) { + Some(v) => match v.checked_add(page_size - 1) { + Some(total) => total, + None => return, + }, + None => return, + }; + let end = addr_end & !(page_size - 1); + let size = end.saturating_sub(start); + if size == 0 { + return; + } + + // Best-effort; ignore failures because mlock may require privileges. + let _ = unsafe { mlock(start as *const c_void, size) }; +} + +#[cfg(not(unix))] +fn mlock_str(_value: &str) {} diff --git a/codex-rs/cli/src/main.rs b/codex-rs/cli/src/main.rs index b1e9601c8c..cde0041666 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::PreMainArgs; use codex_arg0::arg0_dispatch_or_else; use codex_chatgpt::apply_command::ApplyCommand; use codex_chatgpt::apply_command::run_apply_command; @@ -224,13 +225,18 @@ fn pre_main_hardening() { } fn main() -> anyhow::Result<()> { - arg0_dispatch_or_else(|codex_linux_sandbox_exe| async move { - cli_main(codex_linux_sandbox_exe).await?; + arg0_dispatch_or_else(|pre_main_args| async move { + cli_main(pre_main_args).await?; Ok(()) }) } -async fn cli_main(codex_linux_sandbox_exe: Option) -> anyhow::Result<()> { +async fn cli_main(pre_main_args: PreMainArgs) -> anyhow::Result<()> { + let PreMainArgs { + codex_linux_sandbox_exe, + openai_api_key: _, + } = pre_main_args; + let MultitoolCli { config_overrides: root_config_overrides, mut interactive, diff --git a/codex-rs/exec/src/main.rs b/codex-rs/exec/src/main.rs index 03ee533ea9..7bdd9544fa 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::PreMainArgs; use codex_arg0::arg0_dispatch_or_else; use codex_common::CliConfigOverrides; use codex_exec::Cli; @@ -25,7 +26,11 @@ struct TopCli { } fn main() -> anyhow::Result<()> { - arg0_dispatch_or_else(|codex_linux_sandbox_exe| async move { + arg0_dispatch_or_else(|pre_main_args| async move { + let PreMainArgs { + codex_linux_sandbox_exe, + openai_api_key: _, + } = pre_main_args; 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/mcp-server/src/main.rs b/codex-rs/mcp-server/src/main.rs index 314944fab5..d9700313b1 100644 --- a/codex-rs/mcp-server/src/main.rs +++ b/codex-rs/mcp-server/src/main.rs @@ -1,9 +1,14 @@ +use codex_arg0::PreMainArgs; use codex_arg0::arg0_dispatch_or_else; use codex_common::CliConfigOverrides; use codex_mcp_server::run_main; fn main() -> anyhow::Result<()> { - arg0_dispatch_or_else(|codex_linux_sandbox_exe| async move { + arg0_dispatch_or_else(|pre_main_args| async move { + let PreMainArgs { + codex_linux_sandbox_exe, + openai_api_key: _, + } = pre_main_args; run_main(codex_linux_sandbox_exe, CliConfigOverrides::default()).await?; Ok(()) }) diff --git a/codex-rs/tui/src/main.rs b/codex-rs/tui/src/main.rs index 50ea95f170..3582439996 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::PreMainArgs; use codex_arg0::arg0_dispatch_or_else; use codex_common::CliConfigOverrides; use codex_tui::Cli; @@ -14,7 +15,11 @@ struct TopCli { } fn main() -> anyhow::Result<()> { - arg0_dispatch_or_else(|codex_linux_sandbox_exe| async move { + arg0_dispatch_or_else(|pre_main_args| async move { + let PreMainArgs { + codex_linux_sandbox_exe, + openai_api_key: _, + } = pre_main_args; let top_cli = TopCli::parse(); let mut inner = top_cli.inner; inner