From 0016346dfba9617ca278e642cb86154014577a4a Mon Sep 17 00:00:00 2001 From: Jeremy Rose <172423086+nornagon-openai@users.noreply.github.com> Date: Wed, 15 Oct 2025 13:57:44 -0700 Subject: [PATCH 01/35] tui: ^C in prompt area resets history navigation cursor (#5078) ^C resets the history navigation, similar to zsh/bash. Fixes #4834 ------ https://chatgpt.com/codex/tasks/task_i_68e9674b6ac8832c8212bff6cba75e87 --- codex-rs/tui/src/bottom_pane/chat_composer.rs | 5 ++++ .../src/bottom_pane/chat_composer_history.rs | 26 +++++++++++++++++++ codex-rs/tui/src/bottom_pane/mod.rs | 7 ++++- 3 files changed, 37 insertions(+), 1 deletion(-) diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index 0d1395d51d..39a170ce1c 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -316,6 +316,11 @@ impl ChatComposer { self.sync_file_search_popup(); } + pub(crate) fn clear_for_ctrl_c(&mut self) { + self.set_text_content(String::new()); + self.history.reset_navigation(); + } + /// Get the current composer text. pub(crate) fn current_text(&self) -> String { self.textarea.text().to_string() diff --git a/codex-rs/tui/src/bottom_pane/chat_composer_history.rs b/codex-rs/tui/src/bottom_pane/chat_composer_history.rs index 07e3df7cd7..991283a566 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer_history.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer_history.rs @@ -70,6 +70,12 @@ impl ChatComposerHistory { self.local_history.push(text.to_string()); } + /// Reset navigation tracking so the next Up key resumes from the latest entry. + pub fn reset_navigation(&mut self) { + self.history_cursor = None; + self.last_history_text = None; + } + /// Should Up/Down key presses be interpreted as history navigation given /// the current content and cursor position of `textarea`? pub fn should_handle_navigation(&self, text: &str, cursor: usize) -> bool { @@ -271,4 +277,24 @@ mod tests { history.on_entry_response(1, 1, Some("older".into())) ); } + + #[test] + fn reset_navigation_resets_cursor() { + let (tx, _rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx); + + let mut history = ChatComposerHistory::new(); + history.set_metadata(1, 3); + history.fetched_history.insert(1, "command2".into()); + history.fetched_history.insert(2, "command3".into()); + + assert_eq!(Some("command3".into()), history.navigate_up(&tx)); + assert_eq!(Some("command2".into()), history.navigate_up(&tx)); + + history.reset_navigation(); + assert!(history.history_cursor.is_none()); + assert!(history.last_history_text.is_none()); + + assert_eq!(Some("command3".into()), history.navigate_up(&tx)); + } } diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index db13a041cd..d8c3265f95 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -236,7 +236,7 @@ impl BottomPane { CancellationEvent::NotHandled } else { self.view_stack.pop(); - self.set_composer_text(String::new()); + self.clear_composer_for_ctrl_c(); self.show_ctrl_c_quit_hint(); CancellationEvent::Handled } @@ -270,6 +270,11 @@ impl BottomPane { self.request_redraw(); } + pub(crate) fn clear_composer_for_ctrl_c(&mut self) { + self.composer.clear_for_ctrl_c(); + self.request_redraw(); + } + /// Get the current composer text (for tests and programmatic checks). pub(crate) fn composer_text(&self) -> String { self.composer.current_text() From 9b53a306e32a6c469f48431258b05013283db00a Mon Sep 17 00:00:00 2001 From: Jeremy Rose <172423086+nornagon-openai@users.noreply.github.com> Date: Wed, 15 Oct 2025 13:57:50 -0700 Subject: [PATCH 02/35] Keep backtrack Esc hint gated on empty composer (#5076) ## Summary - only prime backtrack and show the ESC hint when the composer is empty - keep the composer-side ESC hint unchanged when drafts or attachments exist and cover it with a regression test Fixes #5030 ------ https://chatgpt.com/codex/tasks/task_i_68e95ba59cd8832caec8e72ae2efeb55 --- codex-rs/tui/src/app.rs | 5 ++- codex-rs/tui/src/app_backtrack.rs | 19 ++++----- codex-rs/tui/src/bottom_pane/chat_composer.rs | 39 +++++++++++++++++-- 3 files changed, 48 insertions(+), 15 deletions(-) diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index cb3dea5e60..2906e3a694 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -427,8 +427,9 @@ impl App { tui.frame_requester().schedule_frame(); } // Esc primes/advances backtracking only in normal (not working) mode - // with an empty composer. In any other state, forward Esc so the - // active UI (e.g. status indicator, modals, popups) handles it. + // with the composer focused and empty. In any other state, forward + // Esc so the active UI (e.g. status indicator, modals, popups) + // handles it. KeyEvent { code: KeyCode::Esc, kind: KeyEventKind::Press | KeyEventKind::Repeat, diff --git a/codex-rs/tui/src/app_backtrack.rs b/codex-rs/tui/src/app_backtrack.rs index 5528fa7bc4..8f889623cf 100644 --- a/codex-rs/tui/src/app_backtrack.rs +++ b/codex-rs/tui/src/app_backtrack.rs @@ -82,15 +82,16 @@ impl App { /// Handle global Esc presses for backtracking when no overlay is present. pub(crate) fn handle_backtrack_esc_key(&mut self, tui: &mut tui::Tui) { - // Only handle backtracking when composer is empty to avoid clobbering edits. - if self.chat_widget.composer_is_empty() { - if !self.backtrack.primed { - self.prime_backtrack(); - } else if self.overlay.is_none() { - self.open_backtrack_preview(tui); - } else if self.backtrack.overlay_preview_active { - self.step_backtrack_and_highlight(tui); - } + if !self.chat_widget.composer_is_empty() { + return; + } + + if !self.backtrack.primed { + self.prime_backtrack(); + } else if self.overlay.is_none() { + self.open_backtrack_preview(tui); + } else if self.backtrack.overlay_preview_active { + self.step_backtrack_and_highlight(tui); } } diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index 39a170ce1c..56a6e44de3 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -857,10 +857,12 @@ impl ChatComposer { return (InputResult::None, true); } if key_event.code == KeyCode::Esc { - let next_mode = esc_hint_mode(self.footer_mode, self.is_task_running); - if next_mode != self.footer_mode { - self.footer_mode = next_mode; - return (InputResult::None, true); + if self.is_empty() { + let next_mode = esc_hint_mode(self.footer_mode, self.is_task_running); + if next_mode != self.footer_mode { + self.footer_mode = next_mode; + return (InputResult::None, true); + } } } else { self.footer_mode = reset_mode_after_activity(self.footer_mode); @@ -1797,6 +1799,35 @@ mod tests { }); } + #[test] + fn esc_hint_stays_hidden_with_draft_content() { + use crossterm::event::KeyCode; + use crossterm::event::KeyEvent; + use crossterm::event::KeyModifiers; + + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + true, + sender, + true, + "Ask Codex to do anything".to_string(), + false, + ); + + type_chars_humanlike(&mut composer, &['d']); + + assert!(!composer.is_empty()); + assert_eq!(composer.current_text(), "d"); + assert_eq!(composer.footer_mode, FooterMode::ShortcutSummary); + assert!(matches!(composer.active_popup, ActivePopup::None)); + + let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + + assert_eq!(composer.footer_mode, FooterMode::ShortcutSummary); + assert!(!composer.esc_backtrack_hint); + } + #[test] fn question_mark_only_toggles_on_first_char() { use crossterm::event::KeyCode; From 995f5c36142affadf6f28cb70c040d8a8819df27 Mon Sep 17 00:00:00 2001 From: Michael Bolin Date: Wed, 15 Oct 2025 13:58:40 -0700 Subject: [PATCH 03/35] feat: add Vec to ExecApprovalRequestEvent (#5222) This adds `parsed_cmd: Vec` to `ExecApprovalRequestEvent` in the core protocol (`protocol/src/protocol.rs`), which is also what this field is named on `ExecCommandBeginEvent`. Honestly, I don't love the name (it sounds like a single command, but it is actually a list of them), but I don't want to get distracted by a naming discussion right now. This also adds `parsed_cmd` to `ExecCommandApprovalParams` in `codex-rs/app-server-protocol/src/protocol.rs`, so it will be available via `codex app-server`, as well. For consistency, I also updated `ExecApprovalElicitRequestParams` in `codex-rs/mcp-server/src/exec_approval.rs` to include this field under the name `codex_parsed_cmd`, as that struct already has a number of special `codex_*` fields. Note this is the code for when Codex is used as an MCP _server_ and therefore has to conform to the official spec for an MCP elicitation type. --- codex-rs/app-server-protocol/src/protocol.rs | 11 +++++++++++ codex-rs/app-server/src/codex_message_processor.rs | 2 ++ .../tests/suite/codex_message_processor_flow.rs | 4 ++++ codex-rs/core/src/codex.rs | 2 ++ codex-rs/mcp-server/src/codex_tool_runner.rs | 2 ++ codex-rs/mcp-server/src/exec_approval.rs | 4 ++++ codex-rs/mcp-server/tests/suite/codex_tool.rs | 3 +++ codex-rs/protocol/src/protocol.rs | 1 + codex-rs/tui/src/chatwidget/tests.rs | 6 ++++++ codex-rs/tui/tests/fixtures/binary-size-log.jsonl | 8 ++++---- 10 files changed, 39 insertions(+), 4 deletions(-) diff --git a/codex-rs/app-server-protocol/src/protocol.rs b/codex-rs/app-server-protocol/src/protocol.rs index 845a2431f4..916560c60b 100644 --- a/codex-rs/app-server-protocol/src/protocol.rs +++ b/codex-rs/app-server-protocol/src/protocol.rs @@ -9,6 +9,7 @@ use codex_protocol::config_types::ReasoningEffort; use codex_protocol::config_types::ReasoningSummary; use codex_protocol::config_types::SandboxMode; use codex_protocol::config_types::Verbosity; +use codex_protocol::parse_command::ParsedCommand; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::EventMsg; use codex_protocol::protocol::FileChange; @@ -697,6 +698,7 @@ pub struct ExecCommandApprovalParams { pub cwd: PathBuf, #[serde(skip_serializing_if = "Option::is_none")] pub reason: Option, + pub parsed_cmd: Vec, } #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS)] @@ -904,6 +906,9 @@ mod tests { command: vec!["echo".to_string(), "hello".to_string()], cwd: PathBuf::from("/tmp"), reason: Some("because tests".to_string()), + parsed_cmd: vec![ParsedCommand::Unknown { + cmd: "echo hello".to_string(), + }], }; let request = ServerRequest::ExecCommandApproval { request_id: RequestId::Integer(7), @@ -920,6 +925,12 @@ mod tests { "command": ["echo", "hello"], "cwd": "/tmp", "reason": "because tests", + "parsedCmd": [ + { + "type": "unknown", + "cmd": "echo hello" + } + ] } }), serde_json::to_value(&request)?, diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index cb894a51ab..f455ad32e1 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -1284,6 +1284,7 @@ async fn apply_bespoke_event_handling( command, cwd, reason, + parsed_cmd, }) => { let params = ExecCommandApprovalParams { conversation_id, @@ -1291,6 +1292,7 @@ async fn apply_bespoke_event_handling( command, cwd, reason, + parsed_cmd, }; let rx = outgoing .send_request(ServerRequestPayload::ExecCommandApproval(params)) diff --git a/codex-rs/app-server/tests/suite/codex_message_processor_flow.rs b/codex-rs/app-server/tests/suite/codex_message_processor_flow.rs index 4dff2a1575..27731f8430 100644 --- a/codex-rs/app-server/tests/suite/codex_message_processor_flow.rs +++ b/codex-rs/app-server/tests/suite/codex_message_processor_flow.rs @@ -27,6 +27,7 @@ use codex_core::protocol_config_types::ReasoningEffort; use codex_core::protocol_config_types::ReasoningSummary; use codex_core::spawn::CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR; use codex_protocol::config_types::SandboxMode; +use codex_protocol::parse_command::ParsedCommand; use codex_protocol::protocol::Event; use codex_protocol::protocol::EventMsg; use codex_protocol::protocol::InputMessageKind; @@ -311,6 +312,9 @@ async fn test_send_user_turn_changes_approval_policy_behavior() { ], cwd: working_directory.clone(), reason: None, + parsed_cmd: vec![ParsedCommand::Unknown { + cmd: "python3 -c 'print(42)'".to_string() + }], }, params ); diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index adeeb4764b..5e28a8170e 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -620,6 +620,7 @@ impl Session { warn!("Overwriting existing pending approval for sub_id: {event_id}"); } + let parsed_cmd = parse_command(&command); let event = Event { id: event_id, msg: EventMsg::ExecApprovalRequest(ExecApprovalRequestEvent { @@ -627,6 +628,7 @@ impl Session { command, cwd, reason, + parsed_cmd, }), }; self.send_event(event).await; diff --git a/codex-rs/mcp-server/src/codex_tool_runner.rs b/codex-rs/mcp-server/src/codex_tool_runner.rs index f09dc98cbc..370dde0e82 100644 --- a/codex-rs/mcp-server/src/codex_tool_runner.rs +++ b/codex-rs/mcp-server/src/codex_tool_runner.rs @@ -178,6 +178,7 @@ async fn run_codex_tool_session_inner( cwd, call_id, reason: _, + parsed_cmd, }) => { handle_exec_approval_request( command, @@ -188,6 +189,7 @@ async fn run_codex_tool_session_inner( request_id_str.clone(), event.id.clone(), call_id, + parsed_cmd, ) .await; continue; diff --git a/codex-rs/mcp-server/src/exec_approval.rs b/codex-rs/mcp-server/src/exec_approval.rs index 119481cda9..44607b754d 100644 --- a/codex-rs/mcp-server/src/exec_approval.rs +++ b/codex-rs/mcp-server/src/exec_approval.rs @@ -4,6 +4,7 @@ use std::sync::Arc; use codex_core::CodexConversation; use codex_core::protocol::Op; use codex_core::protocol::ReviewDecision; +use codex_protocol::parse_command::ParsedCommand; use mcp_types::ElicitRequest; use mcp_types::ElicitRequestParamsRequestedSchema; use mcp_types::JSONRPCErrorError; @@ -35,6 +36,7 @@ pub struct ExecApprovalElicitRequestParams { pub codex_call_id: String, pub codex_command: Vec, pub codex_cwd: PathBuf, + pub codex_parsed_cmd: Vec, } // TODO(mbolin): ExecApprovalResponse does not conform to ElicitResult. See: @@ -56,6 +58,7 @@ pub(crate) async fn handle_exec_approval_request( tool_call_id: String, event_id: String, call_id: String, + codex_parsed_cmd: Vec, ) { let escaped_command = shlex::try_join(command.iter().map(String::as_str)).unwrap_or_else(|_| command.join(" ")); @@ -77,6 +80,7 @@ pub(crate) async fn handle_exec_approval_request( codex_call_id: call_id, codex_command: command, codex_cwd: cwd, + codex_parsed_cmd, }; let params_json = match serde_json::to_value(¶ms) { Ok(value) => value, diff --git a/codex-rs/mcp-server/tests/suite/codex_tool.rs b/codex-rs/mcp-server/tests/suite/codex_tool.rs index 13ce3864e3..a26892e5d4 100644 --- a/codex-rs/mcp-server/tests/suite/codex_tool.rs +++ b/codex-rs/mcp-server/tests/suite/codex_tool.rs @@ -3,6 +3,7 @@ use std::env; use std::path::Path; use std::path::PathBuf; +use codex_core::parse_command; use codex_core::protocol::FileChange; use codex_core::protocol::ReviewDecision; use codex_core::spawn::CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR; @@ -176,6 +177,7 @@ fn create_expected_elicitation_request( shlex::try_join(command.iter().map(std::convert::AsRef::as_ref))?, workdir.to_string_lossy() ); + let codex_parsed_cmd = parse_command::parse_command(&command); Ok(JSONRPCRequest { jsonrpc: JSONRPC_VERSION.into(), id: elicitation_request_id, @@ -193,6 +195,7 @@ fn create_expected_elicitation_request( codex_command: command, codex_cwd: workdir.to_path_buf(), codex_call_id: "call1234".to_string(), + codex_parsed_cmd, })?), }) } diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index b73909473d..d18c5338c8 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -1178,6 +1178,7 @@ pub struct ExecApprovalRequestEvent { /// Optional human-readable reason for the approval (e.g. retry without sandbox). #[serde(skip_serializing_if = "Option::is_none")] pub reason: Option, + pub parsed_cmd: Vec, } #[derive(Debug, Clone, Deserialize, Serialize, TS)] diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index a92be43365..64130d55ae 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -392,6 +392,7 @@ fn exec_approval_emits_proposed_command_and_decision_history() { reason: Some( "this is a test reason such as one that would be produced by the model".into(), ), + parsed_cmd: vec![], }; chat.handle_codex_event(Event { id: "sub-short".into(), @@ -433,6 +434,7 @@ fn exec_approval_decision_truncates_multiline_and_long_commands() { reason: Some( "this is a test reason such as one that would be produced by the model".into(), ), + parsed_cmd: vec![], }; chat.handle_codex_event(Event { id: "sub-multi".into(), @@ -480,6 +482,7 @@ fn exec_approval_decision_truncates_multiline_and_long_commands() { command: vec!["bash".into(), "-lc".into(), long], cwd: std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")), reason: None, + parsed_cmd: vec![], }; chat.handle_codex_event(Event { id: "sub-long".into(), @@ -1317,6 +1320,7 @@ fn approval_modal_exec_snapshot() { reason: Some( "this is a test reason such as one that would be produced by the model".into(), ), + parsed_cmd: vec![], }; chat.handle_codex_event(Event { id: "sub-approve".into(), @@ -1360,6 +1364,7 @@ fn approval_modal_exec_without_reason_snapshot() { command: vec!["bash".into(), "-lc".into(), "echo hello world".into()], cwd: std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")), reason: None, + parsed_cmd: vec![], }; chat.handle_codex_event(Event { id: "sub-approve-noreason".into(), @@ -1569,6 +1574,7 @@ fn status_widget_and_approval_modal_snapshot() { reason: Some( "this is a test reason such as one that would be produced by the model".into(), ), + parsed_cmd: vec![], }; chat.handle_codex_event(Event { id: "sub-approve-exec".into(), diff --git a/codex-rs/tui/tests/fixtures/binary-size-log.jsonl b/codex-rs/tui/tests/fixtures/binary-size-log.jsonl index 392cf55250..2e6bdd40ea 100644 --- a/codex-rs/tui/tests/fixtures/binary-size-log.jsonl +++ b/codex-rs/tui/tests/fixtures/binary-size-log.jsonl @@ -2492,7 +2492,7 @@ {"ts":"2025-08-09T15:51:59.856Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} {"ts":"2025-08-09T15:51:59.858Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} {"ts":"2025-08-09T15:51:59.939Z","dir":"to_tui","kind":"log_line","line":"[INFO codex_core::codex] FunctionCall: {\"command\":[\"bash\",\"-lc\",\"just fix\"],\"with_escalated_permissions\":true,\"justifica"} -{"ts":"2025-08-09T15:51:59.939Z","dir":"to_tui","kind":"codex_event","payload":{"id":"1","msg":{"type":"exec_approval_request","call_id":"call_KOxVodT3X5ci7LJmudvcovhW","command":["bash","-lc","just fix"],"cwd":"/Users/easong/code/codex/codex-rs","reason":"Run clippy with network and system permissions to apply lint fixes across workspace."}}} +{"ts":"2025-08-09T15:51:59.939Z","dir":"to_tui","kind":"codex_event","payload":{"id":"1","msg":{"type":"exec_approval_request","call_id":"call_KOxVodT3X5ci7LJmudvcovhW","command":["bash","-lc","just fix"],"cwd":"/Users/easong/code/codex/codex-rs","reason":"Run clippy with network and system permissions to apply lint fixes across workspace.","parsed_cmd":[]}}} {"ts":"2025-08-09T15:51:59.939Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} {"ts":"2025-08-09T15:51:59.939Z","dir":"to_tui","kind":"insert_history","lines":5} {"ts":"2025-08-09T15:51:59.939Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} @@ -4172,7 +4172,7 @@ {"ts":"2025-08-09T15:53:09.375Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} {"ts":"2025-08-09T15:53:09.376Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} {"ts":"2025-08-09T15:53:09.448Z","dir":"to_tui","kind":"log_line","line":"[INFO codex_core::codex] FunctionCall: {\"command\":[\"bash\",\"-lc\",\"just fix\"],\"with_escalated_permissions\":true,\"justifica"} -{"ts":"2025-08-09T15:53:09.448Z","dir":"to_tui","kind":"codex_event","payload":{"id":"1","msg":{"type":"exec_approval_request","call_id":"call_POl3hxI2xeszBLv9IOM7L2ir","command":["bash","-lc","just fix"],"cwd":"/Users/easong/code/codex/codex-rs","reason":"Clippy needs broader permissions; allow to run and apply lint fixes."}}} +{"ts":"2025-08-09T15:53:09.448Z","dir":"to_tui","kind":"codex_event","payload":{"id":"1","msg":{"type":"exec_approval_request","call_id":"call_POl3hxI2xeszBLv9IOM7L2ir","command":["bash","-lc","just fix"],"cwd":"/Users/easong/code/codex/codex-rs","reason":"Clippy needs broader permissions; allow to run and apply lint fixes.","parsed_cmd":[]}}} {"ts":"2025-08-09T15:53:09.448Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} {"ts":"2025-08-09T15:53:09.449Z","dir":"to_tui","kind":"insert_history","lines":5} {"ts":"2025-08-09T15:53:09.449Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} @@ -7776,7 +7776,7 @@ {"ts":"2025-08-09T15:58:28.583Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} {"ts":"2025-08-09T15:58:28.590Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} {"ts":"2025-08-09T15:58:28.594Z","dir":"to_tui","kind":"log_line","line":"[INFO codex_core::codex] FunctionCall: {\"command\":[\"bash\",\"-lc\",\"cargo test -p codex-core shell::tests::test_current_she"} -{"ts":"2025-08-09T15:58:28.594Z","dir":"to_tui","kind":"codex_event","payload":{"id":"1","msg":{"type":"exec_approval_request","call_id":"call_iMa8Qnw0dYLba4rVysxebmkV","command":["bash","-lc","cargo test -p codex-core shell::tests::test_current_shell_detects_zsh -- --nocapture"],"cwd":"/Users/easong/code/codex/codex-rs","reason":"Run the macOS shell detection test without sandbox limits so dscl can read user shell."}}} +{"ts":"2025-08-09T15:58:28.594Z","dir":"to_tui","kind":"codex_event","payload":{"id":"1","msg":{"type":"exec_approval_request","call_id":"call_iMa8Qnw0dYLba4rVysxebmkV","command":["bash","-lc","cargo test -p codex-core shell::tests::test_current_shell_detects_zsh -- --nocapture"],"cwd":"/Users/easong/code/codex/codex-rs","reason":"Run the macOS shell detection test without sandbox limits so dscl can read user shell.","parsed_cmd":[]}}} {"ts":"2025-08-09T15:58:28.594Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} {"ts":"2025-08-09T15:58:28.594Z","dir":"to_tui","kind":"insert_history","lines":5} {"ts":"2025-08-09T15:58:28.594Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} @@ -8730,7 +8730,7 @@ {"ts":"2025-08-09T15:59:01.983Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} {"ts":"2025-08-09T15:59:01.985Z","dir":"to_tui","kind":"app_event","variant":"Redraw"} {"ts":"2025-08-09T15:59:02.005Z","dir":"to_tui","kind":"log_line","line":"[INFO codex_core::codex] FunctionCall: {\"command\":[\"bash\",\"-lc\",\"cargo test --all-features\"],\"with_escalated_permissions"} -{"ts":"2025-08-09T15:59:02.005Z","dir":"to_tui","kind":"codex_event","payload":{"id":"1","msg":{"type":"exec_approval_request","call_id":"call_JDFGIuFhYCIiQO1Aq2L9lBO1","command":["bash","-lc","cargo test --all-features"],"cwd":"/Users/easong/code/codex/codex-rs","reason":"Run full test suite without sandbox constraints to validate the merge."}}} +{"ts":"2025-08-09T15:59:02.005Z","dir":"to_tui","kind":"codex_event","payload":{"id":"1","msg":{"type":"exec_approval_request","call_id":"call_JDFGIuFhYCIiQO1Aq2L9lBO1","command":["bash","-lc","cargo test --all-features"],"cwd":"/Users/easong/code/codex/codex-rs","reason":"Run full test suite without sandbox constraints to validate the merge.","parsed_cmd":[]}}} {"ts":"2025-08-09T15:59:02.006Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} {"ts":"2025-08-09T15:59:02.006Z","dir":"to_tui","kind":"insert_history","lines":5} {"ts":"2025-08-09T15:59:02.006Z","dir":"to_tui","kind":"app_event","variant":"RequestRedraw"} From 17550fee9e689bf05eee90b273380cb55b9fde00 Mon Sep 17 00:00:00 2001 From: Jeremy Rose <172423086+nornagon-openai@users.noreply.github.com> Date: Wed, 15 Oct 2025 14:39:55 -0700 Subject: [PATCH 04/35] add ^Y and kill-buffer to textarea (#5075) ## Summary - add a kill buffer to the text area and wire Ctrl+Y to yank it - capture text from Ctrl+W, Ctrl+U, and Ctrl+K operations into the kill buffer - add regression coverage ensuring the last kill can be yanked back Fixes #5017 ------ https://chatgpt.com/codex/tasks/task_i_68e95bf06c48832cbf3d2ba8fa2035d2 --- codex-rs/tui/src/bottom_pane/textarea.rs | 94 +++++++++++++++++++++--- 1 file changed, 84 insertions(+), 10 deletions(-) diff --git a/codex-rs/tui/src/bottom_pane/textarea.rs b/codex-rs/tui/src/bottom_pane/textarea.rs index 6687793979..2571f7bb86 100644 --- a/codex-rs/tui/src/bottom_pane/textarea.rs +++ b/codex-rs/tui/src/bottom_pane/textarea.rs @@ -26,6 +26,7 @@ pub(crate) struct TextArea { wrap_cache: RefCell>, preferred_col: Option, elements: Vec, + kill_buffer: String, } #[derive(Debug, Clone)] @@ -48,6 +49,7 @@ impl TextArea { wrap_cache: RefCell::new(None), preferred_col: None, elements: Vec::new(), + kill_buffer: String::new(), } } @@ -57,6 +59,7 @@ impl TextArea { self.wrap_cache.replace(None); self.preferred_col = None; self.elements.clear(); + self.kill_buffer.clear(); } pub fn text(&self) -> &str { @@ -305,6 +308,13 @@ impl TextArea { } => { self.kill_to_end_of_line(); } + KeyEvent { + code: KeyCode::Char('y'), + modifiers: KeyModifiers::CONTROL, + .. + } => { + self.yank(); + } // Cursor movement KeyEvent { @@ -437,7 +447,7 @@ impl TextArea { pub fn delete_backward_word(&mut self) { let start = self.beginning_of_previous_word(); - self.replace_range(start..self.cursor_pos, ""); + self.kill_range(start..self.cursor_pos); } /// Delete text to the right of the cursor using "word" semantics. @@ -448,32 +458,63 @@ impl TextArea { pub fn delete_forward_word(&mut self) { let end = self.end_of_next_word(); if end > self.cursor_pos { - self.replace_range(self.cursor_pos..end, ""); + self.kill_range(self.cursor_pos..end); } } pub fn kill_to_end_of_line(&mut self) { let eol = self.end_of_current_line(); - if self.cursor_pos == eol { + let range = if self.cursor_pos == eol { if eol < self.text.len() { - self.replace_range(self.cursor_pos..eol + 1, ""); + Some(self.cursor_pos..eol + 1) + } else { + None } } else { - self.replace_range(self.cursor_pos..eol, ""); + Some(self.cursor_pos..eol) + }; + + if let Some(range) = range { + self.kill_range(range); } } pub fn kill_to_beginning_of_line(&mut self) { let bol = self.beginning_of_current_line(); - if self.cursor_pos == bol { - if bol > 0 { - self.replace_range(bol - 1..bol, ""); - } + let range = if self.cursor_pos == bol { + if bol > 0 { Some(bol - 1..bol) } else { None } } else { - self.replace_range(bol..self.cursor_pos, ""); + Some(bol..self.cursor_pos) + }; + + if let Some(range) = range { + self.kill_range(range); } } + pub fn yank(&mut self) { + if self.kill_buffer.is_empty() { + return; + } + let text = self.kill_buffer.clone(); + self.insert_str(&text); + } + + fn kill_range(&mut self, range: Range) { + let range = self.expand_range_to_element_boundaries(range); + if range.start >= range.end { + return; + } + + let removed = self.text[range.clone()].to_string(); + if removed.is_empty() { + return; + } + + self.kill_buffer = removed; + self.replace_range_raw(range, ""); + } + /// Move the cursor left by a single grapheme cluster. pub fn move_cursor_left(&mut self) { self.cursor_pos = self.prev_atomic_boundary(self.cursor_pos); @@ -1198,6 +1239,39 @@ mod tests { assert_eq!(t.cursor(), elem_range.start); } + #[test] + fn yank_restores_last_kill() { + let mut t = ta_with("hello"); + t.set_cursor(0); + t.kill_to_end_of_line(); + assert_eq!(t.text(), ""); + assert_eq!(t.cursor(), 0); + + t.yank(); + assert_eq!(t.text(), "hello"); + assert_eq!(t.cursor(), 5); + + let mut t = ta_with("hello world"); + t.set_cursor(t.text().len()); + t.delete_backward_word(); + assert_eq!(t.text(), "hello "); + assert_eq!(t.cursor(), 6); + + t.yank(); + assert_eq!(t.text(), "hello world"); + assert_eq!(t.cursor(), 11); + + let mut t = ta_with("hello"); + t.set_cursor(5); + t.kill_to_beginning_of_line(); + assert_eq!(t.text(), ""); + assert_eq!(t.cursor(), 0); + + t.yank(); + assert_eq!(t.text(), "hello"); + assert_eq!(t.cursor(), 5); + } + #[test] fn cursor_left_and_right_handle_graphemes() { let mut t = ta_with("a👍b"); From 18d00e36b9b89f42e0bd8c8da128ca2069e1ebf3 Mon Sep 17 00:00:00 2001 From: joshka-oai Date: Wed, 15 Oct 2025 14:57:05 -0700 Subject: [PATCH 05/35] feat(tui): warn high effort rate use (#5035) Highlight that selecting a high reasoning level will hit Plus plan rate limits faster. --- .../src/bottom_pane/list_selection_view.rs | 7 ++++++- codex-rs/tui/src/chatwidget.rs | 21 ++++++++++++++++--- ...ests__model_reasoning_selection_popup.snap | 5 +++-- 3 files changed, 27 insertions(+), 6 deletions(-) diff --git a/codex-rs/tui/src/bottom_pane/list_selection_view.rs b/codex-rs/tui/src/bottom_pane/list_selection_view.rs index 1e8d1983b0..44d7b26402 100644 --- a/codex-rs/tui/src/bottom_pane/list_selection_view.rs +++ b/codex-rs/tui/src/bottom_pane/list_selection_view.rs @@ -37,6 +37,7 @@ pub(crate) struct SelectionItem { pub name: String, pub display_shortcut: Option, pub description: Option, + pub selected_description: Option, pub is_current: bool, pub actions: Vec, pub dismiss_on_select: bool, @@ -193,12 +194,16 @@ impl ListSelectionView { } else { format!("{prefix} {n}. {name_with_marker}") }; + let description = is_selected + .then(|| item.selected_description.clone()) + .flatten() + .or_else(|| item.description.clone()); GenericDisplayRow { name: display_name, display_shortcut: item.display_shortcut, match_indices: None, is_current: item.is_current, - description: item.description.clone(), + description, } }) }) diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 787e098093..bd0893bc6d 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -53,6 +53,8 @@ use ratatui::buffer::Buffer; use ratatui::layout::Constraint; use ratatui::layout::Layout; use ratatui::layout::Rect; +use ratatui::style::Stylize; +use ratatui::text::Line; use ratatui::widgets::Widget; use ratatui::widgets::WidgetRef; use tokio::sync::mpsc::UnboundedSender; @@ -81,6 +83,7 @@ use crate::history_cell::AgentMessageCell; use crate::history_cell::HistoryCell; use crate::history_cell::McpToolCallCell; use crate::markdown::append_markdown; +use crate::render::renderable::ColumnRenderable; use crate::slash_command::SlashCommand; use crate::status::RateLimitSnapshotDisplay; use crate::text_formatting::truncate_text; @@ -1718,7 +1721,6 @@ impl ChatWidget { } else { default_choice }; - let mut items: Vec = Vec::new(); for choice in choices.iter() { let effort = choice.display; @@ -1741,6 +1743,14 @@ impl ChatWidget { .map(|preset| preset.description.to_string()) }); + let warning = "⚠ High reasoning effort can quickly consume Plus plan rate limits."; + let show_warning = model_slug == "gpt-5-codex" && effort == ReasoningEffortConfig::High; + let selected_description = show_warning.then(|| { + description + .as_ref() + .map_or(warning.to_string(), |d| format!("{d}\n{warning}")) + }); + let model_for_action = model_slug.clone(); let effort_for_action = choice.stored; let actions: Vec = vec![Box::new(move |tx| { @@ -1770,6 +1780,7 @@ impl ChatWidget { items.push(SelectionItem { name: effort_label, description, + selected_description, is_current: is_current_model && choice.stored == highlight_choice, actions, dismiss_on_select: true, @@ -1777,9 +1788,13 @@ impl ChatWidget { }); } + let mut header = ColumnRenderable::new(); + header.push(Line::from( + format!("Select Reasoning Level for {model_slug}").bold(), + )); + self.bottom_pane.show_selection_view(SelectionViewParams { - title: Some("Select Reasoning Level".to_string()), - subtitle: Some(format!("Reasoning for model {model_slug}")), + header: Box::new(header), footer_hint: Some(standard_popup_hint_line()), items, ..Default::default() diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__model_reasoning_selection_popup.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__model_reasoning_selection_popup.snap index 664ec0f50f..d2ef858a67 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__model_reasoning_selection_popup.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__model_reasoning_selection_popup.snap @@ -2,12 +2,13 @@ source: tui/src/chatwidget/tests.rs expression: popup --- - Select Reasoning Level - Reasoning for model gpt-5-codex + Select Reasoning Level for gpt-5-codex 1. Low Fastest responses with limited reasoning 2. Medium (default) Dynamically adjusts reasoning based on the task › 3. High (current) Maximizes reasoning depth for complex or ambiguous problems + ⚠ High reasoning effort can quickly consume Plus plan + rate limits. Press enter to confirm or esc to go back From 272e13dd90c1f5c65da8da71ef3379297f91726c Mon Sep 17 00:00:00 2001 From: dedrisian-oai Date: Wed, 15 Oct 2025 16:11:20 -0700 Subject: [PATCH 06/35] feat: Auto update approval (#5185) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds an update prompt when the CLI starts: Screenshot 2025-10-14 at 5 53 17 PM --- codex-rs/cli/src/main.rs | 31 +- codex-rs/tui/src/app.rs | 8 + ..._tui__chatwidget__tests__update_popup.snap | 14 + codex-rs/tui/src/lib.rs | 119 +++++-- .../tui/src/onboarding/trust_directory.rs | 32 +- codex-rs/tui/src/selection_list.rs | 35 ++ ...te_prompt__tests__update_prompt_modal.snap | 13 + codex-rs/tui/src/update_prompt.rs | 313 ++++++++++++++++++ codex-rs/tui/src/updates.rs | 36 ++ 9 files changed, 546 insertions(+), 55 deletions(-) create mode 100644 codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__update_popup.snap create mode 100644 codex-rs/tui/src/selection_list.rs create mode 100644 codex-rs/tui/src/snapshots/codex_tui__update_prompt__tests__update_prompt_modal.snap create mode 100644 codex-rs/tui/src/update_prompt.rs diff --git a/codex-rs/cli/src/main.rs b/codex-rs/cli/src/main.rs index 81b795b0a1..71fee4fd6b 100644 --- a/codex-rs/cli/src/main.rs +++ b/codex-rs/cli/src/main.rs @@ -19,6 +19,7 @@ use codex_exec::Cli as ExecCli; use codex_responses_api_proxy::Args as ResponsesApiProxyArgs; use codex_tui::AppExitInfo; use codex_tui::Cli as TuiCli; +use codex_tui::UpdateAction; use owo_colors::OwoColorize; use std::path::PathBuf; use supports_color::Stream; @@ -208,6 +209,7 @@ fn format_exit_messages(exit_info: AppExitInfo, color_enabled: bool) -> Vec Vec anyhow::Result<()> { + let update_action = exit_info.update_action; let color_enabled = supports_color::on(Stream::Stdout).is_some(); for line in format_exit_messages(exit_info, color_enabled) { println!("{line}"); } + if let Some(action) = update_action { + run_update_action(action)?; + } + Ok(()) +} + +/// Run the update action and print the result. +fn run_update_action(action: UpdateAction) -> anyhow::Result<()> { + println!(); + let (cmd, args) = action.command_args(); + let cmd_str = action.command_str(); + println!("Updating Codex via `{cmd_str}`..."); + let status = std::process::Command::new(cmd).args(args).status()?; + if !status.success() { + anyhow::bail!("`{cmd_str}` failed with status {status}"); + } + println!(); + println!("🎉 Update ran successfully! Please restart Codex."); + Ok(()) } #[derive(Debug, Default, Parser, Clone)] @@ -321,7 +344,7 @@ async fn cli_main(codex_linux_sandbox_exe: Option) -> anyhow::Result<() root_config_overrides.clone(), ); let exit_info = codex_tui::run_main(interactive, codex_linux_sandbox_exe).await?; - print_exit_messages(exit_info); + handle_app_exit(exit_info)?; } Some(Subcommand::Exec(mut exec_cli)) => { prepend_config_flags( @@ -354,7 +377,7 @@ async fn cli_main(codex_linux_sandbox_exe: Option) -> anyhow::Result<() config_overrides, ); let exit_info = codex_tui::run_main(interactive, codex_linux_sandbox_exe).await?; - print_exit_messages(exit_info); + handle_app_exit(exit_info)?; } Some(Subcommand::Login(mut login_cli)) => { prepend_config_flags( @@ -595,6 +618,7 @@ mod tests { conversation_id: conversation .map(ConversationId::from_string) .map(Result::unwrap), + update_action: None, } } @@ -603,6 +627,7 @@ mod tests { let exit_info = AppExitInfo { token_usage: TokenUsage::default(), conversation_id: None, + update_action: None, }; let lines = format_exit_messages(exit_info, false); assert!(lines.is_empty()); diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 2906e3a694..6124f377ec 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -1,3 +1,4 @@ +use crate::UpdateAction; use crate::app_backtrack::BacktrackState; use crate::app_event::AppEvent; use crate::app_event_sender::AppEventSender; @@ -43,6 +44,7 @@ use tokio::sync::mpsc::unbounded_channel; pub struct AppExitInfo { pub token_usage: TokenUsage, pub conversation_id: Option, + pub update_action: Option, } pub(crate) struct App { @@ -71,6 +73,9 @@ pub(crate) struct App { // Esc-backtracking state grouped pub(crate) backtrack: crate::app_backtrack::BacktrackState, + + /// Set when the user confirms an update; propagated on exit. + pub(crate) pending_update_action: Option, } impl App { @@ -152,6 +157,7 @@ impl App { has_emitted_history_lines: false, commit_anim_running: Arc::new(AtomicBool::new(false)), backtrack: BacktrackState::default(), + pending_update_action: None, }; let tui_events = tui.event_stream(); @@ -171,6 +177,7 @@ impl App { Ok(AppExitInfo { token_usage: app.token_usage(), conversation_id: app.chat_widget.conversation_id(), + update_action: app.pending_update_action, }) } @@ -521,6 +528,7 @@ mod tests { enhanced_keys_supported: false, commit_anim_running: Arc::new(AtomicBool::new(false)), backtrack: BacktrackState::default(), + pending_update_action: None, } } diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__update_popup.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__update_popup.snap new file mode 100644 index 0000000000..6a49cb253c --- /dev/null +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__update_popup.snap @@ -0,0 +1,14 @@ +--- +source: tui/src/chatwidget/tests.rs +expression: terminal.backend().vt100().screen().contents() +--- + ✨ New version available! Would you like to update? + + Full release notes: https://github.com/openai/codex/releases/latest + + +› 1. Yes, update now + 2. No, not now + 3. Don't remind me + + Press enter to confirm or esc to go back diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index 7d7412e0a4..01b1bd976a 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -59,6 +59,7 @@ mod pager_overlay; pub mod public_widgets; mod render; mod resume_picker; +mod selection_list; mod session_log; mod shimmer; mod slash_command; @@ -70,7 +71,38 @@ mod terminal_palette; mod text_formatting; mod tui; mod ui_consts; +mod update_prompt; mod version; + +/// Update action the CLI should perform after the TUI exits. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum UpdateAction { + /// Update via `npm install -g @openai/codex@latest`. + NpmGlobalLatest, + /// Update via `bun install -g @openai/codex@latest`. + BunGlobalLatest, + /// Update via `brew upgrade codex`. + BrewUpgrade, +} + +impl UpdateAction { + /// Returns the list of command-line arguments for invoking the update. + pub fn command_args(&self) -> (&'static str, &'static [&'static str]) { + match self { + UpdateAction::NpmGlobalLatest => ("npm", &["install", "-g", "@openai/codex@latest"]), + UpdateAction::BunGlobalLatest => ("bun", &["install", "-g", "@openai/codex@latest"]), + UpdateAction::BrewUpgrade => ("brew", &["upgrade", "codex"]), + } + } + + /// Returns string representation of the command-line arguments for invoking the update. + pub fn command_str(&self) -> String { + let (command, args) = self.command_args(); + let args_str = args.join(" "); + format!("{command} {args_str}") + } +} + mod wrapping; #[cfg(test)] @@ -299,6 +331,26 @@ async fn run_ratatui_app( let mut tui = Tui::new(terminal); + #[cfg(not(debug_assertions))] + { + use crate::update_prompt::UpdatePromptOutcome; + + let skip_update_prompt = cli.prompt.as_ref().is_some_and(|prompt| !prompt.is_empty()); + if !skip_update_prompt { + match update_prompt::run_update_prompt_if_needed(&mut tui, &config).await? { + UpdatePromptOutcome::Continue => {} + UpdatePromptOutcome::RunUpdate(action) => { + crate::tui::restore()?; + return Ok(AppExitInfo { + token_usage: codex_core::protocol::TokenUsage::default(), + conversation_id: None, + update_action: Some(action), + }); + } + } + } + } + // Show update banner in terminal history (instead of stderr) so it is visible // within the TUI scrollback. Building spans keeps styling consistent. #[cfg(not(debug_assertions))] @@ -309,9 +361,6 @@ async fn run_ratatui_app( use ratatui::text::Line; let current_version = env!("CARGO_PKG_VERSION"); - let exe = std::env::current_exe()?; - let managed_by_bun = std::env::var_os("CODEX_MANAGED_BY_BUN").is_some(); - let managed_by_npm = std::env::var_os("CODEX_MANAGED_BY_NPM").is_some(); let mut content_lines: Vec> = vec![ Line::from(vec![ @@ -331,27 +380,10 @@ async fn run_ratatui_app( Line::from(""), ]; - if managed_by_bun { - let bun_cmd = "bun install -g @openai/codex@latest"; + if let Some(update_action) = get_update_action() { content_lines.push(Line::from(vec![ "Run ".into(), - bun_cmd.cyan(), - " to update.".into(), - ])); - } else if managed_by_npm { - let npm_cmd = "npm install -g @openai/codex@latest"; - content_lines.push(Line::from(vec![ - "Run ".into(), - npm_cmd.cyan(), - " to update.".into(), - ])); - } else if cfg!(target_os = "macos") - && (exe.starts_with("/opt/homebrew") || exe.starts_with("/usr/local")) - { - let brew_cmd = "brew upgrade codex"; - content_lines.push(Line::from(vec![ - "Run ".into(), - brew_cmd.cyan(), + update_action.command_str().cyan(), " to update.".into(), ])); } else { @@ -405,6 +437,7 @@ async fn run_ratatui_app( return Ok(AppExitInfo { token_usage: codex_core::protocol::TokenUsage::default(), conversation_id: None, + update_action: None, }); } if should_show_windows_wsl_screen { @@ -449,6 +482,7 @@ async fn run_ratatui_app( return Ok(AppExitInfo { token_usage: codex_core::protocol::TokenUsage::default(), conversation_id: None, + update_action: None, }); } other => other, @@ -477,6 +511,47 @@ async fn run_ratatui_app( app_result } +/// Get the update action from the environment. +/// Returns `None` if not managed by npm, bun, or brew. +#[cfg(not(debug_assertions))] +pub(crate) fn get_update_action() -> Option { + let exe = std::env::current_exe().unwrap_or_default(); + let managed_by_npm = std::env::var_os("CODEX_MANAGED_BY_NPM").is_some(); + let managed_by_bun = std::env::var_os("CODEX_MANAGED_BY_BUN").is_some(); + if managed_by_npm { + Some(UpdateAction::NpmGlobalLatest) + } else if managed_by_bun { + Some(UpdateAction::BunGlobalLatest) + } else if cfg!(target_os = "macos") + && (exe.starts_with("/opt/homebrew") || exe.starts_with("/usr/local")) + { + Some(UpdateAction::BrewUpgrade) + } else { + None + } +} + +#[test] +#[cfg(not(debug_assertions))] +fn test_get_update_action() { + let prev = std::env::var_os("CODEX_MANAGED_BY_NPM"); + + // First: no npm var -> expect None (we do not run from brew in CI) + unsafe { std::env::remove_var("CODEX_MANAGED_BY_NPM") }; + assert_eq!(get_update_action(), None); + + // Then: with npm var -> expect NpmGlobalLatest + unsafe { std::env::set_var("CODEX_MANAGED_BY_NPM", "1") }; + assert_eq!(get_update_action(), Some(UpdateAction::NpmGlobalLatest)); + + // Restore prior value to avoid leaking state + if let Some(v) = prev { + unsafe { std::env::set_var("CODEX_MANAGED_BY_NPM", v) }; + } else { + unsafe { std::env::remove_var("CODEX_MANAGED_BY_NPM") }; + } +} + #[expect( clippy::print_stderr, reason = "TUI should no longer be displayed, so we can write to stderr." diff --git a/codex-rs/tui/src/onboarding/trust_directory.rs b/codex-rs/tui/src/onboarding/trust_directory.rs index 3324c3d866..8a7541c022 100644 --- a/codex-rs/tui/src/onboarding/trust_directory.rs +++ b/codex-rs/tui/src/onboarding/trust_directory.rs @@ -7,8 +7,6 @@ use crossterm::event::KeyEvent; use crossterm::event::KeyEventKind; use ratatui::buffer::Buffer; use ratatui::layout::Rect; -use ratatui::style::Style; -use ratatui::style::Styled as _; use ratatui::style::Stylize; use ratatui::text::Line; use ratatui::widgets::Paragraph; @@ -22,11 +20,9 @@ use crate::render::Insets; use crate::render::renderable::ColumnRenderable; use crate::render::renderable::Renderable; use crate::render::renderable::RenderableExt as _; -use crate::render::renderable::RowRenderable; +use crate::selection_list::selection_option_row; use super::onboarding_screen::StepState; -use unicode_width::UnicodeWidthStr; - pub(crate) struct TrustDirectoryWidget { pub codex_home: PathBuf, pub cwd: PathBuf, @@ -88,7 +84,7 @@ impl WidgetRef for &TrustDirectoryWidget { } for (idx, (text, selection)) in options.iter().enumerate() { - column.push(new_option_row( + column.push(selection_option_row( idx, text.to_string(), self.highlighted == *selection, @@ -120,30 +116,6 @@ impl WidgetRef for &TrustDirectoryWidget { } } -fn new_option_row(index: usize, label: String, is_selected: bool) -> Box { - let prefix = if is_selected { - format!("› {}. ", index + 1) - } else { - format!(" {}. ", index + 1) - }; - - let mut style = Style::default(); - if is_selected { - style = style.cyan(); - } - - let mut row = RowRenderable::new(); - row.push(prefix.width() as u16, prefix.set_style(style)); - row.push( - u16::MAX, - Paragraph::new(label) - .style(style) - .wrap(Wrap { trim: false }), - ); - - row.into() -} - impl KeyboardHandler for TrustDirectoryWidget { fn handle_key_event(&mut self, key_event: KeyEvent) { if key_event.kind == KeyEventKind::Release { diff --git a/codex-rs/tui/src/selection_list.rs b/codex-rs/tui/src/selection_list.rs new file mode 100644 index 0000000000..4816735437 --- /dev/null +++ b/codex-rs/tui/src/selection_list.rs @@ -0,0 +1,35 @@ +use crate::render::renderable::Renderable; +use crate::render::renderable::RowRenderable; +use ratatui::style::Style; +use ratatui::style::Styled as _; +use ratatui::style::Stylize as _; +use ratatui::widgets::Paragraph; +use ratatui::widgets::Wrap; +use unicode_width::UnicodeWidthStr; + +pub(crate) fn selection_option_row( + index: usize, + label: String, + is_selected: bool, +) -> Box { + let prefix = if is_selected { + format!("› {}. ", index + 1) + } else { + format!(" {}. ", index + 1) + }; + let style = if is_selected { + Style::default().cyan() + } else { + Style::default() + }; + let prefix_width = UnicodeWidthStr::width(prefix.as_str()) as u16; + let mut row = RowRenderable::new(); + row.push(prefix_width, prefix.set_style(style)); + row.push( + u16::MAX, + Paragraph::new(label) + .style(style) + .wrap(Wrap { trim: false }), + ); + row.into() +} diff --git a/codex-rs/tui/src/snapshots/codex_tui__update_prompt__tests__update_prompt_modal.snap b/codex-rs/tui/src/snapshots/codex_tui__update_prompt__tests__update_prompt_modal.snap new file mode 100644 index 0000000000..24d8831c95 --- /dev/null +++ b/codex-rs/tui/src/snapshots/codex_tui__update_prompt__tests__update_prompt_modal.snap @@ -0,0 +1,13 @@ +--- +source: tui/src/update_prompt.rs +expression: terminal.backend() +--- + ✨ Update available! 0.0.0 -> 9.9.9 + + Release notes: https://github.com/openai/codex/releases/latest + +› 1. Update now (runs `npm install -g @openai/codex@latest`) + 2. Skip + 3. Skip until next version + + Press enter to continue diff --git a/codex-rs/tui/src/update_prompt.rs b/codex-rs/tui/src/update_prompt.rs new file mode 100644 index 0000000000..5da8462684 --- /dev/null +++ b/codex-rs/tui/src/update_prompt.rs @@ -0,0 +1,313 @@ +#![cfg(not(debug_assertions))] + +use crate::UpdateAction; +use crate::history_cell::padded_emoji; +use crate::key_hint; +use crate::render::Insets; +use crate::render::renderable::ColumnRenderable; +use crate::render::renderable::Renderable; +use crate::render::renderable::RenderableExt as _; +use crate::selection_list::selection_option_row; +use crate::tui::FrameRequester; +use crate::tui::Tui; +use crate::tui::TuiEvent; +use crate::updates; +use codex_core::config::Config; +use color_eyre::Result; +use crossterm::event::KeyCode; +use crossterm::event::KeyEvent; +use crossterm::event::KeyEventKind; +use crossterm::event::KeyModifiers; +use ratatui::buffer::Buffer; +use ratatui::layout::Rect; +use ratatui::prelude::Widget; +use ratatui::style::Stylize as _; +use ratatui::text::Line; +use ratatui::widgets::Clear; +use ratatui::widgets::WidgetRef; +use tokio_stream::StreamExt; + +pub(crate) enum UpdatePromptOutcome { + Continue, + RunUpdate(UpdateAction), +} + +pub(crate) async fn run_update_prompt_if_needed( + tui: &mut Tui, + config: &Config, +) -> Result { + let Some(latest_version) = updates::get_upgrade_version_for_popup(config) else { + return Ok(UpdatePromptOutcome::Continue); + }; + let Some(update_action) = crate::get_update_action() else { + return Ok(UpdatePromptOutcome::Continue); + }; + + let mut screen = + UpdatePromptScreen::new(tui.frame_requester(), latest_version.clone(), update_action); + tui.draw(u16::MAX, |frame| { + frame.render_widget_ref(&screen, frame.area()); + })?; + + let events = tui.event_stream(); + tokio::pin!(events); + + while !screen.is_done() { + if let Some(event) = events.next().await { + match event { + TuiEvent::Key(key_event) => screen.handle_key(key_event), + TuiEvent::Paste(_) => {} + TuiEvent::Draw => { + tui.draw(u16::MAX, |frame| { + frame.render_widget_ref(&screen, frame.area()); + })?; + } + } + } else { + break; + } + } + + match screen.selection() { + Some(UpdateSelection::UpdateNow) => { + tui.terminal.clear()?; + Ok(UpdatePromptOutcome::RunUpdate(update_action)) + } + Some(UpdateSelection::NotNow) | None => Ok(UpdatePromptOutcome::Continue), + Some(UpdateSelection::DontRemind) => { + if let Err(err) = updates::dismiss_version(config, screen.latest_version()).await { + tracing::error!("Failed to persist update dismissal: {err}"); + } + Ok(UpdatePromptOutcome::Continue) + } + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum UpdateSelection { + UpdateNow, + NotNow, + DontRemind, +} + +struct UpdatePromptScreen { + request_frame: FrameRequester, + latest_version: String, + current_version: String, + update_action: UpdateAction, + highlighted: UpdateSelection, + selection: Option, +} + +impl UpdatePromptScreen { + fn new( + request_frame: FrameRequester, + latest_version: String, + update_action: UpdateAction, + ) -> Self { + Self { + request_frame, + latest_version, + current_version: env!("CARGO_PKG_VERSION").to_string(), + update_action, + highlighted: UpdateSelection::UpdateNow, + selection: None, + } + } + + fn handle_key(&mut self, key_event: KeyEvent) { + if key_event.kind == KeyEventKind::Release { + return; + } + if key_event.modifiers.contains(KeyModifiers::CONTROL) + && matches!(key_event.code, KeyCode::Char('c') | KeyCode::Char('d')) + { + self.select(UpdateSelection::NotNow); + return; + } + match key_event.code { + KeyCode::Up | KeyCode::Char('k') => self.set_highlight(self.highlighted.prev()), + KeyCode::Down | KeyCode::Char('j') => self.set_highlight(self.highlighted.next()), + KeyCode::Char('1') => self.select(UpdateSelection::UpdateNow), + KeyCode::Char('2') => self.select(UpdateSelection::NotNow), + KeyCode::Char('3') => self.select(UpdateSelection::DontRemind), + KeyCode::Enter => self.select(self.highlighted), + KeyCode::Esc => self.select(UpdateSelection::NotNow), + _ => {} + } + } + + fn set_highlight(&mut self, highlight: UpdateSelection) { + if self.highlighted != highlight { + self.highlighted = highlight; + self.request_frame.schedule_frame(); + } + } + + fn select(&mut self, selection: UpdateSelection) { + self.highlighted = selection; + self.selection = Some(selection); + self.request_frame.schedule_frame(); + } + + fn is_done(&self) -> bool { + self.selection.is_some() + } + + fn selection(&self) -> Option { + self.selection + } + + fn latest_version(&self) -> &str { + self.latest_version.as_str() + } +} + +impl UpdateSelection { + fn next(self) -> Self { + match self { + UpdateSelection::UpdateNow => UpdateSelection::NotNow, + UpdateSelection::NotNow => UpdateSelection::DontRemind, + UpdateSelection::DontRemind => UpdateSelection::UpdateNow, + } + } + + fn prev(self) -> Self { + match self { + UpdateSelection::UpdateNow => UpdateSelection::DontRemind, + UpdateSelection::NotNow => UpdateSelection::UpdateNow, + UpdateSelection::DontRemind => UpdateSelection::NotNow, + } + } +} + +impl WidgetRef for &UpdatePromptScreen { + fn render_ref(&self, area: Rect, buf: &mut Buffer) { + Clear.render(area, buf); + let mut column = ColumnRenderable::new(); + + let update_command = self.update_action.command_str(); + + column.push(""); + column.push(Line::from(vec![ + padded_emoji(" ✨").bold().cyan(), + "Update available!".bold(), + " ".into(), + format!( + "{current} -> {latest}", + current = self.current_version, + latest = self.latest_version + ) + .dim(), + ])); + column.push(""); + column.push( + Line::from(vec![ + "Release notes: ".dim(), + "https://github.com/openai/codex/releases/latest" + .dim() + .underlined(), + ]) + .inset(Insets::tlbr(0, 2, 0, 0)), + ); + column.push(""); + column.push(selection_option_row( + 0, + format!("Update now (runs `{update_command}`)"), + self.highlighted == UpdateSelection::UpdateNow, + )); + column.push(selection_option_row( + 1, + "Skip".to_string(), + self.highlighted == UpdateSelection::NotNow, + )); + column.push(selection_option_row( + 2, + "Skip until next version".to_string(), + self.highlighted == UpdateSelection::DontRemind, + )); + column.push(""); + column.push( + Line::from(vec![ + "Press ".dim(), + key_hint::plain(KeyCode::Enter).into(), + " to continue".dim(), + ]) + .inset(Insets::tlbr(0, 2, 0, 0)), + ); + column.render(area, buf); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::test_backend::VT100Backend; + use crate::tui::FrameRequester; + use crossterm::event::KeyCode; + use crossterm::event::KeyEvent; + use crossterm::event::KeyModifiers; + use ratatui::Terminal; + + fn new_prompt() -> UpdatePromptScreen { + UpdatePromptScreen::new( + FrameRequester::test_dummy(), + "9.9.9".into(), + UpdateAction::NpmGlobalLatest, + ) + } + + #[test] + fn update_prompt_snapshot() { + let screen = new_prompt(); + let mut terminal = Terminal::new(VT100Backend::new(80, 12)).expect("terminal"); + terminal + .draw(|frame| frame.render_widget_ref(&screen, frame.area())) + .expect("render update prompt"); + insta::assert_snapshot!("update_prompt_modal", terminal.backend()); + } + + #[test] + fn update_prompt_confirm_selects_update() { + let mut screen = new_prompt(); + screen.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + assert!(screen.is_done()); + assert_eq!(screen.selection(), Some(UpdateSelection::UpdateNow)); + } + + #[test] + fn update_prompt_dismiss_option_leaves_prompt_in_normal_state() { + let mut screen = new_prompt(); + screen.handle_key(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE)); + screen.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + assert!(screen.is_done()); + assert_eq!(screen.selection(), Some(UpdateSelection::NotNow)); + } + + #[test] + fn update_prompt_dont_remind_selects_dismissal() { + let mut screen = new_prompt(); + screen.handle_key(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE)); + screen.handle_key(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE)); + screen.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + assert!(screen.is_done()); + assert_eq!(screen.selection(), Some(UpdateSelection::DontRemind)); + } + + #[test] + fn update_prompt_ctrl_c_skips_update() { + let mut screen = new_prompt(); + screen.handle_key(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL)); + assert!(screen.is_done()); + assert_eq!(screen.selection(), Some(UpdateSelection::NotNow)); + } + + #[test] + fn update_prompt_navigation_wraps_between_entries() { + let mut screen = new_prompt(); + screen.handle_key(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE)); + assert_eq!(screen.highlighted, UpdateSelection::DontRemind); + screen.handle_key(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE)); + assert_eq!(screen.highlighted, UpdateSelection::UpdateNow); + } +} diff --git a/codex-rs/tui/src/updates.rs b/codex-rs/tui/src/updates.rs index 7344e24c78..ca004a36df 100644 --- a/codex-rs/tui/src/updates.rs +++ b/codex-rs/tui/src/updates.rs @@ -45,6 +45,8 @@ struct VersionInfo { latest_version: String, // ISO-8601 timestamp (RFC3339) last_checked_at: DateTime, + #[serde(default)] + dismissed_version: Option, } #[derive(Deserialize, Debug, Clone)] @@ -75,12 +77,15 @@ async fn check_for_update(version_file: &Path) -> anyhow::Result<()> { .json::() .await?; + // Preserve any previously dismissed version if present. + let prev_info = read_version_info(version_file).ok(); let info = VersionInfo { latest_version: latest_tag_name .strip_prefix("rust-v") .ok_or_else(|| anyhow::anyhow!("Failed to parse latest tag name '{latest_tag_name}'"))? .into(), last_checked_at: Utc::now(), + dismissed_version: prev_info.and_then(|p| p.dismissed_version), }; let json_line = format!("{}\n", serde_json::to_string(&info)?); @@ -98,6 +103,37 @@ fn is_newer(latest: &str, current: &str) -> Option { } } +/// Returns the latest version to show in a popup, if it should be shown. +/// This respects the user's dismissal choice for the current latest version. +pub fn get_upgrade_version_for_popup(config: &Config) -> Option { + let version_file = version_filepath(config); + let latest = get_upgrade_version(config)?; + // If the user dismissed this exact version previously, do not show the popup. + if let Ok(info) = read_version_info(&version_file) + && info.dismissed_version.as_deref() == Some(latest.as_str()) + { + return None; + } + Some(latest) +} + +/// Persist a dismissal for the current latest version so we don't show +/// the update popup again for this version. +pub async fn dismiss_version(config: &Config, version: &str) -> anyhow::Result<()> { + let version_file = version_filepath(config); + let mut info = match read_version_info(&version_file) { + Ok(info) => info, + Err(_) => return Ok(()), + }; + info.dismissed_version = Some(version.to_string()); + let json_line = format!("{}\n", serde_json::to_string(&info)?); + if let Some(parent) = version_file.parent() { + tokio::fs::create_dir_all(parent).await?; + } + tokio::fs::write(version_file, json_line).await?; + Ok(()) +} + fn parse_version(v: &str) -> Option<(u64, u64, u64)> { let mut iter = v.trim().split('.'); let maj = iter.next()?.parse::().ok()?; From 84c9b574f93b5c865de31412141593bb4242b0ed Mon Sep 17 00:00:00 2001 From: Javi <666807+JaviSoto@users.noreply.github.com> Date: Wed, 15 Oct 2025 16:31:52 -0700 Subject: [PATCH 07/35] feat(releases): Add macOS notarization step to release workflow (#5233) Also: fixed the contents of the `APPLE_CERTIFICATE_P12` and `APPLE_CERTIFICATE_PASSWORD` secrets, so the code-signing step will use the right certificate now. --- .github/workflows/rust-release.yml | 63 ++++++++++++++++++++++++++++++ 1 file changed, 63 insertions(+) diff --git a/.github/workflows/rust-release.yml b/.github/workflows/rust-release.yml index be04615699..94143acb49 100644 --- a/.github/workflows/rust-release.yml +++ b/.github/workflows/rust-release.yml @@ -206,6 +206,69 @@ jobs: codesign --force --options runtime --timestamp --sign "$APPLE_CODESIGN_IDENTITY" "${keychain_args[@]}" "$path" done + - if: ${{ matrix.runner == 'macos-14' }} + name: Notarize macOS binaries + shell: bash + env: + APPLE_NOTARIZATION_KEY_P8: ${{ secrets.APPLE_NOTARIZATION_KEY_P8 }} + APPLE_NOTARIZATION_KEY_ID: ${{ secrets.APPLE_NOTARIZATION_KEY_ID }} + APPLE_NOTARIZATION_ISSUER_ID: ${{ secrets.APPLE_NOTARIZATION_ISSUER_ID }} + run: | + set -euo pipefail + + for var in APPLE_NOTARIZATION_KEY_P8 APPLE_NOTARIZATION_KEY_ID APPLE_NOTARIZATION_ISSUER_ID; do + if [[ -z "${!var:-}" ]]; then + echo "$var is required for notarization" + exit 1 + fi + done + + notary_key_path="${RUNNER_TEMP}/notarytool.key.p8" + echo "$APPLE_NOTARIZATION_KEY_P8" | base64 -d > "$notary_key_path" + cleanup_notary() { + rm -f "$notary_key_path" + } + trap cleanup_notary EXIT + + notarize_binary() { + local binary="$1" + local source_path="target/${{ matrix.target }}/release/${binary}" + local archive_path="${RUNNER_TEMP}/${binary}.zip" + + if [[ ! -f "$source_path" ]]; then + echo "Binary $source_path not found" + exit 1 + fi + + rm -f "$archive_path" + ditto -c -k --keepParent "$source_path" "$archive_path" + + submission_json=$(xcrun notarytool submit "$archive_path" \ + --key "$notary_key_path" \ + --key-id "$APPLE_NOTARIZATION_KEY_ID" \ + --issuer "$APPLE_NOTARIZATION_ISSUER_ID" \ + --output-format json \ + --wait) + + status=$(printf '%s\n' "$submission_json" | jq -r '.status // "Unknown"') + submission_id=$(printf '%s\n' "$submission_json" | jq -r '.id // ""') + + if [[ -z "$submission_id" ]]; then + echo "Failed to retrieve submission ID for $binary" + exit 1 + fi + + echo "::notice title=Notarization::$binary submission ${submission_id} completed with status ${status}" + + if [[ "$status" != "Accepted" ]]; then + echo "Notarization failed for ${binary} (submission ${submission_id}, status ${status})" + exit 1 + fi + } + + notarize_binary "codex" + notarize_binary "codex-responses-api-proxy" + - name: Stage artifacts shell: bash run: | From 5fa7844ad728946b2760bb08b4fa1db98f778ebf Mon Sep 17 00:00:00 2001 From: Javi <666807+JaviSoto@users.noreply.github.com> Date: Wed, 15 Oct 2025 16:50:18 -0700 Subject: [PATCH 08/35] feat(CI): Allow running `rust-release` manually and in dry-run mode (#5232) Example: https://github.com/openai/codex/actions/runs/18544852356/job/52860637804 --- .github/workflows/rust-release.yml | 23 ++++++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/.github/workflows/rust-release.yml b/.github/workflows/rust-release.yml index 94143acb49..7654e696cf 100644 --- a/.github/workflows/rust-release.yml +++ b/.github/workflows/rust-release.yml @@ -10,6 +10,16 @@ on: push: tags: - "rust-v*.*.*" + workflow_dispatch: + inputs: + branch: + description: "Branch or tag to build" + required: true + default: "main" + dry_run: + description: "Skip publishing the release (dry run)" + type: boolean + default: false concurrency: group: ${{ github.workflow }} @@ -20,8 +30,11 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v5 + with: + ref: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.branch || github.ref }} - name: Validate tag matches Cargo.toml version + if: ${{ github.event_name == 'push' }} shell: bash run: | set -euo pipefail @@ -45,6 +58,10 @@ jobs: echo "✅ Tag and Cargo.toml agree (${tag_ver})" echo "::endgroup::" + - name: Skip tag validation (manual run) + if: ${{ github.event_name != 'push' }} + run: echo "Tag validation skipped for workflow_dispatch runs." + build: needs: tag-check name: Build - ${{ matrix.runner }} - ${{ matrix.target }} @@ -77,6 +94,8 @@ jobs: steps: - uses: actions/checkout@v5 + with: + ref: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.branch || github.ref }} - uses: dtolnay/rust-toolchain@1.90 with: targets: ${{ matrix.target }} @@ -362,6 +381,7 @@ jobs: needs: build name: release runs-on: ubuntu-latest + if: ${{ github.event_name == 'push' && startsWith(github.ref, 'refs/tags/rust-v') }} permissions: contents: write actions: read @@ -456,7 +476,7 @@ jobs: # npm docs: https://docs.npmjs.com/trusted-publishers publish-npm: # Publish to npm for stable releases and alpha pre-releases with numeric suffixes. - if: ${{ needs.release.outputs.should_publish_npm == 'true' }} + if: ${{ needs.release.result == 'success' && needs.release.outputs.should_publish_npm == 'true' }} name: publish-npm needs: release runs-on: ubuntu-latest @@ -525,6 +545,7 @@ jobs: contents: write needs: release runs-on: ubuntu-latest + if: ${{ needs.release.result == 'success' }} steps: - name: Update latest-alpha-cli branch From c146585cdb45e5ca219ad2677e8696e76e39826a Mon Sep 17 00:00:00 2001 From: Anton Panasenko Date: Wed, 15 Oct 2025 17:53:33 -0700 Subject: [PATCH 09/35] [codex][otel] propagate user email in otel events (#5223) include user email into otel events for proper user-level attribution in case of workspace setup --- codex-rs/core/src/auth.rs | 4 ++++ codex-rs/core/src/client.rs | 1 + codex-rs/core/src/codex.rs | 2 ++ .../core/tests/chat_completions_payload.rs | 1 + codex-rs/core/tests/chat_completions_sse.rs | 1 + codex-rs/core/tests/responses_headers.rs | 1 + codex-rs/core/tests/suite/client.rs | 1 + codex-rs/otel/src/otel_event_manager.rs | 18 +++++++++++++++++- docs/config.md | 2 +- 9 files changed, 29 insertions(+), 2 deletions(-) diff --git a/codex-rs/core/src/auth.rs b/codex-rs/core/src/auth.rs index 4eea313e66..21cb4bf6d6 100644 --- a/codex-rs/core/src/auth.rs +++ b/codex-rs/core/src/auth.rs @@ -135,6 +135,10 @@ impl CodexAuth { self.get_current_token_data().and_then(|t| t.account_id) } + pub fn get_account_email(&self) -> Option { + self.get_current_token_data().and_then(|t| t.id_token.email) + } + pub(crate) fn get_plan_type(&self) -> Option { self.get_current_token_data() .and_then(|t| t.id_token.chatgpt_plan_type) diff --git a/codex-rs/core/src/client.rs b/codex-rs/core/src/client.rs index a8dcb7259a..f59f30c5cf 100644 --- a/codex-rs/core/src/client.rs +++ b/codex-rs/core/src/client.rs @@ -1030,6 +1030,7 @@ mod tests { "test", "test", None, + Some("test@test.com".to_string()), Some(AuthMode::ChatGPT), false, "test".to_string(), diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 5e28a8170e..ce86b808c0 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -445,6 +445,7 @@ impl Session { config.model.as_str(), config.model_family.slug.as_str(), auth_manager.auth().and_then(|a| a.get_account_id()), + auth_manager.auth().and_then(|a| a.get_account_email()), auth_manager.auth().map(|a| a.mode), config.otel.log_user_prompt, terminal::user_agent(), @@ -2744,6 +2745,7 @@ mod tests { config.model.as_str(), config.model_family.slug.as_str(), None, + Some("test@test.com".to_string()), Some(AuthMode::ChatGPT), false, "test".to_string(), diff --git a/codex-rs/core/tests/chat_completions_payload.rs b/codex-rs/core/tests/chat_completions_payload.rs index 9e10b37822..0d0d60d47f 100644 --- a/codex-rs/core/tests/chat_completions_payload.rs +++ b/codex-rs/core/tests/chat_completions_payload.rs @@ -79,6 +79,7 @@ async fn run_request(input: Vec) -> Value { config.model.as_str(), config.model_family.slug.as_str(), None, + Some("test@test.com".to_string()), Some(AuthMode::ChatGPT), false, "test".to_string(), diff --git a/codex-rs/core/tests/chat_completions_sse.rs b/codex-rs/core/tests/chat_completions_sse.rs index 1aab6ac38c..dffc9e4213 100644 --- a/codex-rs/core/tests/chat_completions_sse.rs +++ b/codex-rs/core/tests/chat_completions_sse.rs @@ -78,6 +78,7 @@ async fn run_stream_with_bytes(sse_body: &[u8]) -> Vec { config.model.as_str(), config.model_family.slug.as_str(), None, + Some("test@test.com".to_string()), Some(AuthMode::ChatGPT), false, "test".to_string(), diff --git a/codex-rs/core/tests/responses_headers.rs b/codex-rs/core/tests/responses_headers.rs index 19967b06ea..4a086cf1e3 100644 --- a/codex-rs/core/tests/responses_headers.rs +++ b/codex-rs/core/tests/responses_headers.rs @@ -63,6 +63,7 @@ async fn responses_stream_includes_task_type_header() { config.model.as_str(), config.model_family.slug.as_str(), None, + Some("test@test.com".to_string()), Some(AuthMode::ChatGPT), false, "test".to_string(), diff --git a/codex-rs/core/tests/suite/client.rs b/codex-rs/core/tests/suite/client.rs index eb14dabb81..beaa87a853 100644 --- a/codex-rs/core/tests/suite/client.rs +++ b/codex-rs/core/tests/suite/client.rs @@ -657,6 +657,7 @@ async fn azure_responses_request_includes_store_and_reasoning_ids() { config.model.as_str(), config.model_family.slug.as_str(), None, + Some("test@test.com".to_string()), Some(AuthMode::ChatGPT), false, "test".to_string(), diff --git a/codex-rs/otel/src/otel_event_manager.rs b/codex-rs/otel/src/otel_event_manager.rs index 2d6f278a79..c02ea19fdf 100644 --- a/codex-rs/otel/src/otel_event_manager.rs +++ b/codex-rs/otel/src/otel_event_manager.rs @@ -33,6 +33,7 @@ pub struct OtelEventMetadata { conversation_id: ConversationId, auth_mode: Option, account_id: Option, + account_email: Option, model: String, slug: String, log_user_prompts: bool, @@ -46,11 +47,13 @@ pub struct OtelEventManager { } impl OtelEventManager { + #[allow(clippy::too_many_arguments)] pub fn new( conversation_id: ConversationId, model: &str, slug: &str, account_id: Option, + account_email: Option, auth_mode: Option, log_user_prompts: bool, terminal_type: String, @@ -60,6 +63,7 @@ impl OtelEventManager { conversation_id, auth_mode: auth_mode.map(|m| m.to_string()), account_id, + account_email, model: model.to_owned(), slug: slug.to_owned(), log_user_prompts, @@ -98,6 +102,7 @@ impl OtelEventManager { app.version = %self.metadata.app_version, auth_mode = self.metadata.auth_mode, user.account_id = self.metadata.account_id, + user.email = self.metadata.account_email, terminal.type = %self.metadata.terminal_type, model = %self.metadata.model, slug = %self.metadata.slug, @@ -136,6 +141,7 @@ impl OtelEventManager { app.version = %self.metadata.app_version, auth_mode = self.metadata.auth_mode, user.account_id = self.metadata.account_id, + user.email = self.metadata.account_email, terminal.type = %self.metadata.terminal_type, model = %self.metadata.model, slug = %self.metadata.slug, @@ -205,6 +211,7 @@ impl OtelEventManager { app.version = %self.metadata.app_version, auth_mode = self.metadata.auth_mode, user.account_id = self.metadata.account_id, + user.email = self.metadata.account_email, terminal.type = %self.metadata.terminal_type, model = %self.metadata.model, slug = %self.metadata.slug, @@ -226,6 +233,7 @@ impl OtelEventManager { app.version = %self.metadata.app_version, auth_mode = self.metadata.auth_mode, user.account_id = self.metadata.account_id, + user.email = self.metadata.account_email, terminal.type = %self.metadata.terminal_type, model = %self.metadata.model, slug = %self.metadata.slug, @@ -240,6 +248,7 @@ impl OtelEventManager { app.version = %self.metadata.app_version, auth_mode = self.metadata.auth_mode, user.account_id = self.metadata.account_id, + user.email = self.metadata.account_email, terminal.type = %self.metadata.terminal_type, model = %self.metadata.model, slug = %self.metadata.slug, @@ -262,6 +271,7 @@ impl OtelEventManager { app.version = %self.metadata.app_version, auth_mode = self.metadata.auth_mode, user.account_id = self.metadata.account_id, + user.email = self.metadata.account_email, terminal.type = %self.metadata.terminal_type, model = %self.metadata.model, slug = %self.metadata.slug, @@ -286,6 +296,7 @@ impl OtelEventManager { app.version = %self.metadata.app_version, auth_mode = self.metadata.auth_mode, user.account_id = self.metadata.account_id, + user.email = self.metadata.account_email, terminal.type = %self.metadata.terminal_type, model = %self.metadata.model, slug = %self.metadata.slug, @@ -320,6 +331,7 @@ impl OtelEventManager { app.version = %self.metadata.app_version, auth_mode = self.metadata.auth_mode, user.account_id = self.metadata.account_id, + user.email = self.metadata.account_email, terminal.type = %self.metadata.terminal_type, model = %self.metadata.model, slug = %self.metadata.slug, @@ -343,6 +355,7 @@ impl OtelEventManager { app.version = %self.metadata.app_version, auth_mode = self.metadata.auth_mode, user.account_id = self.metadata.account_id, + user.email = self.metadata.account_email, terminal.type = %self.metadata.terminal_type, model = %self.metadata.model, slug = %self.metadata.slug, @@ -383,7 +396,8 @@ impl OtelEventManager { conversation.id = %self.metadata.conversation_id, app.version = %self.metadata.app_version, auth_mode = self.metadata.auth_mode, - user.account_id = self.metadata.account_id, + user.account_id= self.metadata.account_id, + user.email = self.metadata.account_email, terminal.type = %self.metadata.terminal_type, model = %self.metadata.model, slug = %self.metadata.slug, @@ -408,6 +422,7 @@ impl OtelEventManager { app.version = %self.metadata.app_version, auth_mode = self.metadata.auth_mode, user.account_id = self.metadata.account_id, + user.email = self.metadata.account_email, terminal.type = %self.metadata.terminal_type, model = %self.metadata.model, slug = %self.metadata.slug, @@ -437,6 +452,7 @@ impl OtelEventManager { app.version = %self.metadata.app_version, auth_mode = self.metadata.auth_mode, user.account_id = self.metadata.account_id, + user.email = self.metadata.account_email, terminal.type = %self.metadata.terminal_type, model = %self.metadata.model, slug = %self.metadata.slug, diff --git a/docs/config.md b/docs/config.md index c1d305f35a..ad3290887b 100644 --- a/docs/config.md +++ b/docs/config.md @@ -509,7 +509,7 @@ crate—the events listed below—is forwarded to the exporter. Every event shares a common set of metadata fields: `event.timestamp`, `conversation.id`, `app.version`, `auth_mode` (when available), -`user.account_id` (when available), `terminal.type`, `model`, and `slug`. +`user.account_id` (when available), `user.email` (when available), `terminal.type`, `model`, and `slug`. With OTEL enabled Codex emits the following event types (in addition to the metadata above): From 86ba270926eacf4f4cd499b26e4e4e37fdcb1820 Mon Sep 17 00:00:00 2001 From: Thibault Sottiaux Date: Wed, 15 Oct 2025 22:24:46 -0700 Subject: [PATCH 10/35] fix: skip /init when AGENTS.md already exists (#5242) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This change aborts /init if an AGENTS.md already exists to avoid plainly overwriting it. Screenshot 2025-10-15 at 9 43 07 PM --- codex-rs/tui/src/chatwidget.rs | 9 ++++++++ codex-rs/tui/src/chatwidget/tests.rs | 33 ++++++++++++++++++++++++++++ 2 files changed, 42 insertions(+) diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index bd0893bc6d..88249f139b 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -7,6 +7,7 @@ use codex_core::config::Config; use codex_core::config_types::Notifications; use codex_core::git_info::current_branch_name; use codex_core::git_info::local_git_branches; +use codex_core::project_doc::DEFAULT_PROJECT_DOC_FILENAME; use codex_core::protocol::AgentMessageDeltaEvent; use codex_core::protocol::AgentMessageEvent; use codex_core::protocol::AgentReasoningDeltaEvent; @@ -1130,6 +1131,14 @@ impl ChatWidget { self.app_event_tx.send(AppEvent::NewSession); } SlashCommand::Init => { + let init_target = self.config.cwd.join(DEFAULT_PROJECT_DOC_FILENAME); + if init_target.exists() { + let message = format!( + "{DEFAULT_PROJECT_DOC_FILENAME} already exists here. Skipping /init to avoid overwriting it." + ); + self.add_info_message(message, None); + return; + } const INIT_PROMPT: &str = include_str!("../prompt_for_init_command.md"); self.submit_text_message(INIT_PROMPT.to_string()); } diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index 64130d55ae..a9102fdb4b 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -49,6 +49,7 @@ use std::io::BufRead; use std::io::BufReader; use std::path::PathBuf; use tempfile::NamedTempFile; +use tempfile::tempdir; use tokio::sync::mpsc::error::TryRecvError; use tokio::sync::mpsc::unbounded_channel; @@ -769,6 +770,38 @@ fn review_popup_custom_prompt_action_sends_event() { assert!(found, "expected OpenReviewCustomPrompt event to be sent"); } +#[test] +fn slash_init_skips_when_project_doc_exists() { + let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(); + let tempdir = tempdir().unwrap(); + let existing_path = tempdir.path().join(DEFAULT_PROJECT_DOC_FILENAME); + std::fs::write(&existing_path, "existing instructions").unwrap(); + chat.config.cwd = tempdir.path().to_path_buf(); + + chat.dispatch_command(SlashCommand::Init); + + match op_rx.try_recv() { + Err(TryRecvError::Empty) => {} + other => panic!("expected no Codex op to be sent, got {other:?}"), + } + + let cells = drain_insert_history(&mut rx); + assert_eq!(cells.len(), 1, "expected one info message"); + let rendered = lines_to_single_string(&cells[0]); + assert!( + rendered.contains(DEFAULT_PROJECT_DOC_FILENAME), + "info message should mention the existing file: {rendered:?}" + ); + assert!( + rendered.contains("Skipping /init"), + "info message should explain why /init was skipped: {rendered:?}" + ); + assert_eq!( + std::fs::read_to_string(existing_path).unwrap(), + "existing instructions" + ); +} + /// The commit picker shows only commit subjects (no timestamps). #[test] fn review_commit_picker_shows_subjects_without_timestamps() { From 0139f6780c850d87bb37bbb3a11e763d5dc3b50d Mon Sep 17 00:00:00 2001 From: Jeremy Rose <172423086+nornagon-openai@users.noreply.github.com> Date: Wed, 15 Oct 2025 23:22:39 -0700 Subject: [PATCH 11/35] Fix notify documentation to use emitted input-messages key (#5071) ## Summary - align the notify configuration example with the CLI payload by reading the `input-messages` key Fixes #4954 ------ https://chatgpt.com/codex/tasks/task_i_68e95e2be6ec832c8d09d6c65aac7c93 --- docs/config.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/config.md b/docs/config.md index ad3290887b..8b5a45ab06 100644 --- a/docs/config.md +++ b/docs/config.md @@ -640,7 +640,7 @@ def main() -> int: title = f"Codex: {assistant_message}" else: title = "Codex: Turn Complete!" - input_messages = notification.get("input_messages", []) + input_messages = notification.get("input-messages", []) message = " ".join(input_messages) title += message case _: From 4b01f0f50a640192e37c492464e4f76e9cc80fb6 Mon Sep 17 00:00:00 2001 From: Dylan Date: Thu, 16 Oct 2025 11:23:38 -0700 Subject: [PATCH 12/35] fix: tui default trusted settings should respect workspace write config (#3341) ## Summary When using the trusted state during tui startup, we created a new WorkspaceWrite policy without checking the config.toml for a `sandbox_workspace_write` field. This would result in us setting the sandbox_mode as workspace-write, but ignoring the field if the user had set `sandbox_workspace_write` without also setting `sandbox_mode` in the config.toml. This PR adds support for respecting `sandbox_workspace_write` setting in config.toml in the trusted directory flow, and adds tests to cover this case. ## Testing - [x] Added unit tests --- codex-rs/Cargo.lock | 1 + codex-rs/core/src/config.rs | 195 +++++++++++++++++++++++++++--------- codex-rs/tui/Cargo.toml | 1 + codex-rs/tui/src/lib.rs | 151 ++++++++++------------------ 4 files changed, 203 insertions(+), 145 deletions(-) diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 5576b7cba9..70514dd5da 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -1422,6 +1422,7 @@ dependencies = [ "textwrap 0.16.2", "tokio", "tokio-stream", + "toml", "tracing", "tracing-appender", "tracing-subscriber", diff --git a/codex-rs/core/src/config.rs b/codex-rs/core/src/config.rs index f422e4ab81..47456a64b1 100644 --- a/codex-rs/core/src/config.rs +++ b/codex-rs/core/src/config.rs @@ -42,6 +42,7 @@ use codex_protocol::config_types::Verbosity; use codex_rmcp_client::OAuthCredentialsStoreMode; use dirs::home_dir; use serde::Deserialize; +use similar::DiffableStr; use std::collections::BTreeMap; use std::collections::HashMap; use std::io::ErrorKind; @@ -100,6 +101,10 @@ pub struct Config { pub sandbox_policy: SandboxPolicy, + /// True if the user passed in an override or set a value in config.toml + /// for either of approval_policy or sandbox_mode. + pub did_user_set_custom_approval_policy_or_sandbox_mode: bool, + pub shell_environment_policy: ShellEnvironmentPolicy, /// When `true`, `AgentReasoning` events emitted by the backend will be @@ -230,6 +235,10 @@ pub struct Config { /// The active profile name used to derive this `Config` (if any). pub active_profile: Option, + /// The currently active project config, resolved by checking if cwd: + /// is (1) part of a git repo, (2) a git worktree, or (3) just using the cwd + pub active_project: ProjectConfig, + /// Tracks whether the Windows onboarding screen has been acknowledged. pub windows_wsl_setup_acknowledged: bool, @@ -859,6 +868,15 @@ pub struct ProjectConfig { pub trust_level: Option, } +impl ProjectConfig { + pub fn is_trusted(&self) -> bool { + match &self.trust_level { + Some(trust_level) => trust_level == "trusted", + None => false, + } + } +} + #[derive(Deserialize, Debug, Clone, Default, PartialEq)] pub struct ToolsToml { #[serde(default, alias = "web_search_request")] @@ -880,9 +898,23 @@ impl From for Tools { impl ConfigToml { /// Derive the effective sandbox policy from the configuration. - fn derive_sandbox_policy(&self, sandbox_mode_override: Option) -> SandboxPolicy { + fn derive_sandbox_policy( + &self, + sandbox_mode_override: Option, + resolved_cwd: &Path, + ) -> SandboxPolicy { let resolved_sandbox_mode = sandbox_mode_override .or(self.sandbox_mode) + .or_else(|| { + // if no sandbox_mode is set, but user has marked directory as trusted, use WorkspaceWrite + self.get_active_project(resolved_cwd).and_then(|p| { + if p.is_trusted() { + Some(SandboxMode::WorkspaceWrite) + } else { + None + } + }) + }) .unwrap_or_default(); match resolved_sandbox_mode { SandboxMode::ReadOnly => SandboxPolicy::new_read_only_policy(), @@ -904,30 +936,26 @@ impl ConfigToml { } } - pub fn is_cwd_trusted(&self, resolved_cwd: &Path) -> bool { + /// Resolves the cwd to an existing project, or returns None if ConfigToml + /// does not contain a project corresponding to cwd or a git repo for cwd + pub fn get_active_project(&self, resolved_cwd: &Path) -> Option { let projects = self.projects.clone().unwrap_or_default(); - let is_path_trusted = |path: &Path| { - let path_str = path.to_string_lossy().to_string(); - projects - .get(&path_str) - .map(|p| p.trust_level.as_deref() == Some("trusted")) - .unwrap_or(false) - }; - - // Fast path: exact cwd match - if is_path_trusted(resolved_cwd) { - return true; + if let Some(project_config) = projects.get(&resolved_cwd.to_string_lossy().to_string()) { + return Some(project_config.clone()); } - // If cwd lives inside a git worktree, check whether the root git project + // If cwd lives inside a git repo/worktree, check whether the root git project // (the primary repository working directory) is trusted. This lets // worktrees inherit trust from the main project. - if let Some(root_project) = resolve_root_git_project_for_trust(resolved_cwd) { - return is_path_trusted(&root_project); + if let Some(repo_root) = resolve_root_git_project_for_trust(resolved_cwd) + && let Some(project_config_for_root) = + projects.get(&repo_root.to_string_lossy().to_string_lossy().to_string()) + { + return Some(project_config_for_root.clone()); } - false + None } pub fn get_config_profile( @@ -986,7 +1014,7 @@ impl Config { model, review_model: override_review_model, cwd, - approval_policy, + approval_policy: approval_policy_override, sandbox_mode, model_provider, config_profile: config_profile_key, @@ -1026,7 +1054,47 @@ impl Config { let features = Features::from_config(&cfg, &config_profile, feature_overrides); - let sandbox_policy = cfg.derive_sandbox_policy(sandbox_mode); + let resolved_cwd = { + use std::env; + + match cwd { + None => { + tracing::info!("cwd not set, using current dir"); + env::current_dir()? + } + Some(p) if p.is_absolute() => p, + Some(p) => { + // Resolve relative path against the current working directory. + tracing::info!("cwd is relative, resolving against current dir"); + let mut current = env::current_dir()?; + current.push(p); + current + } + } + }; + let active_project = cfg + .get_active_project(&resolved_cwd) + .unwrap_or(ProjectConfig { trust_level: None }); + + let sandbox_policy = cfg.derive_sandbox_policy(sandbox_mode, &resolved_cwd); + let mut approval_policy = approval_policy_override + .or(config_profile.approval_policy) + .or(cfg.approval_policy) + .unwrap_or_else(|| { + if active_project.is_trusted() { + // If no explicit approval policy is set, but we trust cwd, default to OnRequest + AskForApproval::OnRequest + } else { + AskForApproval::default() + } + }); + let did_user_set_custom_approval_policy_or_sandbox_mode = approval_policy_override + .is_some() + || config_profile.approval_policy.is_some() + || cfg.approval_policy.is_some() + // TODO(#3034): profile.sandbox_mode is not implemented + || sandbox_mode.is_some() + || cfg.sandbox_mode.is_some(); let mut model_providers = built_in_model_providers(); // Merge user-defined providers into the built-in list. @@ -1050,25 +1118,6 @@ impl Config { let shell_environment_policy = cfg.shell_environment_policy.into(); - let resolved_cwd = { - use std::env; - - match cwd { - None => { - tracing::info!("cwd not set, using current dir"); - env::current_dir()? - } - Some(p) if p.is_absolute() => p, - Some(p) => { - // Resolve relative path against the current working directory. - tracing::info!("cwd is relative, resolving against current dir"); - let mut current = env::current_dir()?; - current.push(p); - current - } - } - }; - let history = cfg.history.unwrap_or_default(); let include_plan_tool_flag = features.enabled(Feature::PlanTool); @@ -1125,11 +1174,6 @@ impl Config { .or(cfg.review_model) .unwrap_or_else(default_review_model); - let mut approval_policy = approval_policy - .or(config_profile.approval_policy) - .or(cfg.approval_policy) - .unwrap_or_else(AskForApproval::default); - if features.enabled(Feature::ApproveAll) { approval_policy = AskForApproval::OnRequest; } @@ -1146,6 +1190,7 @@ impl Config { cwd: resolved_cwd, approval_policy, sandbox_policy, + did_user_set_custom_approval_policy_or_sandbox_mode, shell_environment_policy, notify: cfg.notify, user_instructions, @@ -1200,6 +1245,7 @@ impl Config { include_view_image_tool: include_view_image_tool_flag, features, active_profile: active_profile_name, + active_project, windows_wsl_setup_acknowledged: cfg.windows_wsl_setup_acknowledged.unwrap_or(false), disable_paste_burst: cfg.disable_paste_burst.unwrap_or(false), tui_notifications: cfg @@ -1395,7 +1441,8 @@ network_access = false # This should be ignored. let sandbox_mode_override = None; assert_eq!( SandboxPolicy::DangerFullAccess, - sandbox_full_access_cfg.derive_sandbox_policy(sandbox_mode_override) + sandbox_full_access_cfg + .derive_sandbox_policy(sandbox_mode_override, &PathBuf::from("/tmp/test")) ); let sandbox_read_only = r#" @@ -1410,7 +1457,8 @@ network_access = true # This should be ignored. let sandbox_mode_override = None; assert_eq!( SandboxPolicy::ReadOnly, - sandbox_read_only_cfg.derive_sandbox_policy(sandbox_mode_override) + sandbox_read_only_cfg + .derive_sandbox_policy(sandbox_mode_override, &PathBuf::from("/tmp/test")) ); let sandbox_workspace_write = r#" @@ -1434,7 +1482,36 @@ exclude_slash_tmp = true exclude_tmpdir_env_var: true, exclude_slash_tmp: true, }, - sandbox_workspace_write_cfg.derive_sandbox_policy(sandbox_mode_override) + sandbox_workspace_write_cfg + .derive_sandbox_policy(sandbox_mode_override, &PathBuf::from("/tmp/test")) + ); + + let sandbox_workspace_write = r#" +sandbox_mode = "workspace-write" + +[sandbox_workspace_write] +writable_roots = [ + "/my/workspace", +] +exclude_tmpdir_env_var = true +exclude_slash_tmp = true + +[projects."/tmp/test"] +trust_level = "trusted" +"#; + + let sandbox_workspace_write_cfg = toml::from_str::(sandbox_workspace_write) + .expect("TOML deserialization should succeed"); + let sandbox_mode_override = None; + assert_eq!( + SandboxPolicy::WorkspaceWrite { + writable_roots: vec![PathBuf::from("/my/workspace")], + network_access: false, + exclude_tmpdir_env_var: true, + exclude_slash_tmp: true, + }, + sandbox_workspace_write_cfg + .derive_sandbox_policy(sandbox_mode_override, &PathBuf::from("/tmp/test")) ); } @@ -2221,6 +2298,7 @@ model_verbosity = "high" model_provider: fixture.openai_provider.clone(), approval_policy: AskForApproval::Never, sandbox_policy: SandboxPolicy::new_read_only_policy(), + did_user_set_custom_approval_policy_or_sandbox_mode: true, shell_environment_policy: ShellEnvironmentPolicy::default(), user_instructions: None, notify: None, @@ -2250,6 +2328,7 @@ model_verbosity = "high" include_view_image_tool: true, features: Features::with_defaults(), active_profile: Some("o3".to_string()), + active_project: ProjectConfig { trust_level: None }, windows_wsl_setup_acknowledged: false, disable_paste_burst: false, tui_notifications: Default::default(), @@ -2285,6 +2364,7 @@ model_verbosity = "high" model_provider: fixture.openai_chat_completions_provider.clone(), approval_policy: AskForApproval::UnlessTrusted, sandbox_policy: SandboxPolicy::new_read_only_policy(), + did_user_set_custom_approval_policy_or_sandbox_mode: true, shell_environment_policy: ShellEnvironmentPolicy::default(), user_instructions: None, notify: None, @@ -2314,6 +2394,7 @@ model_verbosity = "high" include_view_image_tool: true, features: Features::with_defaults(), active_profile: Some("gpt3".to_string()), + active_project: ProjectConfig { trust_level: None }, windows_wsl_setup_acknowledged: false, disable_paste_burst: false, tui_notifications: Default::default(), @@ -2364,6 +2445,7 @@ model_verbosity = "high" model_provider: fixture.openai_provider.clone(), approval_policy: AskForApproval::OnFailure, sandbox_policy: SandboxPolicy::new_read_only_policy(), + did_user_set_custom_approval_policy_or_sandbox_mode: true, shell_environment_policy: ShellEnvironmentPolicy::default(), user_instructions: None, notify: None, @@ -2393,6 +2475,7 @@ model_verbosity = "high" include_view_image_tool: true, features: Features::with_defaults(), active_profile: Some("zdr".to_string()), + active_project: ProjectConfig { trust_level: None }, windows_wsl_setup_acknowledged: false, disable_paste_burst: false, tui_notifications: Default::default(), @@ -2429,6 +2512,7 @@ model_verbosity = "high" model_provider: fixture.openai_provider.clone(), approval_policy: AskForApproval::OnFailure, sandbox_policy: SandboxPolicy::new_read_only_policy(), + did_user_set_custom_approval_policy_or_sandbox_mode: true, shell_environment_policy: ShellEnvironmentPolicy::default(), user_instructions: None, notify: None, @@ -2458,6 +2542,7 @@ model_verbosity = "high" include_view_image_tool: true, features: Features::with_defaults(), active_profile: Some("gpt5".to_string()), + active_project: ProjectConfig { trust_level: None }, windows_wsl_setup_acknowledged: false, disable_paste_burst: false, tui_notifications: Default::default(), @@ -2469,6 +2554,24 @@ model_verbosity = "high" Ok(()) } + #[test] + fn test_did_user_set_custom_approval_policy_or_sandbox_mode_defaults_no() -> anyhow::Result<()> + { + let fixture = create_test_fixture()?; + + let config = Config::load_from_base_config_with_overrides( + fixture.cfg.clone(), + ConfigOverrides { + ..Default::default() + }, + fixture.codex_home(), + )?; + + assert!(config.did_user_set_custom_approval_policy_or_sandbox_mode); + + Ok(()) + } + #[test] fn test_set_project_trusted_writes_explicit_tables() -> anyhow::Result<()> { let project_dir = Path::new("/some/path"); diff --git a/codex-rs/tui/Cargo.toml b/codex-rs/tui/Cargo.toml index 2c6f32a20a..1ba3f0867b 100644 --- a/codex-rs/tui/Cargo.toml +++ b/codex-rs/tui/Cargo.toml @@ -78,6 +78,7 @@ tokio = { workspace = true, features = [ "signal", ] } tokio-stream = { workspace = true } +toml = { workspace = true } tracing = { workspace = true, features = ["log"] } tracing-appender = { workspace = true } tracing-subscriber = { workspace = true, features = ["env-filter"] } diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index 01b1bd976a..7447ef5feb 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -13,12 +13,8 @@ use codex_core::INTERACTIVE_SESSION_SOURCES; use codex_core::RolloutRecorder; use codex_core::config::Config; use codex_core::config::ConfigOverrides; -use codex_core::config::ConfigToml; -use codex_core::config::find_codex_home; -use codex_core::config::load_config_as_toml_with_cli_overrides; use codex_core::find_conversation_path_by_id_str; use codex_core::protocol::AskForApproval; -use codex_core::protocol::SandboxPolicy; use codex_ollama::DEFAULT_OSS_MODEL; use codex_protocol::config_types::SandboxMode; use opentelemetry_appender_tracing::layer::OpenTelemetryTracingBridge; @@ -192,52 +188,9 @@ pub async fn run_main( } }; - let mut config = { - // Load configuration and support CLI overrides. - - #[allow(clippy::print_stderr)] - match Config::load_with_cli_overrides(cli_kv_overrides.clone(), overrides).await { - Ok(config) => config, - Err(err) => { - eprintln!("Error loading configuration: {err}"); - std::process::exit(1); - } - } - }; - - // we load config.toml here to determine project state. - #[allow(clippy::print_stderr)] - let config_toml = { - let codex_home = match find_codex_home() { - Ok(codex_home) => codex_home, - Err(err) => { - eprintln!("Error finding codex home: {err}"); - std::process::exit(1); - } - }; - - match load_config_as_toml_with_cli_overrides(&codex_home, cli_kv_overrides).await { - Ok(config_toml) => config_toml, - Err(err) => { - eprintln!("Error loading config.toml: {err}"); - std::process::exit(1); - } - } - }; - - let cli_profile_override = cli.config_profile.clone(); - let active_profile = cli_profile_override - .clone() - .or_else(|| config_toml.profile.clone()); - - let should_show_trust_screen = determine_repo_trust_state( - &mut config, - &config_toml, - approval_policy, - sandbox_mode, - cli_profile_override, - )?; + let config = load_config_or_exit(cli_kv_overrides.clone(), overrides.clone()).await; + let active_profile = config.active_profile.clone(); let log_dir = codex_core::config::log_dir(&config)?; std::fs::create_dir_all(&log_dir)?; // Open (or create) your log file, appending to it. @@ -303,18 +256,18 @@ pub async fn run_main( let _ = tracing_subscriber::registry().with(file_layer).try_init(); }; - run_ratatui_app(cli, config, active_profile, should_show_trust_screen) + run_ratatui_app(cli, config, overrides, cli_kv_overrides, active_profile) .await .map_err(|err| std::io::Error::other(err.to_string())) } async fn run_ratatui_app( cli: Cli, - config: Config, + initial_config: Config, + overrides: ConfigOverrides, + cli_kv_overrides: Vec<(String, toml::Value)>, active_profile: Option, - should_show_trust_screen: bool, ) -> color_eyre::Result { - let mut config = config; color_eyre::install()?; // Forward panic reports through tracing so they appear in the UI status @@ -337,7 +290,7 @@ async fn run_ratatui_app( let skip_update_prompt = cli.prompt.as_ref().is_some_and(|prompt| !prompt.is_empty()); if !skip_update_prompt { - match update_prompt::run_update_prompt_if_needed(&mut tui, &config).await? { + match update_prompt::run_update_prompt_if_needed(&mut tui, &initial_config).await? { UpdatePromptOutcome::Continue => {} UpdatePromptOutcome::RunUpdate(action) => { crate::tui::restore()?; @@ -354,7 +307,7 @@ async fn run_ratatui_app( // Show update banner in terminal history (instead of stderr) so it is visible // within the TUI scrollback. Building spans keeps styling consistent. #[cfg(not(debug_assertions))] - if let Some(latest_version) = updates::get_upgrade_version(&config) { + if let Some(latest_version) = updates::get_upgrade_version(&initial_config) { use crate::history_cell::padded_emoji; use crate::history_cell::with_border_with_inner_width; use ratatui::style::Stylize as _; @@ -402,27 +355,29 @@ async fn run_ratatui_app( } // Initialize high-fidelity session event logging if enabled. - session_log::maybe_init(&config); + session_log::maybe_init(&initial_config); - let auth_manager = AuthManager::shared(config.codex_home.clone(), false); - let login_status = get_login_status(&config); + let auth_manager = AuthManager::shared(initial_config.codex_home.clone(), false); + let login_status = get_login_status(&initial_config); + let should_show_trust_screen = should_show_trust_screen(&initial_config); let should_show_windows_wsl_screen = - cfg!(target_os = "windows") && !config.windows_wsl_setup_acknowledged; + cfg!(target_os = "windows") && !initial_config.windows_wsl_setup_acknowledged; let should_show_onboarding = should_show_onboarding( login_status, - &config, + &initial_config, should_show_trust_screen, should_show_windows_wsl_screen, ); - if should_show_onboarding { + + let config = if should_show_onboarding { let onboarding_result = run_onboarding_app( OnboardingScreenArgs { + show_login_screen: should_show_login_screen(login_status, &initial_config), show_windows_wsl_screen: should_show_windows_wsl_screen, - show_login_screen: should_show_login_screen(login_status, &config), show_trust_screen: should_show_trust_screen, login_status, auth_manager: auth_manager.clone(), - config: config.clone(), + config: initial_config.clone(), }, &mut tui, ) @@ -440,14 +395,20 @@ async fn run_ratatui_app( update_action: None, }); } - if should_show_windows_wsl_screen { - config.windows_wsl_setup_acknowledged = true; + // if the user acknowledged windows or made an explicit decision ato trust the directory, reload the config accordingly + if should_show_windows_wsl_screen + || onboarding_result + .directory_trust_decision + .map(|d| d == TrustDirectorySelection::Trust) + .unwrap_or(false) + { + load_config_or_exit(cli_kv_overrides, overrides).await + } else { + initial_config } - if let Some(TrustDirectorySelection::Trust) = onboarding_result.directory_trust_decision { - config.approval_policy = AskForApproval::OnRequest; - config.sandbox_policy = SandboxPolicy::new_workspace_write_policy(); - } - } + } else { + initial_config + }; // Determine resume behavior: explicit id, then resume last, then picker. let resume_selection = if let Some(id_str) = cli.resume_session_id.as_deref() { @@ -588,39 +549,31 @@ fn get_login_status(config: &Config) -> LoginStatus { } } -/// Determine if user has configured a sandbox / approval policy, -/// or if the current cwd project is trusted, and updates the config -/// accordingly. -fn determine_repo_trust_state( - config: &mut Config, - config_toml: &ConfigToml, - approval_policy_overide: Option, - sandbox_mode_override: Option, - config_profile_override: Option, -) -> std::io::Result { - let config_profile = config_toml.get_config_profile(config_profile_override)?; +async fn load_config_or_exit( + cli_kv_overrides: Vec<(String, toml::Value)>, + overrides: ConfigOverrides, +) -> Config { + #[allow(clippy::print_stderr)] + match Config::load_with_cli_overrides(cli_kv_overrides, overrides).await { + Ok(config) => config, + Err(err) => { + eprintln!("Error loading configuration: {err}"); + std::process::exit(1); + } + } +} - if approval_policy_overide.is_some() || sandbox_mode_override.is_some() { +/// Determine if user has configured a sandbox / approval policy, +/// or if the current cwd project is already trusted. If not, we need to +/// show the trust screen. +fn should_show_trust_screen(config: &Config) -> bool { + if config.did_user_set_custom_approval_policy_or_sandbox_mode { // if the user has overridden either approval policy or sandbox mode, // skip the trust flow - Ok(false) - } else if config_profile.approval_policy.is_some() { - // if the user has specified settings in a config profile, skip the trust flow - // todo: profile sandbox mode? - Ok(false) - } else if config_toml.approval_policy.is_some() || config_toml.sandbox_mode.is_some() { - // if the user has specified either approval policy or sandbox mode in config.toml - // skip the trust flow - Ok(false) - } else if config_toml.is_cwd_trusted(&config.cwd) { - // if the current cwd project is trusted and no config has been set - // skip the trust flow and set the approval policy and sandbox mode - config.approval_policy = AskForApproval::OnRequest; - config.sandbox_policy = SandboxPolicy::new_workspace_write_policy(); - Ok(false) + false } else { - // if none of the above conditions are met, show the trust screen - Ok(true) + // otherwise, skip iff the active project is trusted + !config.active_project.is_trusted() } } From ed5b0bfeb3b1826f1c3e37efcb3fbe8479eb65fb Mon Sep 17 00:00:00 2001 From: pakrym-oai Date: Thu, 16 Oct 2025 14:51:42 -0700 Subject: [PATCH 13/35] Improve error decoding response body error (#5263) Split Reqwest error into separate error: 1. One for streaming response 2. One for initial connection failing Include request_id where possible. image --- codex-rs/core/src/chat_completions.rs | 13 +++++++-- codex-rs/core/src/client.rs | 13 +++++++-- codex-rs/core/src/error.rs | 40 +++++++++++++++++++++++++-- 3 files changed, 59 insertions(+), 7 deletions(-) diff --git a/codex-rs/core/src/chat_completions.rs b/codex-rs/core/src/chat_completions.rs index d6f394fb86..de0b9d4151 100644 --- a/codex-rs/core/src/chat_completions.rs +++ b/codex-rs/core/src/chat_completions.rs @@ -5,6 +5,8 @@ use crate::client_common::Prompt; use crate::client_common::ResponseEvent; use crate::client_common::ResponseStream; use crate::error::CodexErr; +use crate::error::ConnectionFailedError; +use crate::error::ResponseStreamFailed; use crate::error::Result; use crate::error::RetryLimitReachedError; use crate::error::UnexpectedResponseError; @@ -309,7 +311,12 @@ pub(crate) async fn stream_chat_completions( match res { Ok(resp) if resp.status().is_success() => { let (tx_event, rx_event) = mpsc::channel::>(1600); - let stream = resp.bytes_stream().map_err(CodexErr::Reqwest); + let stream = resp.bytes_stream().map_err(|e| { + CodexErr::ResponseStreamFailed(ResponseStreamFailed { + source: e, + request_id: None, + }) + }); tokio::spawn(process_chat_sse( stream, tx_event, @@ -349,7 +356,9 @@ pub(crate) async fn stream_chat_completions( } Err(e) => { if attempt > max_retries { - return Err(e.into()); + return Err(CodexErr::ConnectionFailed(ConnectionFailedError { + source: e, + })); } let delay = backoff(attempt); tokio::time::sleep(delay).await; diff --git a/codex-rs/core/src/client.rs b/codex-rs/core/src/client.rs index f59f30c5cf..3a259fce67 100644 --- a/codex-rs/core/src/client.rs +++ b/codex-rs/core/src/client.rs @@ -5,6 +5,8 @@ use std::time::Duration; use crate::AuthManager; use crate::auth::CodexAuth; +use crate::error::ConnectionFailedError; +use crate::error::ResponseStreamFailed; use crate::error::RetryLimitReachedError; use crate::error::UnexpectedResponseError; use bytes::Bytes; @@ -351,7 +353,12 @@ impl ModelClient { } // spawn task to process SSE - let stream = resp.bytes_stream().map_err(CodexErr::Reqwest); + let stream = resp.bytes_stream().map_err(move |e| { + CodexErr::ResponseStreamFailed(ResponseStreamFailed { + source: e, + request_id: request_id.clone(), + }) + }); tokio::spawn(process_sse( stream, tx_event, @@ -431,7 +438,9 @@ impl ModelClient { request_id, }) } - Err(e) => Err(StreamAttemptError::RetryableTransportError(e.into())), + Err(e) => Err(StreamAttemptError::RetryableTransportError( + CodexErr::ConnectionFailed(ConnectionFailedError { source: e }), + )), } } diff --git a/codex-rs/core/src/error.rs b/codex-rs/core/src/error.rs index 786e20523a..da2a868d70 100644 --- a/codex-rs/core/src/error.rs +++ b/codex-rs/core/src/error.rs @@ -91,6 +91,12 @@ pub enum CodexErr { #[error("{0}")] UsageLimitReached(UsageLimitReachedError), + #[error("{0}")] + ResponseStreamFailed(ResponseStreamFailed), + + #[error("{0}")] + ConnectionFailed(ConnectionFailedError), + #[error( "To use Codex with your ChatGPT plan, upgrade to Plus: https://openai.com/chatgpt/pricing." )] @@ -126,9 +132,6 @@ pub enum CodexErr { #[error(transparent)] Io(#[from] io::Error), - #[error(transparent)] - Reqwest(#[from] reqwest::Error), - #[error(transparent)] Json(#[from] serde_json::Error), @@ -147,6 +150,37 @@ pub enum CodexErr { EnvVar(EnvVarError), } +#[derive(Debug)] +pub struct ConnectionFailedError { + pub source: reqwest::Error, +} + +impl std::fmt::Display for ConnectionFailedError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "Connection failed: {}", self.source) + } +} + +#[derive(Debug)] +pub struct ResponseStreamFailed { + pub source: reqwest::Error, + pub request_id: Option, +} + +impl std::fmt::Display for ResponseStreamFailed { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!( + f, + "Error while reading the server response: {}{}", + self.source, + self.request_id + .as_ref() + .map(|id| format!(", request id: {id}")) + .unwrap_or_default() + ) + } +} + #[derive(Debug)] pub struct UnexpectedResponseError { pub status: StatusCode, From fc1723f13185a06e29ee263c05b6e0750024daaf Mon Sep 17 00:00:00 2001 From: Javi <666807+JaviSoto@users.noreply.github.com> Date: Thu, 16 Oct 2025 14:58:44 -0700 Subject: [PATCH 14/35] Revert "feat(CI): Allow running `rust-release` manually and in dry-run mode (#5232)" (#5266) This reverts commit 5fa7844ad728946b2760bb08b4fa1db98f778ebf. --- .github/workflows/rust-release.yml | 23 +---------------------- 1 file changed, 1 insertion(+), 22 deletions(-) diff --git a/.github/workflows/rust-release.yml b/.github/workflows/rust-release.yml index 7654e696cf..94143acb49 100644 --- a/.github/workflows/rust-release.yml +++ b/.github/workflows/rust-release.yml @@ -10,16 +10,6 @@ on: push: tags: - "rust-v*.*.*" - workflow_dispatch: - inputs: - branch: - description: "Branch or tag to build" - required: true - default: "main" - dry_run: - description: "Skip publishing the release (dry run)" - type: boolean - default: false concurrency: group: ${{ github.workflow }} @@ -30,11 +20,8 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v5 - with: - ref: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.branch || github.ref }} - name: Validate tag matches Cargo.toml version - if: ${{ github.event_name == 'push' }} shell: bash run: | set -euo pipefail @@ -58,10 +45,6 @@ jobs: echo "✅ Tag and Cargo.toml agree (${tag_ver})" echo "::endgroup::" - - name: Skip tag validation (manual run) - if: ${{ github.event_name != 'push' }} - run: echo "Tag validation skipped for workflow_dispatch runs." - build: needs: tag-check name: Build - ${{ matrix.runner }} - ${{ matrix.target }} @@ -94,8 +77,6 @@ jobs: steps: - uses: actions/checkout@v5 - with: - ref: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.branch || github.ref }} - uses: dtolnay/rust-toolchain@1.90 with: targets: ${{ matrix.target }} @@ -381,7 +362,6 @@ jobs: needs: build name: release runs-on: ubuntu-latest - if: ${{ github.event_name == 'push' && startsWith(github.ref, 'refs/tags/rust-v') }} permissions: contents: write actions: read @@ -476,7 +456,7 @@ jobs: # npm docs: https://docs.npmjs.com/trusted-publishers publish-npm: # Publish to npm for stable releases and alpha pre-releases with numeric suffixes. - if: ${{ needs.release.result == 'success' && needs.release.outputs.should_publish_npm == 'true' }} + if: ${{ needs.release.outputs.should_publish_npm == 'true' }} name: publish-npm needs: release runs-on: ubuntu-latest @@ -545,7 +525,6 @@ jobs: contents: write needs: release runs-on: ubuntu-latest - if: ${{ needs.release.result == 'success' }} steps: - name: Update latest-alpha-cli branch From 78f2785595331b79f24278193846e51436356336 Mon Sep 17 00:00:00 2001 From: Dylan Date: Thu, 16 Oct 2025 17:31:46 -0700 Subject: [PATCH 15/35] feat(tui): Add confirmation prompt for enabling full access approvals (#4980) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Adds a confirmation screen when a user attempts to select Full Access via the `/approvals` flow in the TUI. If the user selects the remember option, the preference is persisted to config.toml as `full_access_warning_acknowledged`, so they will not be prompted again. ## Testing - [x] Adds snapshot test coverage for the approvals flow and the confirmation flow Screenshot 2025-10-08 at 6 04 59 PM ------ https://chatgpt.com/codex/tasks/task_i_68e6c5c458088322a28efa3207058180 --------- Co-authored-by: Fouad Matin <169186268+fouad-openai@users.noreply.github.com> Co-authored-by: Fouad Matin --- codex-rs/core/src/config.rs | 61 +++++++++ codex-rs/core/src/config_types.rs | 14 ++ codex-rs/tui/src/app.rs | 21 +++ codex-rs/tui/src/app_event.rs | 15 +++ codex-rs/tui/src/chatwidget.rs | 121 ++++++++++++++++-- ...get__tests__approvals_selection_popup.snap | 17 +++ ...tests__full_access_confirmation_popup.snap | 15 +++ codex-rs/tui/src/chatwidget/tests.rs | 26 ++++ 8 files changed, 276 insertions(+), 14 deletions(-) create mode 100644 codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__approvals_selection_popup.snap create mode 100644 codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__full_access_confirmation_popup.snap diff --git a/codex-rs/core/src/config.rs b/codex-rs/core/src/config.rs index 47456a64b1..ae41407968 100644 --- a/codex-rs/core/src/config.rs +++ b/codex-rs/core/src/config.rs @@ -7,6 +7,7 @@ use crate::config_types::DEFAULT_OTEL_ENVIRONMENT; use crate::config_types::History; use crate::config_types::McpServerConfig; use crate::config_types::McpServerTransportConfig; +use crate::config_types::Notice; use crate::config_types::Notifications; use crate::config_types::OtelConfig; use crate::config_types::OtelConfigToml; @@ -242,6 +243,9 @@ pub struct Config { /// Tracks whether the Windows onboarding screen has been acknowledged. pub windows_wsl_setup_acknowledged: bool, + /// Collection of various notices we show the user + pub notices: Notice, + /// When true, disables burst-paste detection for typed input entirely. /// All characters are inserted as they are received, and no buffering /// or placeholder replacement will occur for fast keypress bursts. @@ -557,6 +561,54 @@ pub fn set_windows_wsl_setup_acknowledged( Ok(()) } +/// Persist the acknowledgement flag for the full access warning prompt. +pub fn set_hide_full_access_warning(codex_home: &Path, acknowledged: bool) -> anyhow::Result<()> { + let config_path = codex_home.join(CONFIG_TOML_FILE); + let mut doc = match std::fs::read_to_string(config_path.clone()) { + Ok(s) => s.parse::()?, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => DocumentMut::new(), + Err(e) => return Err(e.into()), + }; + + let notices_table = load_or_create_top_level_table(&mut doc, Notice::TABLE_KEY)?; + + notices_table["hide_full_access_warning"] = toml_edit::value(acknowledged); + + std::fs::create_dir_all(codex_home)?; + let tmp_file = NamedTempFile::new_in(codex_home)?; + std::fs::write(tmp_file.path(), doc.to_string())?; + tmp_file.persist(config_path)?; + + Ok(()) +} + +fn load_or_create_top_level_table<'a>( + doc: &'a mut DocumentMut, + key: &str, +) -> anyhow::Result<&'a mut toml_edit::Table> { + let mut created_table = false; + + let root = doc.as_table_mut(); + let needs_table = + !root.contains_key(key) || root.get(key).and_then(|item| item.as_table()).is_none(); + if needs_table { + root.insert(key, toml_edit::table()); + created_table = true; + } + + let Some(table) = doc[key].as_table_mut() else { + return Err(anyhow::anyhow!(format!( + "table [{key}] missing after initialization" + ))); + }; + + if created_table { + table.set_implicit(true); + } + + Ok(table) +} + fn ensure_profile_table<'a>( doc: &'a mut DocumentMut, profile_name: &str, @@ -832,6 +884,10 @@ pub struct ConfigToml { /// Tracks whether the Windows onboarding screen has been acknowledged. pub windows_wsl_setup_acknowledged: Option, + /// Collection of in-product notices (different from notifications) + /// See [`crate::config_types::Notices`] for more details + pub notice: Option, + /// Legacy, now use features pub experimental_instructions_file: Option, pub experimental_use_exec_command_tool: Option, @@ -1247,6 +1303,7 @@ impl Config { active_profile: active_profile_name, active_project, windows_wsl_setup_acknowledged: cfg.windows_wsl_setup_acknowledged.unwrap_or(false), + notices: cfg.notice.unwrap_or_default(), disable_paste_burst: cfg.disable_paste_burst.unwrap_or(false), tui_notifications: cfg .tui @@ -2330,6 +2387,7 @@ model_verbosity = "high" active_profile: Some("o3".to_string()), active_project: ProjectConfig { trust_level: None }, windows_wsl_setup_acknowledged: false, + notices: Default::default(), disable_paste_burst: false, tui_notifications: Default::default(), otel: OtelConfig::default(), @@ -2396,6 +2454,7 @@ model_verbosity = "high" active_profile: Some("gpt3".to_string()), active_project: ProjectConfig { trust_level: None }, windows_wsl_setup_acknowledged: false, + notices: Default::default(), disable_paste_burst: false, tui_notifications: Default::default(), otel: OtelConfig::default(), @@ -2477,6 +2536,7 @@ model_verbosity = "high" active_profile: Some("zdr".to_string()), active_project: ProjectConfig { trust_level: None }, windows_wsl_setup_acknowledged: false, + notices: Default::default(), disable_paste_burst: false, tui_notifications: Default::default(), otel: OtelConfig::default(), @@ -2544,6 +2604,7 @@ model_verbosity = "high" active_profile: Some("gpt5".to_string()), active_project: ProjectConfig { trust_level: None }, windows_wsl_setup_acknowledged: false, + notices: Default::default(), disable_paste_burst: false, tui_notifications: Default::default(), otel: OtelConfig::default(), diff --git a/codex-rs/core/src/config_types.rs b/codex-rs/core/src/config_types.rs index 0ef9248511..b724086a51 100644 --- a/codex-rs/core/src/config_types.rs +++ b/codex-rs/core/src/config_types.rs @@ -322,6 +322,20 @@ pub struct Tui { pub notifications: Notifications, } +/// Settings for notices we display to users via the tui and app-server clients +/// (primarily the Codex IDE extension). NOTE: these are different from +/// notifications - notices are warnings, NUX screens, acknowledgements, etc. +#[derive(Deserialize, Debug, Clone, PartialEq, Default)] +pub struct Notice { + /// Tracks whether the user has acknowledged the full access warning prompt. + pub hide_full_access_warning: Option, +} + +impl Notice { + /// used by set_hide_full_access_warning until we refactor config updates + pub(crate) const TABLE_KEY: &'static str = "notice"; +} + #[derive(Deserialize, Debug, Clone, PartialEq, Default)] pub struct SandboxWorkspaceWrite { #[serde(default)] diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 6124f377ec..11a10d6d11 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -18,6 +18,7 @@ use codex_core::AuthManager; use codex_core::ConversationManager; use codex_core::config::Config; use codex_core::config::persist_model_selection; +use codex_core::config::set_hide_full_access_warning; use codex_core::model_family::find_family_for_model; use codex_core::protocol::SessionSource; use codex_core::protocol::TokenUsage; @@ -334,6 +335,9 @@ impl App { AppEvent::OpenReasoningPopup { model, presets } => { self.chat_widget.open_reasoning_popup(model, presets); } + AppEvent::OpenFullAccessConfirmation { preset } => { + self.chat_widget.open_full_access_confirmation(preset); + } AppEvent::PersistModelSelection { model, effort } => { let profile = self.active_profile.as_deref(); match persist_model_selection(&self.config.codex_home, profile, &model, effort) @@ -379,6 +383,23 @@ impl App { AppEvent::UpdateSandboxPolicy(policy) => { self.chat_widget.set_sandbox_policy(policy); } + AppEvent::UpdateFullAccessWarningAcknowledged(ack) => { + self.chat_widget.set_full_access_warning_acknowledged(ack); + } + AppEvent::PersistFullAccessWarningAcknowledged => { + if let Err(err) = set_hide_full_access_warning(&self.config.codex_home, true) { + tracing::error!( + error = %err, + "failed to persist full access warning acknowledgement" + ); + self.chat_widget.add_error_message(format!( + "Failed to save full access confirmation preference: {err}" + )); + } + } + AppEvent::OpenApprovalsPopup => { + self.chat_widget.open_approvals_popup(); + } AppEvent::OpenReviewBranchPicker(cwd) => { self.chat_widget.show_review_branch_picker(&cwd).await; } diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs index 9d79c8ae13..402b0fa46d 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -1,5 +1,6 @@ use std::path::PathBuf; +use codex_common::approval_presets::ApprovalPreset; use codex_common::model_presets::ModelPreset; use codex_core::protocol::ConversationPathResponseEvent; use codex_core::protocol::Event; @@ -67,12 +68,26 @@ pub(crate) enum AppEvent { presets: Vec, }, + /// Open the confirmation prompt before enabling full access mode. + OpenFullAccessConfirmation { + preset: ApprovalPreset, + }, + /// Update the current approval policy in the running app and widget. UpdateAskForApprovalPolicy(AskForApproval), /// Update the current sandbox policy in the running app and widget. UpdateSandboxPolicy(SandboxPolicy), + /// Update whether the full access warning prompt has been acknowledged. + UpdateFullAccessWarningAcknowledged(bool), + + /// Persist the acknowledgement flag for the full access warning prompt. + PersistFullAccessWarningAcknowledged, + + /// Re-open the approval presets popup. + OpenApprovalsPopup, + /// Forwarded conversation history snapshot from the current conversation. ConversationHistory(ConversationPathResponseEvent), diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 88249f139b..775242a597 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -54,10 +54,13 @@ use ratatui::buffer::Buffer; use ratatui::layout::Constraint; use ratatui::layout::Layout; use ratatui::layout::Rect; +use ratatui::style::Color; use ratatui::style::Stylize; use ratatui::text::Line; +use ratatui::widgets::Paragraph; use ratatui::widgets::Widget; use ratatui::widgets::WidgetRef; +use ratatui::widgets::Wrap; use tokio::sync::mpsc::UnboundedSender; use tracing::debug; @@ -85,6 +88,7 @@ use crate::history_cell::HistoryCell; use crate::history_cell::McpToolCallCell; use crate::markdown::append_markdown; use crate::render::renderable::ColumnRenderable; +use crate::render::renderable::Renderable; use crate::slash_command::SlashCommand; use crate::status::RateLimitSnapshotDisplay; use crate::text_formatting::truncate_text; @@ -1819,22 +1823,24 @@ impl ChatWidget { for preset in presets.into_iter() { let is_current = current_approval == preset.approval && current_sandbox == preset.sandbox; - let approval = preset.approval; - let sandbox = preset.sandbox.clone(); let name = preset.label.to_string(); let description = Some(preset.description.to_string()); - let actions: Vec = vec![Box::new(move |tx| { - tx.send(AppEvent::CodexOp(Op::OverrideTurnContext { - cwd: None, - approval_policy: Some(approval), - sandbox_policy: Some(sandbox.clone()), - model: None, - effort: None, - summary: None, - })); - tx.send(AppEvent::UpdateAskForApprovalPolicy(approval)); - tx.send(AppEvent::UpdateSandboxPolicy(sandbox.clone())); - })]; + let requires_confirmation = preset.id == "full-access" + && !self + .config + .notices + .hide_full_access_warning + .unwrap_or(false); + let actions: Vec = if requires_confirmation { + let preset_clone = preset.clone(); + vec![Box::new(move |tx| { + tx.send(AppEvent::OpenFullAccessConfirmation { + preset: preset_clone.clone(), + }); + })] + } else { + Self::approval_preset_actions(preset.approval, preset.sandbox.clone()) + }; items.push(SelectionItem { name, description, @@ -1853,6 +1859,89 @@ impl ChatWidget { }); } + fn approval_preset_actions( + approval: AskForApproval, + sandbox: SandboxPolicy, + ) -> Vec { + vec![Box::new(move |tx| { + let sandbox_clone = sandbox.clone(); + tx.send(AppEvent::CodexOp(Op::OverrideTurnContext { + cwd: None, + approval_policy: Some(approval), + sandbox_policy: Some(sandbox_clone.clone()), + model: None, + effort: None, + summary: None, + })); + tx.send(AppEvent::UpdateAskForApprovalPolicy(approval)); + tx.send(AppEvent::UpdateSandboxPolicy(sandbox_clone)); + })] + } + + pub(crate) fn open_full_access_confirmation(&mut self, preset: ApprovalPreset) { + let approval = preset.approval; + let sandbox = preset.sandbox; + let mut header_children: Vec> = Vec::new(); + let title_line = Line::from("Enable full access?").bold(); + let info_line = Line::from(vec![ + "When Codex runs with full access, it can edit any file on your computer and run commands with network, without your approval. " + .into(), + "Exercise caution when enabling full access. This significantly increases the risk of data loss, leaks, or unexpected behavior." + .fg(Color::Red), + ]); + header_children.push(Box::new(title_line)); + header_children.push(Box::new( + Paragraph::new(vec![info_line]).wrap(Wrap { trim: false }), + )); + let header = ColumnRenderable::with(header_children); + + let mut accept_actions = Self::approval_preset_actions(approval, sandbox.clone()); + accept_actions.push(Box::new(|tx| { + tx.send(AppEvent::UpdateFullAccessWarningAcknowledged(true)); + })); + + let mut accept_and_remember_actions = Self::approval_preset_actions(approval, sandbox); + accept_and_remember_actions.push(Box::new(|tx| { + tx.send(AppEvent::UpdateFullAccessWarningAcknowledged(true)); + tx.send(AppEvent::PersistFullAccessWarningAcknowledged); + })); + + let deny_actions: Vec = vec![Box::new(|tx| { + tx.send(AppEvent::OpenApprovalsPopup); + })]; + + let items = vec![ + SelectionItem { + name: "Yes, continue anyway".to_string(), + description: Some("Apply full access for this session".to_string()), + actions: accept_actions, + dismiss_on_select: true, + ..Default::default() + }, + SelectionItem { + name: "Yes, and don't ask again".to_string(), + description: Some("Enable full access and remember this choice".to_string()), + actions: accept_and_remember_actions, + dismiss_on_select: true, + ..Default::default() + }, + SelectionItem { + name: "Cancel".to_string(), + description: Some("Go back without enabling full access".to_string()), + actions: deny_actions, + dismiss_on_select: true, + ..Default::default() + }, + ]; + + self.bottom_pane.show_selection_view(SelectionViewParams { + footer_hint: Some(standard_popup_hint_line()), + items, + header: Box::new(header), + ..Default::default() + }); + } + /// Set the approval policy in the widget's config copy. pub(crate) fn set_approval_policy(&mut self, policy: AskForApproval) { self.config.approval_policy = policy; @@ -1863,6 +1952,10 @@ impl ChatWidget { self.config.sandbox_policy = policy; } + pub(crate) fn set_full_access_warning_acknowledged(&mut self, acknowledged: bool) { + self.config.notices.hide_full_access_warning = Some(acknowledged); + } + /// Set the reasoning effort in the widget's config copy. pub(crate) fn set_reasoning_effort(&mut self, effort: Option) { self.config.model_reasoning_effort = effort; diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__approvals_selection_popup.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__approvals_selection_popup.snap new file mode 100644 index 0000000000..1d612966de --- /dev/null +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__approvals_selection_popup.snap @@ -0,0 +1,17 @@ +--- +source: tui/src/chatwidget/tests.rs +expression: popup +--- + Select Approval Mode + +› 1. Read Only (current) Codex can read files and answer questions. Codex + requires approval to make edits, run commands, or + access network + 2. Auto Codex can read files, make edits, and run commands + in the workspace. Codex requires approval to work + outside the workspace or access network + 3. Full Access Codex can read files, make edits, and run commands + with network access, without approval. Exercise + caution + + Press enter to confirm or esc to go back diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__full_access_confirmation_popup.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__full_access_confirmation_popup.snap new file mode 100644 index 0000000000..71dac5f590 --- /dev/null +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__full_access_confirmation_popup.snap @@ -0,0 +1,15 @@ +--- +source: tui/src/chatwidget/tests.rs +expression: popup +--- + Enable full access? + When Codex runs with full access, it can edit any file on your computer and + run commands with network, without your approval. Exercise caution when + enabling full access. This significantly increases the risk of data loss, + leaks, or unexpected behavior. + +› 1. Yes, continue anyway Apply full access for this session + 2. Yes, and don't ask again Enable full access and remember this choice + 3. Cancel Go back without enabling full access + + Press enter to confirm or esc to go back diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index a9102fdb4b..adadcd0a0f 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -4,6 +4,7 @@ use crate::app_event_sender::AppEventSender; use crate::test_backend::VT100Backend; use crate::tui::FrameRequester; use assert_matches::assert_matches; +use codex_common::approval_presets::builtin_approval_presets; use codex_core::AuthManager; use codex_core::CodexAuth; use codex_core::config::Config; @@ -1087,6 +1088,31 @@ fn model_selection_popup_snapshot() { assert_snapshot!("model_selection_popup", popup); } +#[test] +fn approvals_selection_popup_snapshot() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(); + + chat.config.notices.hide_full_access_warning = None; + chat.open_approvals_popup(); + + let popup = render_bottom_popup(&chat, 80); + assert_snapshot!("approvals_selection_popup", popup); +} + +#[test] +fn full_access_confirmation_popup_snapshot() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(); + + let preset = builtin_approval_presets() + .into_iter() + .find(|preset| preset.id == "full-access") + .expect("full access preset"); + chat.open_full_access_confirmation(preset); + + let popup = render_bottom_popup(&chat, 80); + assert_snapshot!("full_access_confirmation_popup", popup); +} + #[test] fn model_reasoning_selection_popup_snapshot() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(); From a5d48a775bb934f57cf38b99d48e12538999cfdf Mon Sep 17 00:00:00 2001 From: Gabriel Peal Date: Thu, 16 Oct 2025 20:15:47 -0700 Subject: [PATCH 16/35] [MCP] Allow specifying custom headers with streamable http servers (#5241) This adds two new config fields to streamable http mcp servers: `http_headers`: a map of key to value `env_http_headers` a map of key to env var which will be resolved at request time All headers will be passed to all MCP requests to that server just like authorization headers. There is a test ensuring that headers are not passed to other servers. Fixes #5180 --- codex-rs/cli/src/mcp_cmd.rs | 68 ++++- codex-rs/cli/tests/mcp_add_remove.rs | 8 + codex-rs/core/src/config.rs | 241 +++++++++++++++++- codex-rs/core/src/config_types.rs | 70 ++++- codex-rs/core/src/mcp/auth.rs | 4 + codex-rs/core/src/mcp_connection_manager.rs | 23 +- codex-rs/core/tests/suite/rmcp_client.rs | 4 + codex-rs/rmcp-client/src/auth_status.rs | 17 +- .../rmcp-client/src/perform_oauth_login.rs | 11 +- codex-rs/rmcp-client/src/rmcp_client.rs | 31 ++- codex-rs/rmcp-client/src/utils.rs | 73 ++++++ codex-rs/tui/src/history_cell.rs | 31 ++- 12 files changed, 560 insertions(+), 21 deletions(-) diff --git a/codex-rs/cli/src/mcp_cmd.rs b/codex-rs/cli/src/mcp_cmd.rs index 888a3092a5..e7fd7b8dc1 100644 --- a/codex-rs/cli/src/mcp_cmd.rs +++ b/codex-rs/cli/src/mcp_cmd.rs @@ -239,6 +239,8 @@ async fn run_add(config_overrides: &CliConfigOverrides, add_args: AddArgs) -> Re } => McpServerTransportConfig::StreamableHttp { url, bearer_token_env_var, + http_headers: None, + env_http_headers: None, }, AddMcpTransportArgs { .. } => bail!("exactly one of --command or --url must be provided"), }; @@ -260,11 +262,20 @@ async fn run_add(config_overrides: &CliConfigOverrides, add_args: AddArgs) -> Re if let McpServerTransportConfig::StreamableHttp { url, bearer_token_env_var: None, + http_headers, + env_http_headers, } = transport && matches!(supports_oauth_login(&url).await, Ok(true)) { println!("Detected OAuth support. Starting OAuth flow…"); - perform_oauth_login(&name, &url, config.mcp_oauth_credentials_store_mode).await?; + perform_oauth_login( + &name, + &url, + config.mcp_oauth_credentials_store_mode, + http_headers.clone(), + env_http_headers.clone(), + ) + .await?; println!("Successfully logged in."); } @@ -317,12 +328,24 @@ async fn run_login(config_overrides: &CliConfigOverrides, login_args: LoginArgs) bail!("No MCP server named '{name}' found."); }; - let url = match &server.transport { - McpServerTransportConfig::StreamableHttp { url, .. } => url.clone(), + let (url, http_headers, env_http_headers) = match &server.transport { + McpServerTransportConfig::StreamableHttp { + url, + http_headers, + env_http_headers, + .. + } => (url.clone(), http_headers.clone(), env_http_headers.clone()), _ => bail!("OAuth login is only supported for streamable HTTP servers."), }; - perform_oauth_login(&name, &url, config.mcp_oauth_credentials_store_mode).await?; + perform_oauth_login( + &name, + &url, + config.mcp_oauth_credentials_store_mode, + http_headers, + env_http_headers, + ) + .await?; println!("Successfully logged in to MCP server '{name}'."); Ok(()) } @@ -386,11 +409,15 @@ async fn run_list(config_overrides: &CliConfigOverrides, list_args: ListArgs) -> McpServerTransportConfig::StreamableHttp { url, bearer_token_env_var, + http_headers, + env_http_headers, } => { serde_json::json!({ "type": "streamable_http", "url": url, "bearer_token_env_var": bearer_token_env_var, + "http_headers": http_headers, + "env_http_headers": env_http_headers, }) } }; @@ -465,6 +492,7 @@ async fn run_list(config_overrides: &CliConfigOverrides, list_args: ListArgs) -> McpServerTransportConfig::StreamableHttp { url, bearer_token_env_var, + .. } => { let status = if cfg.enabled { "enabled".to_string() @@ -610,10 +638,14 @@ async fn run_get(config_overrides: &CliConfigOverrides, get_args: GetArgs) -> Re McpServerTransportConfig::StreamableHttp { url, bearer_token_env_var, + http_headers, + env_http_headers, } => serde_json::json!({ "type": "streamable_http", "url": url, "bearer_token_env_var": bearer_token_env_var, + "http_headers": http_headers, + "env_http_headers": env_http_headers, }), }; let output = serde_json::to_string_pretty(&serde_json::json!({ @@ -661,11 +693,39 @@ async fn run_get(config_overrides: &CliConfigOverrides, get_args: GetArgs) -> Re McpServerTransportConfig::StreamableHttp { url, bearer_token_env_var, + http_headers, + env_http_headers, } => { println!(" transport: streamable_http"); println!(" url: {url}"); let env_var = bearer_token_env_var.as_deref().unwrap_or("-"); println!(" bearer_token_env_var: {env_var}"); + let headers_display = match http_headers { + Some(map) if !map.is_empty() => { + let mut pairs: Vec<_> = map.iter().collect(); + pairs.sort_by(|(a, _), (b, _)| a.cmp(b)); + pairs + .into_iter() + .map(|(k, v)| format!("{k}={v}")) + .collect::>() + .join(", ") + } + _ => "-".to_string(), + }; + println!(" http_headers: {headers_display}"); + let env_headers_display = match env_http_headers { + Some(map) if !map.is_empty() => { + let mut pairs: Vec<_> = map.iter().collect(); + pairs.sort_by(|(a, _), (b, _)| a.cmp(b)); + pairs + .into_iter() + .map(|(k, v)| format!("{k}={v}")) + .collect::>() + .join(", ") + } + _ => "-".to_string(), + }; + println!(" env_http_headers: {env_headers_display}"); } } if let Some(timeout) = server.startup_timeout_sec { diff --git a/codex-rs/cli/tests/mcp_add_remove.rs b/codex-rs/cli/tests/mcp_add_remove.rs index 705509abf5..83abe72c29 100644 --- a/codex-rs/cli/tests/mcp_add_remove.rs +++ b/codex-rs/cli/tests/mcp_add_remove.rs @@ -112,9 +112,13 @@ async fn add_streamable_http_without_manual_token() -> Result<()> { McpServerTransportConfig::StreamableHttp { url, bearer_token_env_var, + http_headers, + env_http_headers, } => { assert_eq!(url, "https://example.com/mcp"); assert!(bearer_token_env_var.is_none()); + assert!(http_headers.is_none()); + assert!(env_http_headers.is_none()); } other => panic!("unexpected transport: {other:?}"), } @@ -150,9 +154,13 @@ async fn add_streamable_http_with_custom_env_var() -> Result<()> { McpServerTransportConfig::StreamableHttp { url, bearer_token_env_var, + http_headers, + env_http_headers, } => { assert_eq!(url, "https://example.com/issues"); assert_eq!(bearer_token_env_var.as_deref(), Some("GITHUB_TOKEN")); + assert!(http_headers.is_none()); + assert!(env_http_headers.is_none()); } other => panic!("unexpected transport: {other:?}"), } diff --git a/codex-rs/core/src/config.rs b/codex-rs/core/src/config.rs index ae41407968..570cd5151c 100644 --- a/codex-rs/core/src/config.rs +++ b/codex-rs/core/src/config.rs @@ -415,11 +415,37 @@ pub fn write_global_mcp_servers( McpServerTransportConfig::StreamableHttp { url, bearer_token_env_var, + http_headers, + env_http_headers, } => { entry["url"] = toml_edit::value(url.clone()); if let Some(env_var) = bearer_token_env_var { entry["bearer_token_env_var"] = toml_edit::value(env_var.clone()); } + if let Some(headers) = http_headers + && !headers.is_empty() + { + let mut table = TomlTable::new(); + table.set_implicit(false); + let mut pairs: Vec<_> = headers.iter().collect(); + pairs.sort_by(|(a, _), (b, _)| a.cmp(b)); + for (key, value) in pairs { + table.insert(key, toml_edit::value(value.clone())); + } + entry["http_headers"] = TomlItem::Table(table); + } + if let Some(headers) = env_http_headers + && !headers.is_empty() + { + let mut table = TomlTable::new(); + table.set_implicit(false); + let mut pairs: Vec<_> = headers.iter().collect(); + pairs.sort_by(|(a, _), (b, _)| a.cmp(b)); + for (key, value) in pairs { + table.insert(key, toml_edit::value(value.clone())); + } + entry["env_http_headers"] = TomlItem::Table(table); + } } } @@ -1948,15 +1974,18 @@ ZIG_VAR = "3" } #[tokio::test] - async fn write_global_mcp_servers_serializes_streamable_http() -> anyhow::Result<()> { + async fn write_global_mcp_servers_streamable_http_serializes_bearer_token() -> anyhow::Result<()> + { let codex_home = TempDir::new()?; - let mut servers = BTreeMap::from([( + let servers = BTreeMap::from([( "docs".to_string(), McpServerConfig { transport: McpServerTransportConfig::StreamableHttp { url: "https://example.com/mcp".to_string(), bearer_token_env_var: Some("MCP_TOKEN".to_string()), + http_headers: None, + env_http_headers: None, }, enabled: true, startup_timeout_sec: Some(Duration::from_secs(2)), @@ -1983,20 +2012,127 @@ startup_timeout_sec = 2.0 McpServerTransportConfig::StreamableHttp { url, bearer_token_env_var, + http_headers, + env_http_headers, } => { assert_eq!(url, "https://example.com/mcp"); assert_eq!(bearer_token_env_var.as_deref(), Some("MCP_TOKEN")); + assert!(http_headers.is_none()); + assert!(env_http_headers.is_none()); } other => panic!("unexpected transport {other:?}"), } assert_eq!(docs.startup_timeout_sec, Some(Duration::from_secs(2))); + Ok(()) + } + + #[tokio::test] + async fn write_global_mcp_servers_streamable_http_serializes_custom_headers() + -> anyhow::Result<()> { + let codex_home = TempDir::new()?; + + let servers = BTreeMap::from([( + "docs".to_string(), + McpServerConfig { + transport: McpServerTransportConfig::StreamableHttp { + url: "https://example.com/mcp".to_string(), + bearer_token_env_var: Some("MCP_TOKEN".to_string()), + http_headers: Some(HashMap::from([("X-Doc".to_string(), "42".to_string())])), + env_http_headers: Some(HashMap::from([( + "X-Auth".to_string(), + "DOCS_AUTH".to_string(), + )])), + }, + enabled: true, + startup_timeout_sec: Some(Duration::from_secs(2)), + tool_timeout_sec: None, + }, + )]); + write_global_mcp_servers(codex_home.path(), &servers)?; + + let config_path = codex_home.path().join(CONFIG_TOML_FILE); + let serialized = std::fs::read_to_string(&config_path)?; + assert_eq!( + serialized, + r#"[mcp_servers.docs] +url = "https://example.com/mcp" +bearer_token_env_var = "MCP_TOKEN" +startup_timeout_sec = 2.0 + +[mcp_servers.docs.http_headers] +X-Doc = "42" + +[mcp_servers.docs.env_http_headers] +X-Auth = "DOCS_AUTH" +"# + ); + + let loaded = load_global_mcp_servers(codex_home.path()).await?; + let docs = loaded.get("docs").expect("docs entry"); + match &docs.transport { + McpServerTransportConfig::StreamableHttp { + http_headers, + env_http_headers, + .. + } => { + assert_eq!( + http_headers, + &Some(HashMap::from([("X-Doc".to_string(), "42".to_string())])) + ); + assert_eq!( + env_http_headers, + &Some(HashMap::from([( + "X-Auth".to_string(), + "DOCS_AUTH".to_string() + )])) + ); + } + other => panic!("unexpected transport {other:?}"), + } + + Ok(()) + } + + #[tokio::test] + async fn write_global_mcp_servers_streamable_http_removes_optional_sections() + -> anyhow::Result<()> { + let codex_home = TempDir::new()?; + + let config_path = codex_home.path().join(CONFIG_TOML_FILE); + + let mut servers = BTreeMap::from([( + "docs".to_string(), + McpServerConfig { + transport: McpServerTransportConfig::StreamableHttp { + url: "https://example.com/mcp".to_string(), + bearer_token_env_var: Some("MCP_TOKEN".to_string()), + http_headers: Some(HashMap::from([("X-Doc".to_string(), "42".to_string())])), + env_http_headers: Some(HashMap::from([( + "X-Auth".to_string(), + "DOCS_AUTH".to_string(), + )])), + }, + enabled: true, + startup_timeout_sec: Some(Duration::from_secs(2)), + tool_timeout_sec: None, + }, + )]); + + write_global_mcp_servers(codex_home.path(), &servers)?; + let serialized_with_optional = std::fs::read_to_string(&config_path)?; + assert!(serialized_with_optional.contains("bearer_token_env_var = \"MCP_TOKEN\"")); + assert!(serialized_with_optional.contains("[mcp_servers.docs.http_headers]")); + assert!(serialized_with_optional.contains("[mcp_servers.docs.env_http_headers]")); + servers.insert( "docs".to_string(), McpServerConfig { transport: McpServerTransportConfig::StreamableHttp { url: "https://example.com/mcp".to_string(), bearer_token_env_var: None, + http_headers: None, + env_http_headers: None, }, enabled: true, startup_timeout_sec: None, @@ -2019,9 +2155,110 @@ url = "https://example.com/mcp" McpServerTransportConfig::StreamableHttp { url, bearer_token_env_var, + http_headers, + env_http_headers, } => { assert_eq!(url, "https://example.com/mcp"); assert!(bearer_token_env_var.is_none()); + assert!(http_headers.is_none()); + assert!(env_http_headers.is_none()); + } + other => panic!("unexpected transport {other:?}"), + } + + assert!(docs.startup_timeout_sec.is_none()); + + Ok(()) + } + + #[tokio::test] + async fn write_global_mcp_servers_streamable_http_isolates_headers_between_servers() + -> anyhow::Result<()> { + let codex_home = TempDir::new()?; + let config_path = codex_home.path().join(CONFIG_TOML_FILE); + + let servers = BTreeMap::from([ + ( + "docs".to_string(), + McpServerConfig { + transport: McpServerTransportConfig::StreamableHttp { + url: "https://example.com/mcp".to_string(), + bearer_token_env_var: Some("MCP_TOKEN".to_string()), + http_headers: Some(HashMap::from([( + "X-Doc".to_string(), + "42".to_string(), + )])), + env_http_headers: Some(HashMap::from([( + "X-Auth".to_string(), + "DOCS_AUTH".to_string(), + )])), + }, + enabled: true, + startup_timeout_sec: Some(Duration::from_secs(2)), + tool_timeout_sec: None, + }, + ), + ( + "logs".to_string(), + McpServerConfig { + transport: McpServerTransportConfig::Stdio { + command: "logs-server".to_string(), + args: vec!["--follow".to_string()], + env: None, + }, + enabled: true, + startup_timeout_sec: None, + tool_timeout_sec: None, + }, + ), + ]); + + write_global_mcp_servers(codex_home.path(), &servers)?; + + let serialized = std::fs::read_to_string(&config_path)?; + assert!( + serialized.contains("[mcp_servers.docs.http_headers]"), + "serialized config missing docs headers section:\n{serialized}" + ); + assert!( + !serialized.contains("[mcp_servers.logs.http_headers]"), + "serialized config should not add logs headers section:\n{serialized}" + ); + assert!( + !serialized.contains("[mcp_servers.logs.env_http_headers]"), + "serialized config should not add logs env headers section:\n{serialized}" + ); + assert!( + !serialized.contains("mcp_servers.logs.bearer_token_env_var"), + "serialized config should not add bearer token to logs:\n{serialized}" + ); + + let loaded = load_global_mcp_servers(codex_home.path()).await?; + let docs = loaded.get("docs").expect("docs entry"); + match &docs.transport { + McpServerTransportConfig::StreamableHttp { + http_headers, + env_http_headers, + .. + } => { + assert_eq!( + http_headers, + &Some(HashMap::from([("X-Doc".to_string(), "42".to_string())])) + ); + assert_eq!( + env_http_headers, + &Some(HashMap::from([( + "X-Auth".to_string(), + "DOCS_AUTH".to_string() + )])) + ); + } + other => panic!("unexpected transport {other:?}"), + } + let logs = loaded.get("logs").expect("logs entry"); + match &logs.transport { + McpServerTransportConfig::Stdio { env, .. } => { + assert!(env.is_none()); } other => panic!("unexpected transport {other:?}"), } diff --git a/codex-rs/core/src/config_types.rs b/codex-rs/core/src/config_types.rs index b724086a51..3a14d77a66 100644 --- a/codex-rs/core/src/config_types.rs +++ b/codex-rs/core/src/config_types.rs @@ -49,6 +49,10 @@ impl<'de> Deserialize<'de> for McpServerConfig { args: Option>, #[serde(default)] env: Option>, + #[serde(default)] + http_headers: Option>, + #[serde(default)] + env_http_headers: Option>, url: Option, bearer_token: Option, @@ -94,6 +98,8 @@ impl<'de> Deserialize<'de> for McpServerConfig { env, url, bearer_token_env_var, + http_headers, + env_http_headers, .. } => { throw_if_set("stdio", "url", url.as_ref())?; @@ -102,6 +108,8 @@ impl<'de> Deserialize<'de> for McpServerConfig { "bearer_token_env_var", bearer_token_env_var.as_ref(), )?; + throw_if_set("stdio", "http_headers", http_headers.as_ref())?; + throw_if_set("stdio", "env_http_headers", env_http_headers.as_ref())?; McpServerTransportConfig::Stdio { command, args: args.unwrap_or_default(), @@ -115,6 +123,8 @@ impl<'de> Deserialize<'de> for McpServerConfig { command, args, env, + http_headers, + env_http_headers, .. } => { throw_if_set("streamable_http", "command", command.as_ref())?; @@ -124,6 +134,8 @@ impl<'de> Deserialize<'de> for McpServerConfig { McpServerTransportConfig::StreamableHttp { url, bearer_token_env_var, + http_headers, + env_http_headers, } } _ => return Err(SerdeError::custom("invalid transport")), @@ -161,6 +173,12 @@ pub enum McpServerTransportConfig { /// The actual secret value must be provided via the environment. #[serde(default, skip_serializing_if = "Option::is_none")] bearer_token_env_var: Option, + /// Additional HTTP headers to include in requests to this server. + #[serde(default, skip_serializing_if = "Option::is_none")] + http_headers: Option>, + /// HTTP headers where the value is sourced from an environment variable. + #[serde(default, skip_serializing_if = "Option::is_none")] + env_http_headers: Option>, }, } @@ -557,7 +575,9 @@ mod tests { cfg.transport, McpServerTransportConfig::StreamableHttp { url: "https://example.com/mcp".to_string(), - bearer_token_env_var: None + bearer_token_env_var: None, + http_headers: None, + env_http_headers: None, } ); assert!(cfg.enabled); @@ -577,12 +597,39 @@ mod tests { cfg.transport, McpServerTransportConfig::StreamableHttp { url: "https://example.com/mcp".to_string(), - bearer_token_env_var: Some("GITHUB_TOKEN".to_string()) + bearer_token_env_var: Some("GITHUB_TOKEN".to_string()), + http_headers: None, + env_http_headers: None, } ); assert!(cfg.enabled); } + #[test] + fn deserialize_streamable_http_server_config_with_headers() { + let cfg: McpServerConfig = toml::from_str( + r#" + url = "https://example.com/mcp" + http_headers = { "X-Foo" = "bar" } + env_http_headers = { "X-Token" = "TOKEN_ENV" } + "#, + ) + .expect("should deserialize http config with headers"); + + assert_eq!( + cfg.transport, + McpServerTransportConfig::StreamableHttp { + url: "https://example.com/mcp".to_string(), + bearer_token_env_var: None, + http_headers: Some(HashMap::from([("X-Foo".to_string(), "bar".to_string())])), + env_http_headers: Some(HashMap::from([( + "X-Token".to_string(), + "TOKEN_ENV".to_string() + )])), + } + ); + } + #[test] fn deserialize_rejects_command_and_url() { toml::from_str::( @@ -605,6 +652,25 @@ mod tests { .expect_err("should reject env for http transport"); } + #[test] + fn deserialize_rejects_headers_for_stdio() { + toml::from_str::( + r#" + command = "echo" + http_headers = { "X-Foo" = "bar" } + "#, + ) + .expect_err("should reject http_headers for stdio transport"); + + toml::from_str::( + r#" + command = "echo" + env_http_headers = { "X-Foo" = "BAR_ENV" } + "#, + ) + .expect_err("should reject env_http_headers for stdio transport"); + } + #[test] fn deserialize_rejects_inline_bearer_token_field() { let err = toml::from_str::( diff --git a/codex-rs/core/src/mcp/auth.rs b/codex-rs/core/src/mcp/auth.rs index dbb9db804f..22d1f5f51f 100644 --- a/codex-rs/core/src/mcp/auth.rs +++ b/codex-rs/core/src/mcp/auth.rs @@ -45,11 +45,15 @@ async fn compute_auth_status( McpServerTransportConfig::StreamableHttp { url, bearer_token_env_var, + http_headers, + env_http_headers, } => { determine_streamable_http_auth_status( server_name, url, bearer_token_env_var.as_deref(), + http_headers.clone(), + env_http_headers.clone(), store_mode, ) .await diff --git a/codex-rs/core/src/mcp_connection_manager.rs b/codex-rs/core/src/mcp_connection_manager.rs index 768c6b01a6..8ddb0366fb 100644 --- a/codex-rs/core/src/mcp_connection_manager.rs +++ b/codex-rs/core/src/mcp_connection_manager.rs @@ -121,17 +121,27 @@ impl McpClientAdapter { } } + #[allow(clippy::too_many_arguments)] async fn new_streamable_http_client( server_name: String, url: String, bearer_token: Option, + http_headers: Option>, + env_http_headers: Option>, params: mcp_types::InitializeRequestParams, startup_timeout: Duration, store_mode: OAuthCredentialsStoreMode, ) -> Result { let client = Arc::new( - RmcpClient::new_streamable_http_client(&server_name, &url, bearer_token, store_mode) - .await?, + RmcpClient::new_streamable_http_client( + &server_name, + &url, + bearer_token, + http_headers, + env_http_headers, + store_mode, + ) + .await?, ); client.initialize(params, Some(startup_timeout)).await?; Ok(McpClientAdapter::Rmcp(client)) @@ -259,11 +269,18 @@ impl McpConnectionManager { ) .await } - McpServerTransportConfig::StreamableHttp { url, .. } => { + McpServerTransportConfig::StreamableHttp { + url, + http_headers, + env_http_headers, + .. + } => { McpClientAdapter::new_streamable_http_client( server_name.clone(), url, resolved_bearer_token.unwrap_or_default(), + http_headers, + env_http_headers, params, startup_timeout, store_mode, diff --git a/codex-rs/core/tests/suite/rmcp_client.rs b/codex-rs/core/tests/suite/rmcp_client.rs index 9dd921b91f..b2559cd2ee 100644 --- a/codex-rs/core/tests/suite/rmcp_client.rs +++ b/codex-rs/core/tests/suite/rmcp_client.rs @@ -235,6 +235,8 @@ async fn streamable_http_tool_call_round_trip() -> anyhow::Result<()> { transport: McpServerTransportConfig::StreamableHttp { url: server_url, bearer_token_env_var: None, + http_headers: None, + env_http_headers: None, }, enabled: true, startup_timeout_sec: Some(Duration::from_secs(10)), @@ -416,6 +418,8 @@ async fn streamable_http_with_oauth_round_trip() -> anyhow::Result<()> { transport: McpServerTransportConfig::StreamableHttp { url: server_url, bearer_token_env_var: None, + http_headers: None, + env_http_headers: None, }, enabled: true, startup_timeout_sec: Some(Duration::from_secs(10)), diff --git a/codex-rs/rmcp-client/src/auth_status.rs b/codex-rs/rmcp-client/src/auth_status.rs index 5e32eed485..77c33f6959 100644 --- a/codex-rs/rmcp-client/src/auth_status.rs +++ b/codex-rs/rmcp-client/src/auth_status.rs @@ -1,3 +1,4 @@ +use std::collections::HashMap; use std::time::Duration; use anyhow::Error; @@ -6,11 +7,14 @@ use codex_protocol::protocol::McpAuthStatus; use reqwest::Client; use reqwest::StatusCode; use reqwest::Url; +use reqwest::header::HeaderMap; use serde::Deserialize; use tracing::debug; use crate::OAuthCredentialsStoreMode; use crate::oauth::has_oauth_tokens; +use crate::utils::apply_default_headers; +use crate::utils::build_default_headers; const DISCOVERY_TIMEOUT: Duration = Duration::from_secs(5); const OAUTH_DISCOVERY_HEADER: &str = "MCP-Protocol-Version"; @@ -21,6 +25,8 @@ pub async fn determine_streamable_http_auth_status( server_name: &str, url: &str, bearer_token_env_var: Option<&str>, + http_headers: Option>, + env_http_headers: Option>, store_mode: OAuthCredentialsStoreMode, ) -> Result { if bearer_token_env_var.is_some() { @@ -31,7 +37,9 @@ pub async fn determine_streamable_http_auth_status( return Ok(McpAuthStatus::OAuth); } - match supports_oauth_login(url).await { + let default_headers = build_default_headers(http_headers, env_http_headers)?; + + match supports_oauth_login_with_headers(url, &default_headers).await { Ok(true) => Ok(McpAuthStatus::NotLoggedIn), Ok(false) => Ok(McpAuthStatus::Unsupported), Err(error) => { @@ -45,8 +53,13 @@ pub async fn determine_streamable_http_auth_status( /// Attempt to determine whether a streamable HTTP MCP server advertises OAuth login. pub async fn supports_oauth_login(url: &str) -> Result { + supports_oauth_login_with_headers(url, &HeaderMap::new()).await +} + +async fn supports_oauth_login_with_headers(url: &str, default_headers: &HeaderMap) -> Result { let base_url = Url::parse(url)?; - let client = Client::builder().timeout(DISCOVERY_TIMEOUT).build()?; + let builder = Client::builder().timeout(DISCOVERY_TIMEOUT); + let client = apply_default_headers(builder, default_headers).build()?; let mut last_error: Option = None; for candidate_path in discovery_paths(base_url.path()) { diff --git a/codex-rs/rmcp-client/src/perform_oauth_login.rs b/codex-rs/rmcp-client/src/perform_oauth_login.rs index c2d39a214f..c5276227aa 100644 --- a/codex-rs/rmcp-client/src/perform_oauth_login.rs +++ b/codex-rs/rmcp-client/src/perform_oauth_login.rs @@ -1,3 +1,4 @@ +use std::collections::HashMap; use std::string::String; use std::sync::Arc; use std::time::Duration; @@ -5,6 +6,7 @@ use std::time::Duration; use anyhow::Context; use anyhow::Result; use anyhow::anyhow; +use reqwest::ClientBuilder; use rmcp::transport::auth::OAuthState; use tiny_http::Response; use tiny_http::Server; @@ -16,6 +18,8 @@ use crate::OAuthCredentialsStoreMode; use crate::StoredOAuthTokens; use crate::WrappedOAuthTokenResponse; use crate::save_oauth_tokens; +use crate::utils::apply_default_headers; +use crate::utils::build_default_headers; struct CallbackServerGuard { server: Arc, @@ -31,6 +35,8 @@ pub async fn perform_oauth_login( server_name: &str, server_url: &str, store_mode: OAuthCredentialsStoreMode, + http_headers: Option>, + env_http_headers: Option>, ) -> Result<()> { let server = Arc::new(Server::http("127.0.0.1:0").map_err(|err| anyhow!(err))?); let guard = CallbackServerGuard { @@ -51,7 +57,10 @@ pub async fn perform_oauth_login( let (tx, rx) = oneshot::channel(); spawn_callback_server(server, tx); - let mut oauth_state = OAuthState::new(server_url, None).await?; + let default_headers = build_default_headers(http_headers, env_http_headers)?; + let http_client = apply_default_headers(ClientBuilder::new(), &default_headers).build()?; + + let mut oauth_state = OAuthState::new(server_url, Some(http_client)).await?; oauth_state .start_authorization(&[], &redirect_uri, Some("Codex")) .await?; diff --git a/codex-rs/rmcp-client/src/rmcp_client.rs b/codex-rs/rmcp-client/src/rmcp_client.rs index 3d12e50868..038f32ef9c 100644 --- a/codex-rs/rmcp-client/src/rmcp_client.rs +++ b/codex-rs/rmcp-client/src/rmcp_client.rs @@ -14,6 +14,7 @@ use mcp_types::InitializeRequestParams; use mcp_types::InitializeResult; use mcp_types::ListToolsRequestParams; use mcp_types::ListToolsResult; +use reqwest::header::HeaderMap; use rmcp::model::CallToolRequestParam; use rmcp::model::InitializeRequestParam; use rmcp::model::PaginatedRequestParam; @@ -38,6 +39,8 @@ use crate::logging_client_handler::LoggingClientHandler; use crate::oauth::OAuthCredentialsStoreMode; use crate::oauth::OAuthPersistor; use crate::oauth::StoredOAuthTokens; +use crate::utils::apply_default_headers; +use crate::utils::build_default_headers; use crate::utils::convert_call_tool_result; use crate::utils::convert_to_mcp; use crate::utils::convert_to_rmcp; @@ -116,12 +119,17 @@ impl RmcpClient { }) } + #[allow(clippy::too_many_arguments)] pub async fn new_streamable_http_client( server_name: &str, url: &str, bearer_token: Option, + http_headers: Option>, + env_http_headers: Option>, store_mode: OAuthCredentialsStoreMode, ) -> Result { + let default_headers = build_default_headers(http_headers, env_http_headers)?; + let initial_oauth_tokens = match bearer_token { Some(_) => None, None => match load_oauth_tokens(server_name, url, store_mode) { @@ -132,21 +140,30 @@ impl RmcpClient { } }, }; + let transport = if let Some(initial_tokens) = initial_oauth_tokens.clone() { - let (transport, oauth_persistor) = - create_oauth_transport_and_runtime(server_name, url, initial_tokens, store_mode) - .await?; + let (transport, oauth_persistor) = create_oauth_transport_and_runtime( + server_name, + url, + initial_tokens, + store_mode, + default_headers.clone(), + ) + .await?; PendingTransport::StreamableHttpWithOAuth { transport, oauth_persistor, } } else { let mut http_config = StreamableHttpClientTransportConfig::with_uri(url.to_string()); - if let Some(bearer_token) = bearer_token { + if let Some(bearer_token) = bearer_token.clone() { http_config = http_config.auth_header(bearer_token); } - let transport = StreamableHttpClientTransport::from_config(http_config); + let http_client = + apply_default_headers(reqwest::Client::builder(), &default_headers).build()?; + + let transport = StreamableHttpClientTransport::with_client(http_client, http_config); PendingTransport::StreamableHttp { transport } }; Ok(Self { @@ -290,11 +307,13 @@ async fn create_oauth_transport_and_runtime( url: &str, initial_tokens: StoredOAuthTokens, credentials_store: OAuthCredentialsStoreMode, + default_headers: HeaderMap, ) -> Result<( StreamableHttpClientTransport>, OAuthPersistor, )> { - let http_client = reqwest::Client::builder().build()?; + let http_client = + apply_default_headers(reqwest::Client::builder(), &default_headers).build()?; let mut oauth_state = OAuthState::new(url.to_string(), Some(http_client.clone())).await?; oauth_state diff --git a/codex-rs/rmcp-client/src/utils.rs b/codex-rs/rmcp-client/src/utils.rs index 6b7bd89424..cccb12c05a 100644 --- a/codex-rs/rmcp-client/src/utils.rs +++ b/codex-rs/rmcp-client/src/utils.rs @@ -6,6 +6,10 @@ use anyhow::Context; use anyhow::Result; use anyhow::anyhow; use mcp_types::CallToolResult; +use reqwest::ClientBuilder; +use reqwest::header::HeaderMap; +use reqwest::header::HeaderName; +use reqwest::header::HeaderValue; use rmcp::model::CallToolResult as RmcpCallToolResult; use rmcp::service::ServiceError; use serde_json::Value; @@ -78,6 +82,75 @@ pub(crate) fn create_env_for_mcp_server( .collect() } +pub(crate) fn build_default_headers( + http_headers: Option>, + env_http_headers: Option>, +) -> Result { + let mut headers = HeaderMap::new(); + + if let Some(static_headers) = http_headers { + for (name, value) in static_headers { + let header_name = match HeaderName::from_bytes(name.as_bytes()) { + Ok(name) => name, + Err(err) => { + tracing::warn!("invalid HTTP header name `{name}`: {err}"); + continue; + } + }; + let header_value = match HeaderValue::from_str(value.as_str()) { + Ok(value) => value, + Err(err) => { + tracing::warn!("invalid HTTP header value for `{name}`: {err}"); + continue; + } + }; + headers.insert(header_name, header_value); + } + } + + if let Some(env_headers) = env_http_headers { + for (name, env_var) in env_headers { + if let Ok(value) = env::var(&env_var) { + if value.trim().is_empty() { + continue; + } + + let header_name = match HeaderName::from_bytes(name.as_bytes()) { + Ok(name) => name, + Err(err) => { + tracing::warn!("invalid HTTP header name `{name}`: {err}"); + continue; + } + }; + + let header_value = match HeaderValue::from_str(value.as_str()) { + Ok(value) => value, + Err(err) => { + tracing::warn!( + "invalid HTTP header value read from {env_var} for `{name}`: {err}" + ); + continue; + } + }; + headers.insert(header_name, header_value); + } + } + } + + Ok(headers) +} + +pub(crate) fn apply_default_headers( + builder: ClientBuilder, + default_headers: &HeaderMap, +) -> ClientBuilder { + if default_headers.is_empty() { + builder + } else { + builder.default_headers(default_headers.clone()) + } +} + #[cfg(unix)] pub(crate) const DEFAULT_ENV_VARS: &[&str] = &[ "HOME", diff --git a/codex-rs/tui/src/history_cell.rs b/codex-rs/tui/src/history_cell.rs index fe37d5fa09..2dd2b57857 100644 --- a/codex-rs/tui/src/history_cell.rs +++ b/codex-rs/tui/src/history_cell.rs @@ -1031,8 +1031,37 @@ pub(crate) fn new_mcp_tools_output( lines.push(vec![" • Env: ".into(), env_pairs.join(" ").into()].into()); } } - McpServerTransportConfig::StreamableHttp { url, .. } => { + McpServerTransportConfig::StreamableHttp { + url, + http_headers, + env_http_headers, + .. + } => { lines.push(vec![" • URL: ".into(), url.clone().into()].into()); + if let Some(headers) = http_headers.as_ref() + && !headers.is_empty() + { + let mut pairs: Vec<_> = headers.iter().collect(); + pairs.sort_by(|(a, _), (b, _)| a.cmp(b)); + let display = pairs + .into_iter() + .map(|(name, value)| format!("{name}={value}")) + .collect::>() + .join(", "); + lines.push(vec![" • HTTP headers: ".into(), display.into()].into()); + } + if let Some(headers) = env_http_headers.as_ref() + && !headers.is_empty() + { + let mut pairs: Vec<_> = headers.iter().collect(); + pairs.sort_by(|(a, _), (b, _)| a.cmp(b)); + let display = pairs + .into_iter() + .map(|(name, env_var)| format!("{name}={env_var}")) + .collect::>() + .join(", "); + lines.push(vec![" • Env HTTP headers: ".into(), display.into()].into()); + } } } From da5492694b1c8488e230ba79aa95238f753b67c7 Mon Sep 17 00:00:00 2001 From: pakrym-oai Date: Thu, 16 Oct 2025 21:03:23 -0700 Subject: [PATCH 17/35] Add log upload support (#5257) --- codex-rs/Cargo.lock | 190 ++++++++++++++ codex-rs/Cargo.toml | 5 +- codex-rs/feedback/Cargo.toml | 13 + codex-rs/feedback/src/lib.rs | 231 ++++++++++++++++++ codex-rs/tui/Cargo.toml | 1 + codex-rs/tui/src/app.rs | 9 +- codex-rs/tui/src/app_backtrack.rs | 1 + codex-rs/tui/src/bottom_pane/feedback_view.rs | 224 +++++++++++++++++ codex-rs/tui/src/bottom_pane/mod.rs | 2 + ...ack_view__tests__feedback_view_render.snap | 17 ++ codex-rs/tui/src/chatwidget.rs | 26 ++ codex-rs/tui/src/chatwidget/tests.rs | 2 + codex-rs/tui/src/lib.rs | 32 ++- codex-rs/tui/src/slash_command.rs | 11 +- 14 files changed, 750 insertions(+), 14 deletions(-) create mode 100644 codex-rs/feedback/Cargo.toml create mode 100644 codex-rs/feedback/src/lib.rs create mode 100644 codex-rs/tui/src/bottom_pane/feedback_view.rs create mode 100644 codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__feedback_view__tests__feedback_view_render.snap diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 70514dd5da..da19466ce4 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -1144,6 +1144,17 @@ dependencies = [ "tempfile", ] +[[package]] +name = "codex-feedback" +version = "0.0.0" +dependencies = [ + "anyhow", + "codex-protocol", + "pretty_assertions", + "sentry", + "tracing-subscriber", +] + [[package]] name = "codex-file-search" version = "0.0.0" @@ -1388,6 +1399,7 @@ dependencies = [ "codex-arg0", "codex-common", "codex-core", + "codex-feedback", "codex-file-search", "codex-git-tooling", "codex-login", @@ -1824,6 +1836,16 @@ version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "092966b41edc516079bdf31ec78a2e0588d1d0c08f78b91d8307215928642b2b" +[[package]] +name = "debugid" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef552e6f588e446098f6ba40d89ac146c8c7b64aade83c051ee00bb5d2bc18d" +dependencies = [ + "serde", + "uuid", +] + [[package]] name = "debugserver-types" version = "0.5.0" @@ -2302,6 +2324,18 @@ dependencies = [ "winapi", ] +[[package]] +name = "findshlibs" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40b9e59cd0f7e0806cca4be089683ecb6434e602038df21fe6bf6711b2f07f64" +dependencies = [ + "cc", + "lazy_static", + "libc", + "winapi", +] + [[package]] name = "fixed_decimal" version = "0.7.0" @@ -2670,6 +2704,17 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "hostname" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a56f203cd1c76362b69e3863fd987520ac36cf70a8c92627449b2f64a8cf7d65" +dependencies = [ + "cfg-if", + "libc", + "windows-link 0.1.3", +] + [[package]] name = "http" version = "1.3.1" @@ -4906,6 +4951,15 @@ version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + [[package]] name = "rustix" version = "0.38.44" @@ -5220,6 +5274,120 @@ dependencies = [ "libc", ] +[[package]] +name = "semver" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" + +[[package]] +name = "sentry" +version = "0.34.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5484316556650182f03b43d4c746ce0e3e48074a21e2f51244b648b6542e1066" +dependencies = [ + "httpdate", + "native-tls", + "reqwest", + "sentry-backtrace", + "sentry-contexts", + "sentry-core", + "sentry-debug-images", + "sentry-panic", + "sentry-tracing", + "tokio", + "ureq", +] + +[[package]] +name = "sentry-backtrace" +version = "0.34.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40aa225bb41e2ec9d7c90886834367f560efc1af028f1c5478a6cce6a59c463a" +dependencies = [ + "backtrace", + "once_cell", + "regex", + "sentry-core", +] + +[[package]] +name = "sentry-contexts" +version = "0.34.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a8dd746da3d16cb8c39751619cefd4fcdbd6df9610f3310fd646b55f6e39910" +dependencies = [ + "hostname", + "libc", + "os_info", + "rustc_version", + "sentry-core", + "uname", +] + +[[package]] +name = "sentry-core" +version = "0.34.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "161283cfe8e99c8f6f236a402b9ccf726b201f365988b5bb637ebca0abbd4a30" +dependencies = [ + "once_cell", + "rand 0.8.5", + "sentry-types", + "serde", + "serde_json", +] + +[[package]] +name = "sentry-debug-images" +version = "0.34.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fc6b25e945fcaa5e97c43faee0267eebda9f18d4b09a251775d8fef1086238a" +dependencies = [ + "findshlibs", + "once_cell", + "sentry-core", +] + +[[package]] +name = "sentry-panic" +version = "0.34.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc74f229c7186dd971a9491ffcbe7883544aa064d1589bd30b83fb856cd22d63" +dependencies = [ + "sentry-backtrace", + "sentry-core", +] + +[[package]] +name = "sentry-tracing" +version = "0.34.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd3c5faf2103cd01eeda779ea439b68c4ee15adcdb16600836e97feafab362ec" +dependencies = [ + "sentry-backtrace", + "sentry-core", + "tracing-core", + "tracing-subscriber", +] + +[[package]] +name = "sentry-types" +version = "0.34.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d68cdf6bc41b8ff3ae2a9c4671e97426dcdd154cc1d4b6b72813f285d6b163f" +dependencies = [ + "debugid", + "hex", + "rand 0.8.5", + "serde", + "serde_json", + "thiserror 1.0.69", + "time", + "url", + "uuid", +] + [[package]] name = "serde" version = "1.0.226" @@ -6427,6 +6595,15 @@ dependencies = [ "winapi", ] +[[package]] +name = "uname" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b72f89f0ca32e4db1c04e2a72f5345d59796d4866a1ee0609084569f73683dc8" +dependencies = [ + "libc", +] + [[package]] name = "unicase" version = "2.8.1" @@ -6486,6 +6663,19 @@ version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" +[[package]] +name = "ureq" +version = "2.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02d1a66277ed75f640d608235660df48c8e3c19f3b4edb6a263315626cc3c01d" +dependencies = [ + "base64", + "log", + "native-tls", + "once_cell", + "url", +] + [[package]] name = "url" version = "2.5.4" diff --git a/codex-rs/Cargo.toml b/codex-rs/Cargo.toml index 1c1640be3f..4ca4b7e6e1 100644 --- a/codex-rs/Cargo.toml +++ b/codex-rs/Cargo.toml @@ -6,6 +6,7 @@ members = [ "app-server-protocol", "apply-patch", "arg0", + "feedback", "codex-backend-openapi-models", "cloud-tasks", "cloud-tasks-client", @@ -56,6 +57,7 @@ codex-chatgpt = { path = "chatgpt" } codex-common = { path = "common" } codex-core = { path = "core" } codex-exec = { path = "exec" } +codex-feedback = { path = "feedback" } codex-file-search = { path = "file-search" } codex-git-tooling = { path = "git-tooling" } codex-linux-sandbox = { path = "linux-sandbox" } @@ -83,8 +85,8 @@ ansi-to-tui = "7.0.0" anyhow = "1" arboard = "3" askama = "0.12" -assert_matches = "1.5.0" assert_cmd = "2" +assert_matches = "1.5.0" async-channel = "2.3.1" async-stream = "0.3.6" async-trait = "0.1.89" @@ -147,6 +149,7 @@ reqwest = "0.12" rmcp = { version = "0.8.0", default-features = false } schemars = "0.8.22" seccompiler = "0.5.0" +sentry = "0.34.0" serde = "1" serde_json = "1" serde_with = "3.14" diff --git a/codex-rs/feedback/Cargo.toml b/codex-rs/feedback/Cargo.toml new file mode 100644 index 0000000000..b104f51225 --- /dev/null +++ b/codex-rs/feedback/Cargo.toml @@ -0,0 +1,13 @@ +[package] +edition.workspace = true +name = "codex-feedback" +version.workspace = true + +[dependencies] +anyhow = { workspace = true } +codex-protocol = { workspace = true } +sentry = { version = "0.34" } +tracing-subscriber = { workspace = true } + +[dev-dependencies] +pretty_assertions = { workspace = true } diff --git a/codex-rs/feedback/src/lib.rs b/codex-rs/feedback/src/lib.rs new file mode 100644 index 0000000000..285ab0661d --- /dev/null +++ b/codex-rs/feedback/src/lib.rs @@ -0,0 +1,231 @@ +use std::collections::VecDeque; +use std::fs; +use std::io::Write; +use std::io::{self}; +use std::path::PathBuf; +use std::sync::Arc; +use std::sync::Mutex; +use std::time::Duration; + +use anyhow::Result; +use anyhow::anyhow; +use codex_protocol::ConversationId; +use tracing_subscriber::fmt::writer::MakeWriter; + +const DEFAULT_MAX_BYTES: usize = 2 * 1024 * 1024; // 2 MiB +const SENTRY_DSN: &str = + "https://ae32ed50620d7a7792c1ce5df38b3e3e@o33249.ingest.us.sentry.io/4510195390611458"; +const UPLOAD_TIMEOUT_SECS: u64 = 10; + +#[derive(Clone)] +pub struct CodexFeedback { + inner: Arc, +} + +impl Default for CodexFeedback { + fn default() -> Self { + Self::new() + } +} + +impl CodexFeedback { + pub fn new() -> Self { + Self::with_capacity(DEFAULT_MAX_BYTES) + } + + pub(crate) fn with_capacity(max_bytes: usize) -> Self { + Self { + inner: Arc::new(FeedbackInner::new(max_bytes)), + } + } + + pub fn make_writer(&self) -> FeedbackMakeWriter { + FeedbackMakeWriter { + inner: self.inner.clone(), + } + } + + pub fn snapshot(&self, session_id: Option) -> CodexLogSnapshot { + let bytes = { + let guard = self.inner.ring.lock().expect("mutex poisoned"); + guard.snapshot_bytes() + }; + CodexLogSnapshot { + bytes, + thread_id: session_id + .map(|id| id.to_string()) + .unwrap_or("no-active-thread-".to_string() + &ConversationId::new().to_string()), + } + } +} + +struct FeedbackInner { + ring: Mutex, +} + +impl FeedbackInner { + fn new(max_bytes: usize) -> Self { + Self { + ring: Mutex::new(RingBuffer::new(max_bytes)), + } + } +} + +#[derive(Clone)] +pub struct FeedbackMakeWriter { + inner: Arc, +} + +impl<'a> MakeWriter<'a> for FeedbackMakeWriter { + type Writer = FeedbackWriter; + + fn make_writer(&'a self) -> Self::Writer { + FeedbackWriter { + inner: self.inner.clone(), + } + } +} + +pub struct FeedbackWriter { + inner: Arc, +} + +impl Write for FeedbackWriter { + fn write(&mut self, buf: &[u8]) -> io::Result { + let mut guard = self.inner.ring.lock().map_err(|_| io::ErrorKind::Other)?; + guard.push_bytes(buf); + Ok(buf.len()) + } + + fn flush(&mut self) -> io::Result<()> { + Ok(()) + } +} + +struct RingBuffer { + max: usize, + buf: VecDeque, +} + +impl RingBuffer { + fn new(capacity: usize) -> Self { + Self { + max: capacity, + buf: VecDeque::with_capacity(capacity), + } + } + + fn len(&self) -> usize { + self.buf.len() + } + + fn push_bytes(&mut self, data: &[u8]) { + if data.is_empty() { + return; + } + + // If the incoming chunk is larger than capacity, keep only the trailing bytes. + if data.len() >= self.max { + self.buf.clear(); + let start = data.len() - self.max; + self.buf.extend(data[start..].iter().copied()); + return; + } + + // Evict from the front if we would exceed capacity. + let needed = self.len() + data.len(); + if needed > self.max { + let to_drop = needed - self.max; + for _ in 0..to_drop { + let _ = self.buf.pop_front(); + } + } + + self.buf.extend(data.iter().copied()); + } + + fn snapshot_bytes(&self) -> Vec { + self.buf.iter().copied().collect() + } +} + +pub struct CodexLogSnapshot { + bytes: Vec, + pub thread_id: String, +} + +impl CodexLogSnapshot { + pub(crate) fn as_bytes(&self) -> &[u8] { + &self.bytes + } + + pub fn save_to_temp_file(&self) -> io::Result { + let dir = std::env::temp_dir(); + let filename = format!("codex-feedback-{}.log", self.thread_id); + let path = dir.join(filename); + fs::write(&path, self.as_bytes())?; + Ok(path) + } + + pub fn upload_to_sentry(&self) -> Result<()> { + use std::collections::BTreeMap; + use std::str::FromStr; + use std::sync::Arc; + + use sentry::Client; + use sentry::ClientOptions; + use sentry::protocol::Attachment; + use sentry::protocol::Envelope; + use sentry::protocol::EnvelopeItem; + use sentry::protocol::Event; + use sentry::protocol::Level; + use sentry::transports::DefaultTransportFactory; + use sentry::types::Dsn; + + let client = Client::from_config(ClientOptions { + dsn: Some(Dsn::from_str(SENTRY_DSN).map_err(|e| anyhow!("invalid DSN: {}", e))?), + transport: Some(Arc::new(DefaultTransportFactory {})), + ..Default::default() + }); + + let tags = BTreeMap::from([(String::from("thread_id"), self.thread_id.to_string())]); + + let event = Event { + level: Level::Error, + message: Some("Codex Log Upload ".to_string() + &self.thread_id), + tags, + ..Default::default() + }; + let mut envelope = Envelope::new(); + envelope.add_item(EnvelopeItem::Event(event)); + envelope.add_item(EnvelopeItem::Attachment(Attachment { + buffer: self.bytes.clone(), + filename: String::from("codex-logs.log"), + content_type: Some("text/plain".to_string()), + ty: None, + })); + + client.send_envelope(envelope); + client.flush(Some(Duration::from_secs(UPLOAD_TIMEOUT_SECS))); + + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn ring_buffer_drops_front_when_full() { + let fb = CodexFeedback::with_capacity(8); + { + let mut w = fb.make_writer().make_writer(); + w.write_all(b"abcdefgh").unwrap(); + w.write_all(b"ij").unwrap(); + } + let snap = fb.snapshot(None); + // Capacity 8: after writing 10 bytes, we should keep the last 8. + pretty_assertions::assert_eq!(std::str::from_utf8(snap.as_bytes()).unwrap(), "cdefghij"); + } +} diff --git a/codex-rs/tui/Cargo.toml b/codex-rs/tui/Cargo.toml index 1ba3f0867b..41ffa31030 100644 --- a/codex-rs/tui/Cargo.toml +++ b/codex-rs/tui/Cargo.toml @@ -40,6 +40,7 @@ codex-login = { workspace = true } codex-ollama = { workspace = true } codex-protocol = { workspace = true } codex-app-server-protocol = { workspace = true } +codex-feedback = { workspace = true } color-eyre = { workspace = true } crossterm = { workspace = true, features = ["bracketed-paste", "event-stream"] } diffy = { workspace = true } diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 11a10d6d11..e1c2d062a4 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -74,12 +74,13 @@ pub(crate) struct App { // Esc-backtracking state grouped pub(crate) backtrack: crate::app_backtrack::BacktrackState, - + pub(crate) feedback: codex_feedback::CodexFeedback, /// Set when the user confirms an update; propagated on exit. pub(crate) pending_update_action: Option, } impl App { + #[allow(clippy::too_many_arguments)] pub async fn run( tui: &mut tui::Tui, auth_manager: Arc, @@ -88,6 +89,7 @@ impl App { initial_prompt: Option, initial_images: Vec, resume_selection: ResumeSelection, + feedback: codex_feedback::CodexFeedback, ) -> Result { use tokio_stream::StreamExt; let (app_event_tx, mut app_event_rx) = unbounded_channel(); @@ -110,6 +112,7 @@ impl App { initial_images: initial_images.clone(), enhanced_keys_supported, auth_manager: auth_manager.clone(), + feedback: feedback.clone(), }; ChatWidget::new(init, conversation_manager.clone()) } @@ -132,6 +135,7 @@ impl App { initial_images: initial_images.clone(), enhanced_keys_supported, auth_manager: auth_manager.clone(), + feedback: feedback.clone(), }; ChatWidget::new_from_existing( init, @@ -158,6 +162,7 @@ impl App { has_emitted_history_lines: false, commit_anim_running: Arc::new(AtomicBool::new(false)), backtrack: BacktrackState::default(), + feedback: feedback.clone(), pending_update_action: None, }; @@ -236,6 +241,7 @@ impl App { initial_images: Vec::new(), enhanced_keys_supported: self.enhanced_keys_supported, auth_manager: self.auth_manager.clone(), + feedback: self.feedback.clone(), }; self.chat_widget = ChatWidget::new(init, self.server.clone()); tui.frame_requester().schedule_frame(); @@ -549,6 +555,7 @@ mod tests { enhanced_keys_supported: false, commit_anim_running: Arc::new(AtomicBool::new(false)), backtrack: BacktrackState::default(), + feedback: codex_feedback::CodexFeedback::new(), pending_update_action: None, } } diff --git a/codex-rs/tui/src/app_backtrack.rs b/codex-rs/tui/src/app_backtrack.rs index 8f889623cf..b3e948ecb3 100644 --- a/codex-rs/tui/src/app_backtrack.rs +++ b/codex-rs/tui/src/app_backtrack.rs @@ -339,6 +339,7 @@ impl App { initial_images: Vec::new(), enhanced_keys_supported: self.enhanced_keys_supported, auth_manager: self.auth_manager.clone(), + feedback: self.feedback.clone(), }; self.chat_widget = crate::chatwidget::ChatWidget::new_from_existing(init, conv, session_configured); diff --git a/codex-rs/tui/src/bottom_pane/feedback_view.rs b/codex-rs/tui/src/bottom_pane/feedback_view.rs new file mode 100644 index 0000000000..9c75e32bdf --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/feedback_view.rs @@ -0,0 +1,224 @@ +use crate::app_event::AppEvent; +use crate::app_event_sender::AppEventSender; +use crate::history_cell; +use crate::history_cell::PlainHistoryCell; +use crate::render::renderable::Renderable; +use ratatui::buffer::Buffer; +use ratatui::layout::Rect; +use ratatui::style::Stylize; +use ratatui::text::Line; +use std::path::PathBuf; + +use super::BottomPane; +use super::SelectionAction; +use super::SelectionItem; +use super::SelectionViewParams; + +const BASE_ISSUE_URL: &str = "https://github.com/openai/codex/issues/new?template=2-bug-report.yml"; + +pub(crate) struct FeedbackView; + +impl FeedbackView { + pub fn show( + bottom_pane: &mut BottomPane, + file_path: PathBuf, + snapshot: codex_feedback::CodexLogSnapshot, + ) { + bottom_pane.show_selection_view(Self::selection_params(file_path, snapshot)); + } + + fn selection_params( + file_path: PathBuf, + snapshot: codex_feedback::CodexLogSnapshot, + ) -> SelectionViewParams { + let header = FeedbackHeader::new(file_path); + + let thread_id = snapshot.thread_id.clone(); + + let upload_action_tread_id = thread_id.clone(); + let upload_action: SelectionAction = Box::new(move |tx: &AppEventSender| { + match snapshot.upload_to_sentry() { + Ok(()) => { + let issue_url = format!( + "{BASE_ISSUE_URL}&steps=Uploaded%20thread:%20{upload_action_tread_id}", + ); + tx.send(AppEvent::InsertHistoryCell(Box::new(PlainHistoryCell::new(vec![ + Line::from( + "• Codex logs uploaded. Please open an issue using the following URL:", + ), + "".into(), + Line::from(vec![" ".into(), issue_url.cyan().underlined()]), + "".into(), + Line::from(vec![" Or mention your thread ID ".into(), upload_action_tread_id.clone().bold(), " in an existing issue.".into()]) + ])))); + } + Err(e) => { + tx.send(AppEvent::InsertHistoryCell(Box::new( + history_cell::new_error_event(format!("Failed to upload logs: {e}")), + ))); + } + } + }); + + let upload_item = SelectionItem { + name: "Yes".to_string(), + description: Some( + "Share the current Codex session logs with the team for troubleshooting." + .to_string(), + ), + actions: vec![upload_action], + dismiss_on_select: true, + ..Default::default() + }; + + let no_action: SelectionAction = Box::new(move |tx: &AppEventSender| { + let issue_url = format!("{BASE_ISSUE_URL}&steps=Thread%20ID:%20{thread_id}",); + + tx.send(AppEvent::InsertHistoryCell(Box::new( + PlainHistoryCell::new(vec![ + Line::from("• Please open an issue using the following URL:"), + "".into(), + Line::from(vec![" ".into(), issue_url.cyan().underlined()]), + "".into(), + Line::from(vec![ + " Or mention your thread ID ".into(), + thread_id.clone().bold(), + " in an existing issue.".into(), + ]), + ]), + ))); + }); + + let no_item = SelectionItem { + name: "No".to_string(), + actions: vec![no_action], + dismiss_on_select: true, + ..Default::default() + }; + + let cancel_item = SelectionItem { + name: "Cancel".to_string(), + dismiss_on_select: true, + ..Default::default() + }; + + SelectionViewParams { + header: Box::new(header), + items: vec![upload_item, no_item, cancel_item], + ..Default::default() + } + } +} + +struct FeedbackHeader { + file_path: PathBuf, +} + +impl FeedbackHeader { + fn new(file_path: PathBuf) -> Self { + Self { file_path } + } + + fn lines(&self) -> Vec> { + vec![ + Line::from("Do you want to upload logs before reporting issue?".bold()), + "".into(), + Line::from( + "Logs may include the full conversation history of this Codex process, including prompts, tool calls, and their results.", + ), + Line::from( + "These logs are retained for 90 days and are used solely for troubleshooting and diagnostic purposes.", + ), + "".into(), + Line::from(vec![ + "You can review the exact content of the logs before they’re uploaded at:".into(), + ]), + Line::from(self.file_path.display().to_string().dim()), + "".into(), + ] + } +} + +impl Renderable for FeedbackHeader { + fn render(&self, area: Rect, buf: &mut Buffer) { + if area.width == 0 || area.height == 0 { + return; + } + + for (i, line) in self.lines().into_iter().enumerate() { + let y = area.y.saturating_add(i as u16); + if y >= area.y.saturating_add(area.height) { + break; + } + let line_area = Rect::new(area.x, y, area.width, 1).intersection(area); + line.render(line_area, buf); + } + } + + fn desired_height(&self, width: u16) -> u16 { + self.lines() + .iter() + .map(|line| line.desired_height(width)) + .sum() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::app_event::AppEvent; + use crate::bottom_pane::list_selection_view::ListSelectionView; + use crate::style::user_message_style; + use codex_feedback::CodexFeedback; + use codex_protocol::ConversationId; + use insta::assert_snapshot; + use ratatui::buffer::Buffer; + use ratatui::layout::Rect; + use ratatui::style::Color; + use tokio::sync::mpsc::unbounded_channel; + + fn buffer_to_string(buffer: &Buffer) -> String { + (0..buffer.area.height) + .map(|row| { + let mut line = String::new(); + for col in 0..buffer.area.width { + let symbol = buffer[(buffer.area.x + col, buffer.area.y + row)].symbol(); + if symbol.is_empty() { + line.push(' '); + } else { + line.push_str(symbol); + } + } + line.trim_end().to_string() + }) + .collect::>() + .join("\n") + } + + #[test] + fn renders_feedback_view_header() { + let (tx_raw, _rx) = unbounded_channel::(); + let app_event_tx = AppEventSender::new(tx_raw); + let snapshot = CodexFeedback::new().snapshot(Some( + ConversationId::from_string("550e8400-e29b-41d4-a716-446655440000").unwrap(), + )); + let file_path = PathBuf::from("/tmp/codex-feedback.log"); + + let params = FeedbackView::selection_params(file_path.clone(), snapshot); + let view = ListSelectionView::new(params, app_event_tx); + + let width = 72; + let height = view.desired_height(width).max(1); + let area = Rect::new(0, 0, width, height); + let mut buf = Buffer::empty(area); + view.render(area, &mut buf); + + let rendered = + buffer_to_string(&buf).replace(&file_path.display().to_string(), ""); + assert_snapshot!("feedback_view_render", rendered); + + let cell_style = buf[(area.x, area.y)].style(); + let expected_bg = user_message_style().bg.unwrap_or(Color::Reset); + assert_eq!(cell_style.bg.unwrap_or(Color::Reset), expected_bg); + } +} diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index d8c3265f95..8d9e84f2aa 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -27,11 +27,13 @@ mod footer; mod list_selection_view; mod prompt_args; pub(crate) use list_selection_view::SelectionViewParams; +mod feedback_view; mod paste_burst; pub mod popup_consts; mod scroll_state; mod selection_popup_common; mod textarea; +pub(crate) use feedback_view::FeedbackView; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) enum CancellationEvent { diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__feedback_view__tests__feedback_view_render.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__feedback_view__tests__feedback_view_render.snap new file mode 100644 index 0000000000..bafa94b09d --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__feedback_view__tests__feedback_view_render.snap @@ -0,0 +1,17 @@ +--- +source: tui/src/bottom_pane/feedback_view.rs +expression: rendered +--- + Do you want to upload logs before reporting issue? + + Logs may include the full conversation history of this Codex process + These logs are retained for 90 days and are used solely for troubles + + You can review the exact content of the logs before they’re uploaded + + + +› 1. Yes Share the current Codex session logs with the team for + troubleshooting. + 2. No + 3. Cancel diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 775242a597..f592695346 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -224,6 +224,7 @@ pub(crate) struct ChatWidgetInit { pub(crate) initial_images: Vec, pub(crate) enhanced_keys_supported: bool, pub(crate) auth_manager: Arc, + pub(crate) feedback: codex_feedback::CodexFeedback, } pub(crate) struct ChatWidget { @@ -272,6 +273,8 @@ pub(crate) struct ChatWidget { needs_final_message_separator: bool, last_rendered_width: std::cell::Cell>, + // Feedback sink for /feedback + feedback: codex_feedback::CodexFeedback, } struct UserMessage { @@ -917,6 +920,7 @@ impl ChatWidget { initial_images, enhanced_keys_supported, auth_manager, + feedback, } = common; let mut rng = rand::rng(); let placeholder = EXAMPLE_PROMPTS[rng.random_range(0..EXAMPLE_PROMPTS.len())].to_string(); @@ -963,6 +967,7 @@ impl ChatWidget { ghost_snapshots_disabled: true, needs_final_message_separator: false, last_rendered_width: std::cell::Cell::new(None), + feedback, } } @@ -980,6 +985,7 @@ impl ChatWidget { initial_images, enhanced_keys_supported, auth_manager, + feedback, } = common; let mut rng = rand::rng(); let placeholder = EXAMPLE_PROMPTS[rng.random_range(0..EXAMPLE_PROMPTS.len())].to_string(); @@ -1028,6 +1034,7 @@ impl ChatWidget { ghost_snapshots_disabled: true, needs_final_message_separator: false, last_rendered_width: std::cell::Cell::new(None), + feedback, } } @@ -1131,6 +1138,25 @@ impl ChatWidget { return; } match cmd { + SlashCommand::Feedback => { + let snapshot = self.feedback.snapshot(self.conversation_id); + match snapshot.save_to_temp_file() { + Ok(path) => { + crate::bottom_pane::FeedbackView::show( + &mut self.bottom_pane, + path, + snapshot, + ); + self.request_redraw(); + } + Err(e) => { + self.add_to_history(history_cell::new_error_event(format!( + "Failed to save feedback logs: {e}" + ))); + self.request_redraw(); + } + } + } SlashCommand::New => { self.app_event_tx.send(AppEvent::NewSession); } diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index adadcd0a0f..2055e20856 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -235,6 +235,7 @@ async fn helpers_are_available_and_do_not_panic() { initial_images: Vec::new(), enhanced_keys_supported: false, auth_manager, + feedback: codex_feedback::CodexFeedback::new(), }; let mut w = ChatWidget::new(init, conversation_manager); // Basic construction sanity. @@ -291,6 +292,7 @@ fn make_chatwidget_manual() -> ( ghost_snapshots_disabled: false, needs_final_message_separator: false, last_rendered_width: std::cell::Cell::new(None), + feedback: codex_feedback::CodexFeedback::new(), }; (widget, rx, op_rx) } diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index 7447ef5feb..aa40b05c55 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -23,6 +23,7 @@ use std::path::PathBuf; use tracing::error; use tracing_appender::non_blocking; use tracing_subscriber::EnvFilter; +use tracing_subscriber::filter::Targets; use tracing_subscriber::prelude::*; mod app; @@ -219,13 +220,21 @@ pub async fn run_main( }) }; - // Build layered subscriber: let file_layer = tracing_subscriber::fmt::layer() .with_writer(non_blocking) .with_target(false) .with_span_events(tracing_subscriber::fmt::format::FmtSpan::CLOSE) .with_filter(env_filter()); + let feedback = codex_feedback::CodexFeedback::new(); + let targets = Targets::new().with_default(tracing::Level::TRACE); + + let feedback_layer = tracing_subscriber::fmt::layer() + .with_writer(feedback.make_writer()) + .with_ansi(false) + .with_target(false) + .with_filter(targets); + if cli.oss { codex_ollama::ensure_oss_ready(&config) .await @@ -250,15 +259,26 @@ pub async fn run_main( let _ = tracing_subscriber::registry() .with(file_layer) + .with(feedback_layer) .with(otel_layer) .try_init(); } else { - let _ = tracing_subscriber::registry().with(file_layer).try_init(); + let _ = tracing_subscriber::registry() + .with(file_layer) + .with(feedback_layer) + .try_init(); }; - run_ratatui_app(cli, config, overrides, cli_kv_overrides, active_profile) - .await - .map_err(|err| std::io::Error::other(err.to_string())) + run_ratatui_app( + cli, + config, + overrides, + cli_kv_overrides, + active_profile, + feedback, + ) + .await + .map_err(|err| std::io::Error::other(err.to_string())) } async fn run_ratatui_app( @@ -267,6 +287,7 @@ async fn run_ratatui_app( overrides: ConfigOverrides, cli_kv_overrides: Vec<(String, toml::Value)>, active_profile: Option, + feedback: codex_feedback::CodexFeedback, ) -> color_eyre::Result { color_eyre::install()?; @@ -462,6 +483,7 @@ async fn run_ratatui_app( prompt, images, resume_selection, + feedback, ) .await; diff --git a/codex-rs/tui/src/slash_command.rs b/codex-rs/tui/src/slash_command.rs index 14604a736d..22da09f358 100644 --- a/codex-rs/tui/src/slash_command.rs +++ b/codex-rs/tui/src/slash_command.rs @@ -25,6 +25,7 @@ pub enum SlashCommand { Mcp, Logout, Quit, + Feedback, #[cfg(debug_assertions)] TestApproval, } @@ -33,6 +34,7 @@ impl SlashCommand { /// User-visible description shown in the popup. pub fn description(self) -> &'static str { match self { + SlashCommand::Feedback => "send logs to maintainers", SlashCommand::New => "start a new chat during a conversation", SlashCommand::Init => "create an AGENTS.md file with instructions for Codex", SlashCommand::Compact => "summarize conversation to prevent hitting the context limit", @@ -72,6 +74,7 @@ impl SlashCommand { | SlashCommand::Mention | SlashCommand::Status | SlashCommand::Mcp + | SlashCommand::Feedback | SlashCommand::Quit => true, #[cfg(debug_assertions)] @@ -85,13 +88,7 @@ pub fn built_in_slash_commands() -> Vec<(&'static str, SlashCommand)> { let show_beta_features = beta_features_enabled(); SlashCommand::iter() - .filter(|cmd| { - if *cmd == SlashCommand::Undo { - show_beta_features - } else { - true - } - }) + .filter(|cmd| *cmd != SlashCommand::Undo || show_beta_features) .map(|c| (c.command(), c)) .collect() } From bdda762deb38f6cc8ba18debc74716928d22a947 Mon Sep 17 00:00:00 2001 From: Gabriel Peal Date: Thu, 16 Oct 2025 21:24:43 -0700 Subject: [PATCH 18/35] [MCP] Allow specifying cwd and additional env vars (#5246) This makes stdio mcp servers more flexible by allowing users to specify the cwd to run the server command from and adding additional environment variables to be passed through to the server. Example config using the test server in this repo: ```toml [mcp_servers.test_stdio] cwd = "/Users//code/codex/codex-rs" command = "cargo" args = ["run", "--bin", "test_stdio_server"] env_vars = ["MCP_TEST_VALUE"] ``` @bolinfest I know you hate these env var tests but let's roll with this for now. I may take a stab at the env guard + serial macro at some point. --- codex-rs/Cargo.lock | 1 + codex-rs/cli/src/mcp_cmd.rs | 102 ++++++++------ codex-rs/cli/tests/mcp_add_remove.rs | 10 +- codex-rs/cli/tests/mcp_list.rs | 30 +++- codex-rs/common/src/format_env_display.rs | 66 +++++++++ codex-rs/common/src/lib.rs | 3 + codex-rs/core/src/config.rs | 128 ++++++++++++++++- codex-rs/core/src/config_types.rs | 79 ++++++++++- codex-rs/core/src/mcp_connection_manager.rs | 20 ++- codex-rs/core/tests/suite/rmcp_client.rs | 146 +++++++++++++++++++- codex-rs/mcp-client/src/main.rs | 2 +- codex-rs/mcp-client/src/mcp_client.rs | 50 +++++-- codex-rs/rmcp-client/Cargo.toml | 1 + codex-rs/rmcp-client/src/rmcp_client.rs | 8 +- codex-rs/rmcp-client/src/utils.rs | 51 ++++++- codex-rs/tui/src/history_cell.rs | 24 ++-- docs/config.md | 5 + 17 files changed, 650 insertions(+), 76 deletions(-) create mode 100644 codex-rs/common/src/format_env_display.rs diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index da19466ce4..6289625ae0 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -1374,6 +1374,7 @@ dependencies = [ "rmcp", "serde", "serde_json", + "serial_test", "sha2", "tempfile", "tiny_http", diff --git a/codex-rs/cli/src/mcp_cmd.rs b/codex-rs/cli/src/mcp_cmd.rs index e7fd7b8dc1..50274f3ee5 100644 --- a/codex-rs/cli/src/mcp_cmd.rs +++ b/codex-rs/cli/src/mcp_cmd.rs @@ -6,6 +6,7 @@ use anyhow::anyhow; use anyhow::bail; use clap::ArgGroup; use codex_common::CliConfigOverrides; +use codex_common::format_env_display::format_env_display; use codex_core::config::Config; use codex_core::config::ConfigOverrides; use codex_core::config::find_codex_home; @@ -227,6 +228,8 @@ async fn run_add(config_overrides: &CliConfigOverrides, add_args: AddArgs) -> Re command: command_bin, args: command_args, env: env_map, + env_vars: Vec::new(), + cwd: None, } } AddMcpTransportArgs { @@ -400,11 +403,19 @@ async fn run_list(config_overrides: &CliConfigOverrides, list_args: ListArgs) -> .copied() .unwrap_or(McpAuthStatus::Unsupported); let transport = match &cfg.transport { - McpServerTransportConfig::Stdio { command, args, env } => serde_json::json!({ + McpServerTransportConfig::Stdio { + command, + args, + env, + env_vars, + cwd, + } => serde_json::json!({ "type": "stdio", "command": command, "args": args, "env": env, + "env_vars": env_vars, + "cwd": cwd, }), McpServerTransportConfig::StreamableHttp { url, @@ -446,30 +457,29 @@ async fn run_list(config_overrides: &CliConfigOverrides, list_args: ListArgs) -> return Ok(()); } - let mut stdio_rows: Vec<[String; 6]> = Vec::new(); + let mut stdio_rows: Vec<[String; 7]> = Vec::new(); let mut http_rows: Vec<[String; 5]> = Vec::new(); for (name, cfg) in entries { match &cfg.transport { - McpServerTransportConfig::Stdio { command, args, env } => { + McpServerTransportConfig::Stdio { + command, + args, + env, + env_vars, + cwd, + } => { let args_display = if args.is_empty() { "-".to_string() } else { args.join(" ") }; - let env_display = match env.as_ref() { - None => "-".to_string(), - Some(map) if map.is_empty() => "-".to_string(), - Some(map) => { - let mut pairs: Vec<_> = map.iter().collect(); - pairs.sort_by(|(a, _), (b, _)| a.cmp(b)); - pairs - .into_iter() - .map(|(k, v)| format!("{k}={v}")) - .collect::>() - .join(", ") - } - }; + let env_display = format_env_display(env.as_ref(), env_vars); + let cwd_display = cwd + .as_ref() + .map(|path| path.display().to_string()) + .filter(|value| !value.is_empty()) + .unwrap_or_else(|| "-".to_string()); let status = if cfg.enabled { "enabled".to_string() } else { @@ -485,6 +495,7 @@ async fn run_list(config_overrides: &CliConfigOverrides, list_args: ListArgs) -> command.clone(), args_display, env_display, + cwd_display, status, auth_status, ]); @@ -521,6 +532,7 @@ async fn run_list(config_overrides: &CliConfigOverrides, list_args: ListArgs) -> "Command".len(), "Args".len(), "Env".len(), + "Cwd".len(), "Status".len(), "Auth".len(), ]; @@ -531,36 +543,40 @@ async fn run_list(config_overrides: &CliConfigOverrides, list_args: ListArgs) -> } println!( - "{name: Re if get_args.json { let transport = match &server.transport { - McpServerTransportConfig::Stdio { command, args, env } => serde_json::json!({ + McpServerTransportConfig::Stdio { + command, + args, + env, + env_vars, + cwd, + } => serde_json::json!({ "type": "stdio", "command": command, "args": args, "env": env, + "env_vars": env_vars, + "cwd": cwd, }), McpServerTransportConfig::StreamableHttp { url, @@ -666,7 +690,13 @@ async fn run_get(config_overrides: &CliConfigOverrides, get_args: GetArgs) -> Re println!("{}", get_args.name); println!(" enabled: {}", server.enabled); match &server.transport { - McpServerTransportConfig::Stdio { command, args, env } => { + McpServerTransportConfig::Stdio { + command, + args, + env, + env_vars, + cwd, + } => { println!(" transport: stdio"); println!(" command: {command}"); let args_display = if args.is_empty() { @@ -675,19 +705,13 @@ async fn run_get(config_overrides: &CliConfigOverrides, get_args: GetArgs) -> Re args.join(" ") }; println!(" args: {args_display}"); - let env_display = match env.as_ref() { - None => "-".to_string(), - Some(map) if map.is_empty() => "-".to_string(), - Some(map) => { - let mut pairs: Vec<_> = map.iter().collect(); - pairs.sort_by(|(a, _), (b, _)| a.cmp(b)); - pairs - .into_iter() - .map(|(k, v)| format!("{k}={v}")) - .collect::>() - .join(", ") - } - }; + let cwd_display = cwd + .as_ref() + .map(|path| path.display().to_string()) + .filter(|value| !value.is_empty()) + .unwrap_or_else(|| "-".to_string()); + println!(" cwd: {cwd_display}"); + let env_display = format_env_display(env.as_ref(), env_vars); println!(" env: {env_display}"); } McpServerTransportConfig::StreamableHttp { diff --git a/codex-rs/cli/tests/mcp_add_remove.rs b/codex-rs/cli/tests/mcp_add_remove.rs index 83abe72c29..7a6c2daaff 100644 --- a/codex-rs/cli/tests/mcp_add_remove.rs +++ b/codex-rs/cli/tests/mcp_add_remove.rs @@ -28,10 +28,18 @@ async fn add_and_remove_server_updates_global_config() -> Result<()> { assert_eq!(servers.len(), 1); let docs = servers.get("docs").expect("server should exist"); match &docs.transport { - McpServerTransportConfig::Stdio { command, args, env } => { + McpServerTransportConfig::Stdio { + command, + args, + env, + env_vars, + cwd, + } => { assert_eq!(command, "echo"); assert_eq!(args, &vec!["hello".to_string()]); assert!(env.is_none()); + assert!(env_vars.is_empty()); + assert!(cwd.is_none()); } other => panic!("unexpected transport: {other:?}"), } diff --git a/codex-rs/cli/tests/mcp_list.rs b/codex-rs/cli/tests/mcp_list.rs index 8f33a8e470..ea0d6fc1d9 100644 --- a/codex-rs/cli/tests/mcp_list.rs +++ b/codex-rs/cli/tests/mcp_list.rs @@ -1,6 +1,9 @@ use std::path::Path; use anyhow::Result; +use codex_core::config::load_global_mcp_servers; +use codex_core::config::write_global_mcp_servers; +use codex_core::config_types::McpServerTransportConfig; use predicates::prelude::PredicateBooleanExt; use predicates::str::contains; use pretty_assertions::assert_eq; @@ -27,8 +30,8 @@ fn list_shows_empty_state() -> Result<()> { Ok(()) } -#[test] -fn list_and_get_render_expected_output() -> Result<()> { +#[tokio::test] +async fn list_and_get_render_expected_output() -> Result<()> { let codex_home = TempDir::new()?; let mut add = codex_command(codex_home.path())?; @@ -46,6 +49,18 @@ fn list_and_get_render_expected_output() -> Result<()> { .assert() .success(); + let mut servers = load_global_mcp_servers(codex_home.path()).await?; + let docs_entry = servers + .get_mut("docs") + .expect("docs server should exist after add"); + match &mut docs_entry.transport { + McpServerTransportConfig::Stdio { env_vars, .. } => { + *env_vars = vec!["APP_TOKEN".to_string(), "WORKSPACE_ID".to_string()]; + } + other => panic!("unexpected transport: {other:?}"), + } + write_global_mcp_servers(codex_home.path(), &servers)?; + let mut list_cmd = codex_command(codex_home.path())?; let list_output = list_cmd.args(["mcp", "list"]).output()?; assert!(list_output.status.success()); @@ -54,6 +69,8 @@ fn list_and_get_render_expected_output() -> Result<()> { assert!(stdout.contains("docs")); assert!(stdout.contains("docs-server")); assert!(stdout.contains("TOKEN=secret")); + assert!(stdout.contains("APP_TOKEN=$APP_TOKEN")); + assert!(stdout.contains("WORKSPACE_ID=$WORKSPACE_ID")); assert!(stdout.contains("Status")); assert!(stdout.contains("Auth")); assert!(stdout.contains("enabled")); @@ -79,7 +96,12 @@ fn list_and_get_render_expected_output() -> Result<()> { ], "env": { "TOKEN": "secret" - } + }, + "env_vars": [ + "APP_TOKEN", + "WORKSPACE_ID" + ], + "cwd": null }, "startup_timeout_sec": null, "tool_timeout_sec": null, @@ -98,6 +120,8 @@ fn list_and_get_render_expected_output() -> Result<()> { assert!(stdout.contains("command: docs-server")); assert!(stdout.contains("args: --port 4000")); assert!(stdout.contains("env: TOKEN=secret")); + assert!(stdout.contains("APP_TOKEN=$APP_TOKEN")); + assert!(stdout.contains("WORKSPACE_ID=$WORKSPACE_ID")); assert!(stdout.contains("enabled: true")); assert!(stdout.contains("remove: codex mcp remove docs")); diff --git a/codex-rs/common/src/format_env_display.rs b/codex-rs/common/src/format_env_display.rs new file mode 100644 index 0000000000..640be30783 --- /dev/null +++ b/codex-rs/common/src/format_env_display.rs @@ -0,0 +1,66 @@ +use std::collections::HashMap; + +pub fn format_env_display(env: Option<&HashMap>, env_vars: &[String]) -> String { + let mut parts: Vec = Vec::new(); + + if let Some(map) = env { + let mut pairs: Vec<_> = map.iter().collect(); + pairs.sort_by(|(a, _), (b, _)| a.cmp(b)); + parts.extend( + pairs + .into_iter() + .map(|(key, value)| format!("{key}={value}")), + ); + } + + if !env_vars.is_empty() { + parts.extend(env_vars.iter().map(|var| format!("{var}=${var}"))); + } + + if parts.is_empty() { + "-".to_string() + } else { + parts.join(", ") + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn returns_dash_when_empty() { + assert_eq!(format_env_display(None, &[]), "-"); + + let empty_map = HashMap::new(); + assert_eq!(format_env_display(Some(&empty_map), &[]), "-"); + } + + #[test] + fn formats_sorted_env_pairs() { + let mut env = HashMap::new(); + env.insert("B".to_string(), "two".to_string()); + env.insert("A".to_string(), "one".to_string()); + + assert_eq!(format_env_display(Some(&env), &[]), "A=one, B=two"); + } + + #[test] + fn formats_env_vars_with_dollar_prefix() { + let vars = vec!["TOKEN".to_string(), "PATH".to_string()]; + + assert_eq!(format_env_display(None, &vars), "TOKEN=$TOKEN, PATH=$PATH"); + } + + #[test] + fn combines_env_pairs_and_vars() { + let mut env = HashMap::new(); + env.insert("HOME".to_string(), "/tmp".to_string()); + let vars = vec!["TOKEN".to_string()]; + + assert_eq!( + format_env_display(Some(&env), &vars), + "HOME=/tmp, TOKEN=$TOKEN" + ); + } +} diff --git a/codex-rs/common/src/lib.rs b/codex-rs/common/src/lib.rs index 292503f77e..276bfca069 100644 --- a/codex-rs/common/src/lib.rs +++ b/codex-rs/common/src/lib.rs @@ -13,6 +13,9 @@ mod sandbox_mode_cli_arg; #[cfg(feature = "cli")] pub use sandbox_mode_cli_arg::SandboxModeCliArg; +#[cfg(feature = "cli")] +pub mod format_env_display; + #[cfg(any(feature = "cli", test))] mod config_override; diff --git a/codex-rs/core/src/config.rs b/codex-rs/core/src/config.rs index 570cd5151c..6f5defcedc 100644 --- a/codex-rs/core/src/config.rs +++ b/codex-rs/core/src/config.rs @@ -388,7 +388,13 @@ pub fn write_global_mcp_servers( let mut entry = TomlTable::new(); entry.set_implicit(false); match &config.transport { - McpServerTransportConfig::Stdio { command, args, env } => { + McpServerTransportConfig::Stdio { + command, + args, + env, + env_vars, + cwd, + } => { entry["command"] = toml_edit::value(command.clone()); if !args.is_empty() { @@ -411,6 +417,15 @@ pub fn write_global_mcp_servers( } entry["env"] = TomlItem::Table(env_table); } + + if !env_vars.is_empty() { + entry["env_vars"] = + TomlItem::Value(env_vars.iter().collect::().into()); + } + + if let Some(cwd) = cwd { + entry["cwd"] = toml_edit::value(cwd.to_string_lossy().to_string()); + } } McpServerTransportConfig::StreamableHttp { url, @@ -1806,6 +1821,8 @@ approve_all = true command: "echo".to_string(), args: vec!["hello".to_string()], env: None, + env_vars: Vec::new(), + cwd: None, }, enabled: true, startup_timeout_sec: Some(Duration::from_secs(3)), @@ -1819,10 +1836,18 @@ approve_all = true assert_eq!(loaded.len(), 1); let docs = loaded.get("docs").expect("docs entry"); match &docs.transport { - McpServerTransportConfig::Stdio { command, args, env } => { + McpServerTransportConfig::Stdio { + command, + args, + env, + env_vars, + cwd, + } => { assert_eq!(command, "echo"); assert_eq!(args, &vec!["hello".to_string()]); assert!(env.is_none()); + assert!(env_vars.is_empty()); + assert!(cwd.is_none()); } other => panic!("unexpected transport {other:?}"), } @@ -1932,6 +1957,8 @@ bearer_token = "secret" ("ZIG_VAR".to_string(), "3".to_string()), ("ALPHA_VAR".to_string(), "1".to_string()), ])), + env_vars: Vec::new(), + cwd: None, }, enabled: true, startup_timeout_sec: None, @@ -1958,7 +1985,13 @@ ZIG_VAR = "3" let loaded = load_global_mcp_servers(codex_home.path()).await?; let docs = loaded.get("docs").expect("docs entry"); match &docs.transport { - McpServerTransportConfig::Stdio { command, args, env } => { + McpServerTransportConfig::Stdio { + command, + args, + env, + env_vars, + cwd, + } => { assert_eq!(command, "docs-server"); assert_eq!(args, &vec!["--verbose".to_string()]); let env = env @@ -1966,6 +1999,91 @@ ZIG_VAR = "3" .expect("env should be preserved for stdio transport"); assert_eq!(env.get("ALPHA_VAR"), Some(&"1".to_string())); assert_eq!(env.get("ZIG_VAR"), Some(&"3".to_string())); + assert!(env_vars.is_empty()); + assert!(cwd.is_none()); + } + other => panic!("unexpected transport {other:?}"), + } + + Ok(()) + } + + #[tokio::test] + async fn write_global_mcp_servers_serializes_env_vars() -> anyhow::Result<()> { + let codex_home = TempDir::new()?; + + let servers = BTreeMap::from([( + "docs".to_string(), + McpServerConfig { + transport: McpServerTransportConfig::Stdio { + command: "docs-server".to_string(), + args: Vec::new(), + env: None, + env_vars: vec!["ALPHA".to_string(), "BETA".to_string()], + cwd: None, + }, + enabled: true, + startup_timeout_sec: None, + tool_timeout_sec: None, + }, + )]); + + write_global_mcp_servers(codex_home.path(), &servers)?; + + let config_path = codex_home.path().join(CONFIG_TOML_FILE); + let serialized = std::fs::read_to_string(&config_path)?; + assert!( + serialized.contains(r#"env_vars = ["ALPHA", "BETA"]"#), + "serialized config missing env_vars field:\n{serialized}" + ); + + let loaded = load_global_mcp_servers(codex_home.path()).await?; + let docs = loaded.get("docs").expect("docs entry"); + match &docs.transport { + McpServerTransportConfig::Stdio { env_vars, .. } => { + assert_eq!(env_vars, &vec!["ALPHA".to_string(), "BETA".to_string()]); + } + other => panic!("unexpected transport {other:?}"), + } + + Ok(()) + } + + #[tokio::test] + async fn write_global_mcp_servers_serializes_cwd() -> anyhow::Result<()> { + let codex_home = TempDir::new()?; + + let cwd_path = PathBuf::from("/tmp/codex-mcp"); + let servers = BTreeMap::from([( + "docs".to_string(), + McpServerConfig { + transport: McpServerTransportConfig::Stdio { + command: "docs-server".to_string(), + args: Vec::new(), + env: None, + env_vars: Vec::new(), + cwd: Some(cwd_path.clone()), + }, + enabled: true, + startup_timeout_sec: None, + tool_timeout_sec: None, + }, + )]); + + write_global_mcp_servers(codex_home.path(), &servers)?; + + let config_path = codex_home.path().join(CONFIG_TOML_FILE); + let serialized = std::fs::read_to_string(&config_path)?; + assert!( + serialized.contains(r#"cwd = "/tmp/codex-mcp""#), + "serialized config missing cwd field:\n{serialized}" + ); + + let loaded = load_global_mcp_servers(codex_home.path()).await?; + let docs = loaded.get("docs").expect("docs entry"); + match &docs.transport { + McpServerTransportConfig::Stdio { cwd, .. } => { + assert_eq!(cwd.as_deref(), Some(Path::new("/tmp/codex-mcp"))); } other => panic!("unexpected transport {other:?}"), } @@ -2205,6 +2323,8 @@ url = "https://example.com/mcp" command: "logs-server".to_string(), args: vec!["--follow".to_string()], env: None, + env_vars: Vec::new(), + cwd: None, }, enabled: true, startup_timeout_sec: None, @@ -2277,6 +2397,8 @@ url = "https://example.com/mcp" command: "docs-server".to_string(), args: Vec::new(), env: None, + env_vars: Vec::new(), + cwd: None, }, enabled: false, startup_timeout_sec: None, diff --git a/codex-rs/core/src/config_types.rs b/codex-rs/core/src/config_types.rs index 3a14d77a66..3da6108631 100644 --- a/codex-rs/core/src/config_types.rs +++ b/codex-rs/core/src/config_types.rs @@ -44,20 +44,26 @@ impl<'de> Deserialize<'de> for McpServerConfig { { #[derive(Deserialize)] struct RawMcpServerConfig { + // stdio command: Option, #[serde(default)] args: Option>, #[serde(default)] env: Option>, #[serde(default)] + env_vars: Option>, + #[serde(default)] + cwd: Option, http_headers: Option>, #[serde(default)] env_http_headers: Option>, + // streamable_http url: Option, bearer_token: Option, bearer_token_env_var: Option, + // shared #[serde(default)] startup_timeout_sec: Option, #[serde(default)] @@ -96,6 +102,8 @@ impl<'de> Deserialize<'de> for McpServerConfig { command: Some(command), args, env, + env_vars, + cwd, url, bearer_token_env_var, http_headers, @@ -114,6 +122,8 @@ impl<'de> Deserialize<'de> for McpServerConfig { command, args: args.unwrap_or_default(), env, + env_vars: env_vars.unwrap_or_default(), + cwd, } } RawMcpServerConfig { @@ -123,13 +133,20 @@ impl<'de> Deserialize<'de> for McpServerConfig { command, args, env, + env_vars, + cwd, http_headers, env_http_headers, - .. + startup_timeout_sec: _, + tool_timeout_sec: _, + startup_timeout_ms: _, + enabled: _, } => { throw_if_set("streamable_http", "command", command.as_ref())?; throw_if_set("streamable_http", "args", args.as_ref())?; throw_if_set("streamable_http", "env", env.as_ref())?; + throw_if_set("streamable_http", "env_vars", env_vars.as_ref())?; + throw_if_set("streamable_http", "cwd", cwd.as_ref())?; throw_if_set("streamable_http", "bearer_token", bearer_token.as_ref())?; McpServerTransportConfig::StreamableHttp { url, @@ -164,6 +181,10 @@ pub enum McpServerTransportConfig { args: Vec, #[serde(default, skip_serializing_if = "Option::is_none")] env: Option>, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + env_vars: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + cwd: Option, }, /// https://modelcontextprotocol.io/specification/2025-06-18/basic/transports#streamable-http StreamableHttp { @@ -500,7 +521,9 @@ mod tests { McpServerTransportConfig::Stdio { command: "echo".to_string(), args: vec![], - env: None + env: None, + env_vars: Vec::new(), + cwd: None, } ); assert!(cfg.enabled); @@ -521,7 +544,9 @@ mod tests { McpServerTransportConfig::Stdio { command: "echo".to_string(), args: vec!["hello".to_string(), "world".to_string()], - env: None + env: None, + env_vars: Vec::new(), + cwd: None, } ); assert!(cfg.enabled); @@ -543,12 +568,58 @@ mod tests { McpServerTransportConfig::Stdio { command: "echo".to_string(), args: vec!["hello".to_string(), "world".to_string()], - env: Some(HashMap::from([("FOO".to_string(), "BAR".to_string())])) + env: Some(HashMap::from([("FOO".to_string(), "BAR".to_string())])), + env_vars: Vec::new(), + cwd: None, } ); assert!(cfg.enabled); } + #[test] + fn deserialize_stdio_command_server_config_with_env_vars() { + let cfg: McpServerConfig = toml::from_str( + r#" + command = "echo" + env_vars = ["FOO", "BAR"] + "#, + ) + .expect("should deserialize command config with env_vars"); + + assert_eq!( + cfg.transport, + McpServerTransportConfig::Stdio { + command: "echo".to_string(), + args: vec![], + env: None, + env_vars: vec!["FOO".to_string(), "BAR".to_string()], + cwd: None, + } + ); + } + + #[test] + fn deserialize_stdio_command_server_config_with_cwd() { + let cfg: McpServerConfig = toml::from_str( + r#" + command = "echo" + cwd = "/tmp" + "#, + ) + .expect("should deserialize command config with cwd"); + + assert_eq!( + cfg.transport, + McpServerTransportConfig::Stdio { + command: "echo".to_string(), + args: vec![], + env: None, + env_vars: Vec::new(), + cwd: Some(PathBuf::from("/tmp")), + } + ); + } + #[test] fn deserialize_disabled_server_config() { let cfg: McpServerConfig = toml::from_str( diff --git a/codex-rs/core/src/mcp_connection_manager.rs b/codex-rs/core/src/mcp_connection_manager.rs index 8ddb0366fb..e7ff998340 100644 --- a/codex-rs/core/src/mcp_connection_manager.rs +++ b/codex-rs/core/src/mcp_connection_manager.rs @@ -10,6 +10,7 @@ use std::collections::HashMap; use std::collections::HashSet; use std::env; use std::ffi::OsString; +use std::path::PathBuf; use std::sync::Arc; use std::time::Duration; @@ -102,20 +103,25 @@ enum McpClientAdapter { } impl McpClientAdapter { + #[allow(clippy::too_many_arguments)] async fn new_stdio_client( use_rmcp_client: bool, program: OsString, args: Vec, env: Option>, + env_vars: Vec, + cwd: Option, params: mcp_types::InitializeRequestParams, startup_timeout: Duration, ) -> Result { if use_rmcp_client { - let client = Arc::new(RmcpClient::new_stdio_client(program, args, env).await?); + let client = + Arc::new(RmcpClient::new_stdio_client(program, args, env, &env_vars, cwd).await?); client.initialize(params, Some(startup_timeout)).await?; Ok(McpClientAdapter::Rmcp(client)) } else { - let client = Arc::new(McpClient::new_stdio_client(program, args, env).await?); + let client = + Arc::new(McpClient::new_stdio_client(program, args, env, &env_vars, cwd).await?); client.initialize(params, Some(startup_timeout)).await?; Ok(McpClientAdapter::Legacy(client)) } @@ -256,7 +262,13 @@ impl McpConnectionManager { }; let client = match transport { - McpServerTransportConfig::Stdio { command, args, env } => { + McpServerTransportConfig::Stdio { + command, + args, + env, + env_vars, + cwd, + } => { let command_os: OsString = command.into(); let args_os: Vec = args.into_iter().map(Into::into).collect(); McpClientAdapter::new_stdio_client( @@ -264,6 +276,8 @@ impl McpConnectionManager { command_os, args_os, env, + env_vars, + cwd, params, startup_timeout, ) diff --git a/codex-rs/core/tests/suite/rmcp_client.rs b/codex-rs/core/tests/suite/rmcp_client.rs index b2559cd2ee..1a2815b0ee 100644 --- a/codex-rs/core/tests/suite/rmcp_client.rs +++ b/codex-rs/core/tests/suite/rmcp_client.rs @@ -1,4 +1,5 @@ use std::collections::HashMap; +use std::ffi::OsStr; use std::ffi::OsString; use std::fs; use std::net::TcpListener; @@ -35,6 +36,7 @@ use tokio::time::sleep; use wiremock::matchers::any; #[tokio::test(flavor = "multi_thread", worker_threads = 1)] +#[serial(mcp_test_value)] async fn stdio_server_round_trip() -> anyhow::Result<()> { skip_if_no_network!(Ok(())); @@ -86,6 +88,8 @@ async fn stdio_server_round_trip() -> anyhow::Result<()> { "MCP_TEST_VALUE".to_string(), expected_env_value.to_string(), )])), + env_vars: Vec::new(), + cwd: None, }, enabled: true, startup_timeout_sec: Some(Duration::from_secs(10)), @@ -106,7 +110,143 @@ async fn stdio_server_round_trip() -> anyhow::Result<()> { final_output_json_schema: None, cwd: fixture.cwd.path().to_path_buf(), approval_policy: AskForApproval::Never, - sandbox_policy: SandboxPolicy::DangerFullAccess, + sandbox_policy: SandboxPolicy::ReadOnly, + model: session_model, + effort: None, + summary: ReasoningSummary::Auto, + }) + .await?; + + let begin_event = wait_for_event_with_timeout( + &fixture.codex, + |ev| matches!(ev, EventMsg::McpToolCallBegin(_)), + Duration::from_secs(10), + ) + .await; + + let EventMsg::McpToolCallBegin(begin) = begin_event else { + unreachable!("event guard guarantees McpToolCallBegin"); + }; + assert_eq!(begin.invocation.server, server_name); + assert_eq!(begin.invocation.tool, "echo"); + + let end_event = wait_for_event(&fixture.codex, |ev| { + matches!(ev, EventMsg::McpToolCallEnd(_)) + }) + .await; + let EventMsg::McpToolCallEnd(end) = end_event else { + unreachable!("event guard guarantees McpToolCallEnd"); + }; + + let result = end + .result + .as_ref() + .expect("rmcp echo tool should return success"); + assert_eq!(result.is_error, Some(false)); + assert!( + result.content.is_empty(), + "content should default to an empty array" + ); + + let structured = result + .structured_content + .as_ref() + .expect("structured content"); + let Value::Object(map) = structured else { + panic!("structured content should be an object: {structured:?}"); + }; + let echo_value = map + .get("echo") + .and_then(Value::as_str) + .expect("echo payload present"); + assert_eq!(echo_value, "ECHOING: ping"); + let env_value = map + .get("env") + .and_then(Value::as_str) + .expect("env snapshot inserted"); + assert_eq!(env_value, expected_env_value); + + wait_for_event(&fixture.codex, |ev| matches!(ev, EventMsg::TaskComplete(_))).await; + + server.verify().await; + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +#[serial(mcp_test_value)] +async fn stdio_server_propagates_whitelisted_env_vars() -> anyhow::Result<()> { + skip_if_no_network!(Ok(())); + + let server = responses::start_mock_server().await; + + let call_id = "call-1234"; + let server_name = "rmcp_whitelist"; + let tool_name = format!("{server_name}__echo"); + + mount_sse_once_match( + &server, + any(), + responses::sse(vec![ + responses::ev_response_created("resp-1"), + responses::ev_function_call(call_id, &tool_name, "{\"message\":\"ping\"}"), + responses::ev_completed("resp-1"), + ]), + ) + .await; + mount_sse_once_match( + &server, + any(), + responses::sse(vec![ + responses::ev_assistant_message("msg-1", "rmcp echo tool completed successfully."), + responses::ev_completed("resp-2"), + ]), + ) + .await; + + let expected_env_value = "propagated-env-from-whitelist"; + let _guard = EnvVarGuard::set("MCP_TEST_VALUE", OsStr::new(expected_env_value)); + let rmcp_test_server_bin = CargoBuild::new() + .package("codex-rmcp-client") + .bin("test_stdio_server") + .run()? + .path() + .to_string_lossy() + .into_owned(); + + let fixture = test_codex() + .with_config(move |config| { + config.features.enable(Feature::RmcpClient); + config.mcp_servers.insert( + server_name.to_string(), + McpServerConfig { + transport: McpServerTransportConfig::Stdio { + command: rmcp_test_server_bin, + args: Vec::new(), + env: None, + env_vars: vec!["MCP_TEST_VALUE".to_string()], + cwd: None, + }, + enabled: true, + startup_timeout_sec: Some(Duration::from_secs(10)), + tool_timeout_sec: None, + }, + ); + }) + .build(&server) + .await?; + let session_model = fixture.session_configured.model.clone(); + + fixture + .codex + .submit(Op::UserTurn { + items: vec![InputItem::Text { + text: "call the rmcp echo tool".into(), + }], + final_output_json_schema: None, + cwd: fixture.cwd.path().to_path_buf(), + approval_policy: AskForApproval::Never, + sandbox_policy: SandboxPolicy::ReadOnly, model: session_model, effort: None, summary: ReasoningSummary::Auto, @@ -257,7 +397,7 @@ async fn streamable_http_tool_call_round_trip() -> anyhow::Result<()> { final_output_json_schema: None, cwd: fixture.cwd.path().to_path_buf(), approval_policy: AskForApproval::Never, - sandbox_policy: SandboxPolicy::DangerFullAccess, + sandbox_policy: SandboxPolicy::ReadOnly, model: session_model, effort: None, summary: ReasoningSummary::Auto, @@ -440,7 +580,7 @@ async fn streamable_http_with_oauth_round_trip() -> anyhow::Result<()> { final_output_json_schema: None, cwd: fixture.cwd.path().to_path_buf(), approval_policy: AskForApproval::Never, - sandbox_policy: SandboxPolicy::DangerFullAccess, + sandbox_policy: SandboxPolicy::ReadOnly, model: session_model, effort: None, summary: ReasoningSummary::Auto, diff --git a/codex-rs/mcp-client/src/main.rs b/codex-rs/mcp-client/src/main.rs index f46058b99e..8e1f322dc7 100644 --- a/codex-rs/mcp-client/src/main.rs +++ b/codex-rs/mcp-client/src/main.rs @@ -49,7 +49,7 @@ async fn main() -> Result<()> { // Spawn the subprocess and connect the client. let program = args.remove(0); let env = None; - let client = McpClient::new_stdio_client(program, args, env) + let client = McpClient::new_stdio_client(program, args, env, &[], None) .await .with_context(|| format!("failed to spawn subprocess: {original_args:?}"))?; diff --git a/codex-rs/mcp-client/src/mcp_client.rs b/codex-rs/mcp-client/src/mcp_client.rs index 27f96494ee..3be93f35f6 100644 --- a/codex-rs/mcp-client/src/mcp_client.rs +++ b/codex-rs/mcp-client/src/mcp_client.rs @@ -13,6 +13,7 @@ use std::collections::HashMap; use std::ffi::OsString; +use std::path::PathBuf; use std::sync::Arc; use std::sync::atomic::AtomicI64; use std::sync::atomic::Ordering; @@ -86,19 +87,26 @@ impl McpClient { program: OsString, args: Vec, env: Option>, + env_vars: &[String], + cwd: Option, ) -> std::io::Result { - let mut child = Command::new(program) + let mut command = Command::new(program); + command .args(args) .env_clear() - .envs(create_env_for_mcp_server(env)) + .envs(create_env_for_mcp_server(env, env_vars)) .stdin(std::process::Stdio::piped()) .stdout(std::process::Stdio::piped()) .stderr(std::process::Stdio::null()) // As noted in the `kill_on_drop` documentation, the Tokio runtime makes // a "best effort" to reap-after-exit to avoid zombie processes, but it // is not a guarantee. - .kill_on_drop(true) - .spawn()?; + .kill_on_drop(true); + if let Some(cwd) = cwd { + command.current_dir(cwd); + } + + let mut child = command.spawn()?; let stdin = child .stdin @@ -447,12 +455,16 @@ const DEFAULT_ENV_VARS: &[&str] = &[ /// `config.toml`. fn create_env_for_mcp_server( extra_env: Option>, + env_vars: &[String], ) -> HashMap { DEFAULT_ENV_VARS .iter() - .filter_map(|var| match std::env::var(var) { - Ok(value) => Some((var.to_string(), value)), - Err(_) => None, + .copied() + .chain(env_vars.iter().map(String::as_str)) + .filter_map(|var| { + std::env::var(var) + .ok() + .map(|value| (var.to_string(), value)) }) .chain(extra_env.unwrap_or_default()) .collect::>() @@ -462,14 +474,36 @@ fn create_env_for_mcp_server( mod tests { use super::*; + fn set_env_var(key: &str, value: &str) { + unsafe { + std::env::set_var(key, value); + } + } + + fn remove_env_var(key: &str) { + unsafe { + std::env::remove_var(key); + } + } + #[test] fn test_create_env_for_mcp_server() { let env_var = "USER"; let env_var_existing_value = std::env::var(env_var).unwrap_or_default(); let env_var_new_value = format!("{env_var_existing_value}-extra"); let extra_env = HashMap::from([(env_var.to_owned(), env_var_new_value.clone())]); - let mcp_server_env = create_env_for_mcp_server(Some(extra_env)); + let mcp_server_env = create_env_for_mcp_server(Some(extra_env), &[]); assert!(mcp_server_env.contains_key("PATH")); assert_eq!(Some(&env_var_new_value), mcp_server_env.get(env_var)); } + + #[test] + fn test_create_env_for_mcp_server_includes_extra_whitelisted_vars() { + let custom_var = "CUSTOM_TEST_VAR"; + let value = "value".to_string(); + set_env_var(custom_var, &value); + let mcp_server_env = create_env_for_mcp_server(None, &[custom_var.to_string()]); + assert_eq!(Some(&value), mcp_server_env.get(custom_var)); + remove_env_var(custom_var); + } } diff --git a/codex-rs/rmcp-client/Cargo.toml b/codex-rs/rmcp-client/Cargo.toml index 99a609b339..0016f1142a 100644 --- a/codex-rs/rmcp-client/Cargo.toml +++ b/codex-rs/rmcp-client/Cargo.toml @@ -58,4 +58,5 @@ webbrowser = { workspace = true } [dev-dependencies] pretty_assertions = { workspace = true } +serial_test = { workspace = true } tempfile = { workspace = true } diff --git a/codex-rs/rmcp-client/src/rmcp_client.rs b/codex-rs/rmcp-client/src/rmcp_client.rs index 038f32ef9c..245b4eb355 100644 --- a/codex-rs/rmcp-client/src/rmcp_client.rs +++ b/codex-rs/rmcp-client/src/rmcp_client.rs @@ -1,6 +1,7 @@ use std::collections::HashMap; use std::ffi::OsString; use std::io; +use std::path::PathBuf; use std::process::Stdio; use std::sync::Arc; use std::time::Duration; @@ -79,6 +80,8 @@ impl RmcpClient { program: OsString, args: Vec, env: Option>, + env_vars: &[String], + cwd: Option, ) -> io::Result { let program_name = program.to_string_lossy().into_owned(); let mut command = Command::new(&program); @@ -87,8 +90,11 @@ impl RmcpClient { .stdin(Stdio::piped()) .stdout(Stdio::piped()) .env_clear() - .envs(create_env_for_mcp_server(env)) + .envs(create_env_for_mcp_server(env, env_vars)) .args(&args); + if let Some(cwd) = cwd { + command.current_dir(cwd); + } let (transport, stderr) = TokioChildProcess::builder(command) .stderr(Stdio::piped()) diff --git a/codex-rs/rmcp-client/src/utils.rs b/codex-rs/rmcp-client/src/utils.rs index cccb12c05a..17e050cb6f 100644 --- a/codex-rs/rmcp-client/src/utils.rs +++ b/codex-rs/rmcp-client/src/utils.rs @@ -74,9 +74,12 @@ where pub(crate) fn create_env_for_mcp_server( extra_env: Option>, + env_vars: &[String], ) -> HashMap { DEFAULT_ENV_VARS .iter() + .copied() + .chain(env_vars.iter().map(String::as_str)) .filter_map(|var| env::var(var).ok().map(|value| (var.to_string(), value))) .chain(extra_env.unwrap_or_default()) .collect() @@ -185,13 +188,59 @@ mod tests { use rmcp::model::CallToolResult as RmcpCallToolResult; use serde_json::json; + use serial_test::serial; + use std::ffi::OsString; + + struct EnvVarGuard { + key: String, + original: Option, + } + + impl EnvVarGuard { + fn set(key: &str, value: &str) -> Self { + let original = std::env::var_os(key); + unsafe { + std::env::set_var(key, value); + } + Self { + key: key.to_string(), + original, + } + } + } + + impl Drop for EnvVarGuard { + fn drop(&mut self) { + if let Some(value) = &self.original { + unsafe { + std::env::set_var(&self.key, value); + } + } else { + unsafe { + std::env::remove_var(&self.key); + } + } + } + } + #[tokio::test] async fn create_env_honors_overrides() { let value = "custom".to_string(); - let env = create_env_for_mcp_server(Some(HashMap::from([("TZ".into(), value.clone())]))); + let env = + create_env_for_mcp_server(Some(HashMap::from([("TZ".into(), value.clone())])), &[]); assert_eq!(env.get("TZ"), Some(&value)); } + #[test] + #[serial(extra_rmcp_env)] + fn create_env_includes_additional_whitelisted_variables() { + let custom_var = "EXTRA_RMCP_ENV"; + let value = "from-env"; + let _guard = EnvVarGuard::set(custom_var, value); + let env = create_env_for_mcp_server(None, &[custom_var.to_string()]); + assert_eq!(env.get(custom_var), Some(&value.to_string())); + } + #[test] fn convert_call_tool_result_defaults_missing_content() -> Result<()> { let structured_content = json!({ "key": "value" }); diff --git a/codex-rs/tui/src/history_cell.rs b/codex-rs/tui/src/history_cell.rs index 2dd2b57857..dae48d487c 100644 --- a/codex-rs/tui/src/history_cell.rs +++ b/codex-rs/tui/src/history_cell.rs @@ -20,6 +20,7 @@ use crate::wrapping::RtOptions; use crate::wrapping::word_wrap_line; use crate::wrapping::word_wrap_lines; use base64::Engine; +use codex_common::format_env_display::format_env_display; use codex_core::config::Config; use codex_core::config_types::McpServerTransportConfig; use codex_core::config_types::ReasoningSummaryFormat; @@ -1013,7 +1014,13 @@ pub(crate) fn new_mcp_tools_output( lines.push(vec![" • Auth: ".into(), status.to_string().into()].into()); match &cfg.transport { - McpServerTransportConfig::Stdio { command, args, env } => { + McpServerTransportConfig::Stdio { + command, + args, + env, + env_vars, + cwd, + } => { let args_suffix = if args.is_empty() { String::new() } else { @@ -1022,13 +1029,13 @@ pub(crate) fn new_mcp_tools_output( let cmd_display = format!("{command}{args_suffix}"); lines.push(vec![" • Command: ".into(), cmd_display.into()].into()); - if let Some(env) = env.as_ref() - && !env.is_empty() - { - let mut env_pairs: Vec = - env.iter().map(|(k, v)| format!("{k}={v}")).collect(); - env_pairs.sort(); - lines.push(vec![" • Env: ".into(), env_pairs.join(" ").into()].into()); + if let Some(cwd) = cwd.as_ref() { + lines.push(vec![" • Cwd: ".into(), cwd.display().to_string().into()].into()); + } + + let env_display = format_env_display(env.as_ref(), env_vars); + if env_display != "-" { + lines.push(vec![" • Env: ".into(), env_display.into()].into()); } } McpServerTransportConfig::StreamableHttp { @@ -1077,7 +1084,6 @@ pub(crate) fn new_mcp_tools_output( PlainHistoryCell { lines } } - pub(crate) fn new_info_event(message: String, hint: Option) -> PlainHistoryCell { let mut line = vec!["• ".dim(), message.into()]; if let Some(hint) = hint { diff --git a/docs/config.md b/docs/config.md index 8b5a45ab06..dafd9dbae3 100644 --- a/docs/config.md +++ b/docs/config.md @@ -359,6 +359,11 @@ env = { "API_KEY" = "value" } # or [mcp_servers.server_name.env] API_KEY = "value" +# Optional: Additional list of environment variables that will be whitelisted in the MCP server's environment. +env_vars = ["API_KEY2"] + +# Optional: cwd that the command will be run from +cwd = "/Users//code/my-server" ``` #### Streamable HTTP From 40fba1bb4c70a98138e54930e40d347055bc7b86 Mon Sep 17 00:00:00 2001 From: Gabriel Peal Date: Thu, 16 Oct 2025 22:05:15 -0700 Subject: [PATCH 19/35] [MCP] Add support for resources (#5239) This PR adds support for [MCP resources](https://modelcontextprotocol.io/specification/2025-06-18/server/resources) by adding three new tools for the model: 1. `list_resources` 2. `list_resource_templates` 3. `read_resource` These 3 tools correspond to the [three primary MCP resource protocol messages](https://modelcontextprotocol.io/specification/2025-06-18/server/resources#protocol-messages). Example of listing and reading a GitHub resource tempalte CleanShot 2025-10-15 at 17 31 10 `/mcp` with Figma configured CleanShot 2025-10-15 at 18 29 35 Fixes #4956 --- AGENTS.md | 1 + codex-rs/Cargo.lock | 1 + codex-rs/core/src/codex.rs | 56 +- codex-rs/core/src/mcp_connection_manager.rs | 240 +++++- .../core/src/tools/handlers/mcp_resource.rs | 773 ++++++++++++++++++ codex-rs/core/src/tools/handlers/mod.rs | 2 + codex-rs/core/src/tools/spec.rs | 166 +++- codex-rs/core/tests/suite/model_tools.rs | 22 +- codex-rs/core/tests/suite/prompt_caching.rs | 22 +- codex-rs/protocol/src/protocol.rs | 6 + codex-rs/rmcp-client/Cargo.toml | 1 + .../rmcp-client/src/bin/test_stdio_server.rs | 96 +++ .../src/bin/test_streamable_http_server.rs | 96 +++ codex-rs/rmcp-client/src/rmcp_client.rs | 57 ++ codex-rs/rmcp-client/tests/resources.rs | 124 +++ codex-rs/tui/src/chatwidget.rs | 2 + codex-rs/tui/src/history_cell.rs | 60 +- docs/config.md | 4 + 18 files changed, 1705 insertions(+), 24 deletions(-) create mode 100644 codex-rs/core/src/tools/handlers/mcp_resource.rs create mode 100644 codex-rs/rmcp-client/tests/resources.rs diff --git a/AGENTS.md b/AGENTS.md index 832f1d6543..81b8d10ac7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -12,6 +12,7 @@ In the codex-rs folder where the rust code lives: - Always inline format! args when possible per https://rust-lang.github.io/rust-clippy/master/index.html#uninlined_format_args - Use method references over closures when possible per https://rust-lang.github.io/rust-clippy/master/index.html#redundant_closure_for_method_calls - When writing tests, prefer comparing the equality of entire objects over fields one by one. +- When making a change that adds or changes an API, ensure that the documentation in the `docs/` folder is up to date if applicable. Run `just fmt` (in `codex-rs` directory) automatically after making Rust code changes; do not ask for approval to run it. Before finalizing a change to `codex-rs`, run `just fix -p ` (in `codex-rs` directory) to fix any linter issues in the code. Prefer scoping with `-p` to avoid slow workspace‑wide Clippy builds; only run `just fix` without `-p` if you changed shared crates. Additionally, run the tests: diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 6289625ae0..45a41b819a 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -1365,6 +1365,7 @@ dependencies = [ "axum", "codex-protocol", "dirs", + "escargot", "futures", "keyring", "mcp-types", diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index ce86b808c0..8acf8b20a8 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -28,6 +28,12 @@ use futures::future::BoxFuture; use futures::prelude::*; use futures::stream::FuturesOrdered; use mcp_types::CallToolResult; +use mcp_types::ListResourceTemplatesRequestParams; +use mcp_types::ListResourceTemplatesResult; +use mcp_types::ListResourcesRequestParams; +use mcp_types::ListResourcesResult; +use mcp_types::ReadResourceRequestParams; +use mcp_types::ReadResourceResult; use serde_json; use serde_json::Value; use tokio::sync::Mutex; @@ -1057,6 +1063,39 @@ impl Session { } } + pub async fn list_resources( + &self, + server: &str, + params: Option, + ) -> anyhow::Result { + self.services + .mcp_connection_manager + .list_resources(server, params) + .await + } + + pub async fn list_resource_templates( + &self, + server: &str, + params: Option, + ) -> anyhow::Result { + self.services + .mcp_connection_manager + .list_resource_templates(server, params) + .await + } + + pub async fn read_resource( + &self, + server: &str, + params: ReadResourceRequestParams, + ) -> anyhow::Result { + self.services + .mcp_connection_manager + .read_resource(server, params) + .await + } + pub async fn call_tool( &self, server: &str, @@ -1419,16 +1458,23 @@ async fn submission_loop( // This is a cheap lookup from the connection manager's cache. let tools = sess.services.mcp_connection_manager.list_all_tools(); - let auth_statuses = compute_auth_statuses( - config.mcp_servers.iter(), - config.mcp_oauth_credentials_store_mode, - ) - .await; + let (auth_statuses, resources, resource_templates) = tokio::join!( + compute_auth_statuses( + config.mcp_servers.iter(), + config.mcp_oauth_credentials_store_mode, + ), + sess.services.mcp_connection_manager.list_all_resources(), + sess.services + .mcp_connection_manager + .list_all_resource_templates() + ); let event = Event { id: sub_id, msg: EventMsg::McpListToolsResponse( crate::protocol::McpListToolsResponseEvent { tools, + resources, + resource_templates, auth_statuses, }, ), diff --git a/codex-rs/core/src/mcp_connection_manager.rs b/codex-rs/core/src/mcp_connection_manager.rs index e7ff998340..99c2e626e5 100644 --- a/codex-rs/core/src/mcp_connection_manager.rs +++ b/codex-rs/core/src/mcp_connection_manager.rs @@ -22,6 +22,14 @@ use codex_rmcp_client::OAuthCredentialsStoreMode; use codex_rmcp_client::RmcpClient; use mcp_types::ClientCapabilities; use mcp_types::Implementation; +use mcp_types::ListResourceTemplatesRequestParams; +use mcp_types::ListResourceTemplatesResult; +use mcp_types::ListResourcesRequestParams; +use mcp_types::ListResourcesResult; +use mcp_types::ReadResourceRequestParams; +use mcp_types::ReadResourceResult; +use mcp_types::Resource; +use mcp_types::ResourceTemplate; use mcp_types::Tool; use serde_json::json; @@ -164,6 +172,47 @@ impl McpClientAdapter { } } + async fn list_resources( + &self, + params: Option, + timeout: Option, + ) -> Result { + match self { + McpClientAdapter::Legacy(_) => Ok(ListResourcesResult { + next_cursor: None, + resources: Vec::new(), + }), + McpClientAdapter::Rmcp(client) => client.list_resources(params, timeout).await, + } + } + + async fn read_resource( + &self, + params: mcp_types::ReadResourceRequestParams, + timeout: Option, + ) -> Result { + match self { + McpClientAdapter::Legacy(_) => Err(anyhow!( + "resources/read is not supported by legacy MCP clients" + )), + McpClientAdapter::Rmcp(client) => client.read_resource(params, timeout).await, + } + } + + async fn list_resource_templates( + &self, + params: Option, + timeout: Option, + ) -> Result { + match self { + McpClientAdapter::Legacy(_) => Ok(ListResourceTemplatesResult { + next_cursor: None, + resource_templates: Vec::new(), + }), + McpClientAdapter::Rmcp(client) => client.list_resource_templates(params, timeout).await, + } + } + async fn call_tool( &self, name: String, @@ -349,7 +398,7 @@ impl McpConnectionManager { Ok((Self { clients, tools }, errors)) } - /// Returns a single map that contains **all** tools. Each key is the + /// Returns a single map that contains all tools. Each key is the /// fully-qualified name for the tool. pub fn list_all_tools(&self) -> HashMap { self.tools @@ -358,6 +407,133 @@ impl McpConnectionManager { .collect() } + /// Returns a single map that contains all resources. Each key is the + /// server name and the value is a vector of resources. + pub async fn list_all_resources(&self) -> HashMap> { + let mut join_set = JoinSet::new(); + + for (server_name, managed_client) in &self.clients { + let server_name_cloned = server_name.clone(); + let client_clone = managed_client.client.clone(); + let timeout = managed_client.tool_timeout; + + join_set.spawn(async move { + let mut collected: Vec = Vec::new(); + let mut cursor: Option = None; + + loop { + let params = cursor.as_ref().map(|next| ListResourcesRequestParams { + cursor: Some(next.clone()), + }); + let response = match client_clone.list_resources(params, timeout).await { + Ok(result) => result, + Err(err) => return (server_name_cloned, Err(err)), + }; + + collected.extend(response.resources); + + match response.next_cursor { + Some(next) => { + if cursor.as_ref() == Some(&next) { + return ( + server_name_cloned, + Err(anyhow!("resources/list returned duplicate cursor")), + ); + } + cursor = Some(next); + } + None => return (server_name_cloned, Ok(collected)), + } + } + }); + } + + let mut aggregated: HashMap> = HashMap::new(); + + while let Some(join_res) = join_set.join_next().await { + match join_res { + Ok((server_name, Ok(resources))) => { + aggregated.insert(server_name, resources); + } + Ok((server_name, Err(err))) => { + warn!("Failed to list resources for MCP server '{server_name}': {err:#}"); + } + Err(err) => { + warn!("Task panic when listing resources for MCP server: {err:#}"); + } + } + } + + aggregated + } + + /// Returns a single map that contains all resource templates. Each key is the + /// server name and the value is a vector of resource templates. + pub async fn list_all_resource_templates(&self) -> HashMap> { + let mut join_set = JoinSet::new(); + + for (server_name, managed_client) in &self.clients { + let server_name_cloned = server_name.clone(); + let client_clone = managed_client.client.clone(); + let timeout = managed_client.tool_timeout; + + join_set.spawn(async move { + let mut collected: Vec = Vec::new(); + let mut cursor: Option = None; + + loop { + let params = cursor + .as_ref() + .map(|next| ListResourceTemplatesRequestParams { + cursor: Some(next.clone()), + }); + let response = match client_clone.list_resource_templates(params, timeout).await + { + Ok(result) => result, + Err(err) => return (server_name_cloned, Err(err)), + }; + + collected.extend(response.resource_templates); + + match response.next_cursor { + Some(next) => { + if cursor.as_ref() == Some(&next) { + return ( + server_name_cloned, + Err(anyhow!( + "resources/templates/list returned duplicate cursor" + )), + ); + } + cursor = Some(next); + } + None => return (server_name_cloned, Ok(collected)), + } + } + }); + } + + let mut aggregated: HashMap> = HashMap::new(); + + while let Some(join_res) = join_set.join_next().await { + match join_res { + Ok((server_name, Ok(templates))) => { + aggregated.insert(server_name, templates); + } + Ok((server_name, Err(err))) => { + warn!( + "Failed to list resource templates for MCP server '{server_name}': {err:#}" + ); + } + Err(err) => { + warn!("Task panic when listing resource templates for MCP server: {err:#}"); + } + } + } + + aggregated + } + /// Invoke the tool indicated by the (server, tool) pair. pub async fn call_tool( &self, @@ -369,7 +545,7 @@ impl McpConnectionManager { .clients .get(server) .ok_or_else(|| anyhow!("unknown MCP server '{server}'"))?; - let client = managed.client.clone(); + let client = &managed.client; let timeout = managed.tool_timeout; client @@ -378,6 +554,64 @@ impl McpConnectionManager { .with_context(|| format!("tool call failed for `{server}/{tool}`")) } + /// List resources from the specified server. + pub async fn list_resources( + &self, + server: &str, + params: Option, + ) -> Result { + let managed = self + .clients + .get(server) + .ok_or_else(|| anyhow!("unknown MCP server '{server}'"))?; + let client = managed.client.clone(); + let timeout = managed.tool_timeout; + + client + .list_resources(params, timeout) + .await + .with_context(|| format!("resources/list failed for `{server}`")) + } + + /// List resource templates from the specified server. + pub async fn list_resource_templates( + &self, + server: &str, + params: Option, + ) -> Result { + let managed = self + .clients + .get(server) + .ok_or_else(|| anyhow!("unknown MCP server '{server}'"))?; + let client = managed.client.clone(); + let timeout = managed.tool_timeout; + + client + .list_resource_templates(params, timeout) + .await + .with_context(|| format!("resources/templates/list failed for `{server}`")) + } + + /// Read a resource from the specified server. + pub async fn read_resource( + &self, + server: &str, + params: ReadResourceRequestParams, + ) -> Result { + let managed = self + .clients + .get(server) + .ok_or_else(|| anyhow!("unknown MCP server '{server}'"))?; + let client = managed.client.clone(); + let timeout = managed.tool_timeout; + let uri = params.uri.clone(); + + client + .read_resource(params, timeout) + .await + .with_context(|| format!("resources/read failed for `{server}` ({uri})")) + } + pub fn parse_tool_name(&self, tool_name: &str) -> Option<(String, String)> { self.tools .get(tool_name) @@ -413,7 +647,7 @@ fn resolve_bearer_token( } /// Query every server for its available tools and return a single map that -/// contains **all** tools. Each key is the fully-qualified name for the tool. +/// contains all tools. Each key is the fully-qualified name for the tool. async fn list_all_tools(clients: &HashMap) -> Result> { let mut join_set = JoinSet::new(); diff --git a/codex-rs/core/src/tools/handlers/mcp_resource.rs b/codex-rs/core/src/tools/handlers/mcp_resource.rs new file mode 100644 index 0000000000..7e425aad9b --- /dev/null +++ b/codex-rs/core/src/tools/handlers/mcp_resource.rs @@ -0,0 +1,773 @@ +use std::collections::HashMap; +use std::sync::Arc; +use std::time::Duration; +use std::time::Instant; + +use async_trait::async_trait; +use mcp_types::CallToolResult; +use mcp_types::ContentBlock; +use mcp_types::ListResourceTemplatesRequestParams; +use mcp_types::ListResourceTemplatesResult; +use mcp_types::ListResourcesRequestParams; +use mcp_types::ListResourcesResult; +use mcp_types::ReadResourceRequestParams; +use mcp_types::ReadResourceResult; +use mcp_types::Resource; +use mcp_types::ResourceTemplate; +use mcp_types::TextContent; +use serde::Deserialize; +use serde::Serialize; +use serde::de::DeserializeOwned; +use serde_json::Value; + +use crate::codex::Session; +use crate::function_tool::FunctionCallError; +use crate::protocol::Event; +use crate::protocol::EventMsg; +use crate::protocol::McpInvocation; +use crate::protocol::McpToolCallBeginEvent; +use crate::protocol::McpToolCallEndEvent; +use crate::tools::context::ToolInvocation; +use crate::tools::context::ToolOutput; +use crate::tools::context::ToolPayload; +use crate::tools::registry::ToolHandler; +use crate::tools::registry::ToolKind; + +pub struct McpResourceHandler; + +#[derive(Debug, Deserialize, Default)] +struct ListResourcesArgs { + /// Lists all resources from all servers if not specified. + #[serde(default)] + server: Option, + #[serde(default)] + cursor: Option, +} + +#[derive(Debug, Deserialize, Default)] +struct ListResourceTemplatesArgs { + /// Lists all resource templates from all servers if not specified. + #[serde(default)] + server: Option, + #[serde(default)] + cursor: Option, +} + +#[derive(Debug, Deserialize)] +struct ReadResourceArgs { + server: String, + uri: String, +} + +#[derive(Debug, Serialize)] +struct ResourceWithServer { + server: String, + #[serde(flatten)] + resource: Resource, +} + +impl ResourceWithServer { + fn new(server: String, resource: Resource) -> Self { + Self { server, resource } + } +} + +#[derive(Debug, Serialize)] +struct ResourceTemplateWithServer { + server: String, + #[serde(flatten)] + template: ResourceTemplate, +} + +impl ResourceTemplateWithServer { + fn new(server: String, template: ResourceTemplate) -> Self { + Self { server, template } + } +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct ListResourcesPayload { + #[serde(skip_serializing_if = "Option::is_none")] + server: Option, + resources: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + next_cursor: Option, +} + +impl ListResourcesPayload { + fn from_single_server(server: String, result: ListResourcesResult) -> Self { + let resources = result + .resources + .into_iter() + .map(|resource| ResourceWithServer::new(server.clone(), resource)) + .collect(); + Self { + server: Some(server), + resources, + next_cursor: result.next_cursor, + } + } + + fn from_all_servers(resources_by_server: HashMap>) -> Self { + let mut entries: Vec<(String, Vec)> = resources_by_server.into_iter().collect(); + entries.sort_by(|a, b| a.0.cmp(&b.0)); + + let mut resources = Vec::new(); + for (server, server_resources) in entries { + for resource in server_resources { + resources.push(ResourceWithServer::new(server.clone(), resource)); + } + } + + Self { + server: None, + resources, + next_cursor: None, + } + } +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct ListResourceTemplatesPayload { + #[serde(skip_serializing_if = "Option::is_none")] + server: Option, + resource_templates: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + next_cursor: Option, +} + +impl ListResourceTemplatesPayload { + fn from_single_server(server: String, result: ListResourceTemplatesResult) -> Self { + let resource_templates = result + .resource_templates + .into_iter() + .map(|template| ResourceTemplateWithServer::new(server.clone(), template)) + .collect(); + Self { + server: Some(server), + resource_templates, + next_cursor: result.next_cursor, + } + } + + fn from_all_servers(templates_by_server: HashMap>) -> Self { + let mut entries: Vec<(String, Vec)> = + templates_by_server.into_iter().collect(); + entries.sort_by(|a, b| a.0.cmp(&b.0)); + + let mut resource_templates = Vec::new(); + for (server, server_templates) in entries { + for template in server_templates { + resource_templates.push(ResourceTemplateWithServer::new(server.clone(), template)); + } + } + + Self { + server: None, + resource_templates, + next_cursor: None, + } + } +} + +#[derive(Debug, Serialize)] +struct ReadResourcePayload { + server: String, + uri: String, + #[serde(flatten)] + result: ReadResourceResult, +} + +#[async_trait] +impl ToolHandler for McpResourceHandler { + fn kind(&self) -> ToolKind { + ToolKind::Function + } + + async fn handle(&self, invocation: ToolInvocation) -> Result { + let ToolInvocation { + session, + sub_id, + call_id, + tool_name, + payload, + .. + } = invocation; + + let arguments = match payload { + ToolPayload::Function { arguments } => arguments, + _ => { + return Err(FunctionCallError::RespondToModel( + "mcp_resource handler received unsupported payload".to_string(), + )); + } + }; + + let arguments_value = parse_arguments(arguments.as_str())?; + + match tool_name.as_str() { + "list_mcp_resources" => { + handle_list_resources( + Arc::clone(&session), + sub_id.clone(), + call_id.clone(), + arguments_value.clone(), + ) + .await + } + "list_mcp_resource_templates" => { + handle_list_resource_templates( + Arc::clone(&session), + sub_id.clone(), + call_id.clone(), + arguments_value.clone(), + ) + .await + } + "read_mcp_resource" => { + handle_read_resource(Arc::clone(&session), sub_id, call_id, arguments_value).await + } + other => Err(FunctionCallError::RespondToModel(format!( + "unsupported MCP resource tool: {other}" + ))), + } + } +} + +async fn handle_list_resources( + session: Arc, + sub_id: String, + call_id: String, + arguments: Option, +) -> Result { + let args: ListResourcesArgs = parse_args_with_default(arguments.clone())?; + let ListResourcesArgs { server, cursor } = args; + let server = normalize_optional_string(server); + let cursor = normalize_optional_string(cursor); + + let invocation = McpInvocation { + server: server.clone().unwrap_or_else(|| "codex".to_string()), + tool: "list_mcp_resources".to_string(), + arguments: arguments.clone(), + }; + + emit_tool_call_begin(&session, &sub_id, &call_id, invocation.clone()).await; + let start = Instant::now(); + + let payload_result: Result = async { + if let Some(server_name) = server.clone() { + let params = cursor.clone().map(|value| ListResourcesRequestParams { + cursor: Some(value), + }); + let result = session + .list_resources(&server_name, params) + .await + .map_err(|err| { + FunctionCallError::RespondToModel(format!("resources/list failed: {err:#}")) + })?; + Ok(ListResourcesPayload::from_single_server( + server_name, + result, + )) + } else { + if cursor.is_some() { + return Err(FunctionCallError::RespondToModel( + "cursor can only be used when a server is specified".to_string(), + )); + } + + let resources = session + .services + .mcp_connection_manager + .list_all_resources() + .await; + Ok(ListResourcesPayload::from_all_servers(resources)) + } + } + .await; + + match payload_result { + Ok(payload) => match serialize_function_output(payload) { + Ok(output) => { + let ToolOutput::Function { content, success } = &output else { + unreachable!("MCP resource handler should return function output"); + }; + let duration = start.elapsed(); + emit_tool_call_end( + &session, + &sub_id, + &call_id, + invocation, + duration, + Ok(call_tool_result_from_content(content, *success)), + ) + .await; + Ok(output) + } + Err(err) => { + let duration = start.elapsed(); + let message = err.to_string(); + emit_tool_call_end( + &session, + &sub_id, + &call_id, + invocation, + duration, + Err(message.clone()), + ) + .await; + Err(err) + } + }, + Err(err) => { + let duration = start.elapsed(); + let message = err.to_string(); + emit_tool_call_end( + &session, + &sub_id, + &call_id, + invocation, + duration, + Err(message.clone()), + ) + .await; + Err(err) + } + } +} + +async fn handle_list_resource_templates( + session: Arc, + sub_id: String, + call_id: String, + arguments: Option, +) -> Result { + let args: ListResourceTemplatesArgs = parse_args_with_default(arguments.clone())?; + let ListResourceTemplatesArgs { server, cursor } = args; + let server = normalize_optional_string(server); + let cursor = normalize_optional_string(cursor); + + let invocation = McpInvocation { + server: server.clone().unwrap_or_else(|| "codex".to_string()), + tool: "list_mcp_resource_templates".to_string(), + arguments: arguments.clone(), + }; + + emit_tool_call_begin(&session, &sub_id, &call_id, invocation.clone()).await; + let start = Instant::now(); + + let payload_result: Result = async { + if let Some(server_name) = server.clone() { + let params = cursor + .clone() + .map(|value| ListResourceTemplatesRequestParams { + cursor: Some(value), + }); + let result = session + .list_resource_templates(&server_name, params) + .await + .map_err(|err| { + FunctionCallError::RespondToModel(format!( + "resources/templates/list failed: {err:#}" + )) + })?; + Ok(ListResourceTemplatesPayload::from_single_server( + server_name, + result, + )) + } else { + if cursor.is_some() { + return Err(FunctionCallError::RespondToModel( + "cursor can only be used when a server is specified".to_string(), + )); + } + + let templates = session + .services + .mcp_connection_manager + .list_all_resource_templates() + .await; + Ok(ListResourceTemplatesPayload::from_all_servers(templates)) + } + } + .await; + + match payload_result { + Ok(payload) => match serialize_function_output(payload) { + Ok(output) => { + let ToolOutput::Function { content, success } = &output else { + unreachable!("MCP resource handler should return function output"); + }; + let duration = start.elapsed(); + emit_tool_call_end( + &session, + &sub_id, + &call_id, + invocation, + duration, + Ok(call_tool_result_from_content(content, *success)), + ) + .await; + Ok(output) + } + Err(err) => { + let duration = start.elapsed(); + let message = err.to_string(); + emit_tool_call_end( + &session, + &sub_id, + &call_id, + invocation, + duration, + Err(message.clone()), + ) + .await; + Err(err) + } + }, + Err(err) => { + let duration = start.elapsed(); + let message = err.to_string(); + emit_tool_call_end( + &session, + &sub_id, + &call_id, + invocation, + duration, + Err(message.clone()), + ) + .await; + Err(err) + } + } +} + +async fn handle_read_resource( + session: Arc, + sub_id: String, + call_id: String, + arguments: Option, +) -> Result { + let args: ReadResourceArgs = parse_args(arguments.clone())?; + let ReadResourceArgs { server, uri } = args; + let server = normalize_required_string("server", server)?; + let uri = normalize_required_string("uri", uri)?; + + let invocation = McpInvocation { + server: server.clone(), + tool: "read_mcp_resource".to_string(), + arguments: arguments.clone(), + }; + + emit_tool_call_begin(&session, &sub_id, &call_id, invocation.clone()).await; + let start = Instant::now(); + + let payload_result: Result = async { + let result = session + .read_resource(&server, ReadResourceRequestParams { uri: uri.clone() }) + .await + .map_err(|err| { + FunctionCallError::RespondToModel(format!("resources/read failed: {err:#}")) + })?; + + Ok(ReadResourcePayload { + server, + uri, + result, + }) + } + .await; + + match payload_result { + Ok(payload) => match serialize_function_output(payload) { + Ok(output) => { + let ToolOutput::Function { content, success } = &output else { + unreachable!("MCP resource handler should return function output"); + }; + let duration = start.elapsed(); + emit_tool_call_end( + &session, + &sub_id, + &call_id, + invocation, + duration, + Ok(call_tool_result_from_content(content, *success)), + ) + .await; + Ok(output) + } + Err(err) => { + let duration = start.elapsed(); + let message = err.to_string(); + emit_tool_call_end( + &session, + &sub_id, + &call_id, + invocation, + duration, + Err(message.clone()), + ) + .await; + Err(err) + } + }, + Err(err) => { + let duration = start.elapsed(); + let message = err.to_string(); + emit_tool_call_end( + &session, + &sub_id, + &call_id, + invocation, + duration, + Err(message.clone()), + ) + .await; + Err(err) + } + } +} + +fn call_tool_result_from_content(content: &str, success: Option) -> CallToolResult { + CallToolResult { + content: vec![ContentBlock::TextContent(TextContent { + annotations: None, + text: content.to_string(), + r#type: "text".to_string(), + })], + is_error: success.map(|value| !value), + structured_content: None, + } +} + +async fn emit_tool_call_begin( + session: &Arc, + sub_id: &str, + call_id: &str, + invocation: McpInvocation, +) { + session + .send_event(Event { + id: sub_id.to_string(), + msg: EventMsg::McpToolCallBegin(McpToolCallBeginEvent { + call_id: call_id.to_string(), + invocation, + }), + }) + .await; +} + +async fn emit_tool_call_end( + session: &Arc, + sub_id: &str, + call_id: &str, + invocation: McpInvocation, + duration: Duration, + result: Result, +) { + session + .send_event(Event { + id: sub_id.to_string(), + msg: EventMsg::McpToolCallEnd(McpToolCallEndEvent { + call_id: call_id.to_string(), + invocation, + duration, + result, + }), + }) + .await; +} + +fn normalize_optional_string(input: Option) -> Option { + input.and_then(|value| { + let trimmed = value.trim().to_string(); + if trimmed.is_empty() { + None + } else { + Some(trimmed) + } + }) +} + +fn normalize_required_string(field: &str, value: String) -> Result { + match normalize_optional_string(Some(value)) { + Some(normalized) => Ok(normalized), + None => Err(FunctionCallError::RespondToModel(format!( + "{field} must be provided" + ))), + } +} + +fn serialize_function_output(payload: T) -> Result +where + T: Serialize, +{ + let content = serde_json::to_string(&payload).map_err(|err| { + FunctionCallError::RespondToModel(format!( + "failed to serialize MCP resource response: {err}" + )) + })?; + + Ok(ToolOutput::Function { + content, + success: Some(true), + }) +} + +fn parse_arguments(raw_args: &str) -> Result, FunctionCallError> { + if raw_args.trim().is_empty() { + Ok(None) + } else { + serde_json::from_str(raw_args).map(Some).map_err(|err| { + FunctionCallError::RespondToModel(format!("failed to parse function arguments: {err}")) + }) + } +} + +fn parse_args(arguments: Option) -> Result +where + T: DeserializeOwned, +{ + match arguments { + Some(value) => serde_json::from_value(value).map_err(|err| { + FunctionCallError::RespondToModel(format!("failed to parse function arguments: {err}")) + }), + None => Err(FunctionCallError::RespondToModel( + "failed to parse function arguments: expected value".to_string(), + )), + } +} + +fn parse_args_with_default(arguments: Option) -> Result +where + T: DeserializeOwned + Default, +{ + match arguments { + Some(value) => parse_args(Some(value)), + None => Ok(T::default()), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use mcp_types::ListResourcesResult; + use mcp_types::ResourceTemplate; + use pretty_assertions::assert_eq; + use serde_json::json; + + fn resource(uri: &str, name: &str) -> Resource { + Resource { + annotations: None, + description: None, + mime_type: None, + name: name.to_string(), + size: None, + title: None, + uri: uri.to_string(), + } + } + + fn template(uri_template: &str, name: &str) -> ResourceTemplate { + ResourceTemplate { + annotations: None, + description: None, + mime_type: None, + name: name.to_string(), + title: None, + uri_template: uri_template.to_string(), + } + } + + #[test] + fn resource_with_server_serializes_server_field() { + let entry = ResourceWithServer::new("test".to_string(), resource("memo://id", "memo")); + let value = serde_json::to_value(&entry).expect("serialize resource"); + + assert_eq!(value["server"], json!("test")); + assert_eq!(value["uri"], json!("memo://id")); + assert_eq!(value["name"], json!("memo")); + } + + #[test] + fn list_resources_payload_from_single_server_copies_next_cursor() { + let result = ListResourcesResult { + next_cursor: Some("cursor-1".to_string()), + resources: vec![resource("memo://id", "memo")], + }; + let payload = ListResourcesPayload::from_single_server("srv".to_string(), result); + let value = serde_json::to_value(&payload).expect("serialize payload"); + + assert_eq!(value["server"], json!("srv")); + assert_eq!(value["nextCursor"], json!("cursor-1")); + let resources = value["resources"].as_array().expect("resources array"); + assert_eq!(resources.len(), 1); + assert_eq!(resources[0]["server"], json!("srv")); + } + + #[test] + fn list_resources_payload_from_all_servers_is_sorted() { + let mut map = HashMap::new(); + map.insert("beta".to_string(), vec![resource("memo://b-1", "b-1")]); + map.insert( + "alpha".to_string(), + vec![resource("memo://a-1", "a-1"), resource("memo://a-2", "a-2")], + ); + + let payload = ListResourcesPayload::from_all_servers(map); + let value = serde_json::to_value(&payload).expect("serialize payload"); + let uris: Vec = value["resources"] + .as_array() + .expect("resources array") + .iter() + .map(|entry| entry["uri"].as_str().unwrap().to_string()) + .collect(); + + assert_eq!( + uris, + vec![ + "memo://a-1".to_string(), + "memo://a-2".to_string(), + "memo://b-1".to_string() + ] + ); + } + + #[test] + fn call_tool_result_from_content_marks_success() { + let result = call_tool_result_from_content("{}", Some(true)); + assert_eq!(result.is_error, Some(false)); + assert_eq!(result.content.len(), 1); + } + + #[test] + fn parse_arguments_handles_empty_and_json() { + assert!( + parse_arguments(" \n\t").unwrap().is_none(), + "expected None for empty arguments" + ); + + let value = parse_arguments(r#"{"server":"figma"}"#) + .expect("parse json") + .expect("value present"); + assert_eq!(value["server"], json!("figma")); + } + + #[test] + fn template_with_server_serializes_server_field() { + let entry = + ResourceTemplateWithServer::new("srv".to_string(), template("memo://{id}", "memo")); + let value = serde_json::to_value(&entry).expect("serialize template"); + + assert_eq!( + value, + json!({ + "server": "srv", + "uriTemplate": "memo://{id}", + "name": "memo" + }) + ); + } +} diff --git a/codex-rs/core/src/tools/handlers/mod.rs b/codex-rs/core/src/tools/handlers/mod.rs index 9bff9fd5e7..355e5d607a 100644 --- a/codex-rs/core/src/tools/handlers/mod.rs +++ b/codex-rs/core/src/tools/handlers/mod.rs @@ -3,6 +3,7 @@ mod exec_stream; mod grep_files; mod list_dir; mod mcp; +mod mcp_resource; mod plan; mod read_file; mod shell; @@ -17,6 +18,7 @@ pub use exec_stream::ExecStreamHandler; pub use grep_files::GrepFilesHandler; pub use list_dir::ListDirHandler; pub use mcp::McpHandler; +pub use mcp_resource::McpResourceHandler; pub use plan::PlanHandler; pub use read_file::ReadFileHandler; pub use shell::ShellHandler; diff --git a/codex-rs/core/src/tools/spec.rs b/codex-rs/core/src/tools/spec.rs index bb1df187c2..5352051917 100644 --- a/codex-rs/core/src/tools/spec.rs +++ b/codex-rs/core/src/tools/spec.rs @@ -511,6 +511,107 @@ fn create_list_dir_tool() -> ToolSpec { }, }) } + +fn create_list_mcp_resources_tool() -> ToolSpec { + let mut properties = BTreeMap::new(); + properties.insert( + "server".to_string(), + JsonSchema::String { + description: Some( + "Optional MCP server name. When omitted, lists resources from every configured server." + .to_string(), + ), + }, + ); + properties.insert( + "cursor".to_string(), + JsonSchema::String { + description: Some( + "Opaque cursor returned by a previous list_mcp_resources call for the same server." + .to_string(), + ), + }, + ); + + ToolSpec::Function(ResponsesApiTool { + name: "list_mcp_resources".to_string(), + description: "Lists resources provided by MCP servers. Resources allow servers to share data that provides context to language models, such as files, database schemas, or application-specific information. Prefer resources over web search when possible.".to_string(), + strict: false, + parameters: JsonSchema::Object { + properties, + required: None, + additional_properties: Some(false.into()), + }, + }) +} + +fn create_list_mcp_resource_templates_tool() -> ToolSpec { + let mut properties = BTreeMap::new(); + properties.insert( + "server".to_string(), + JsonSchema::String { + description: Some( + "Optional MCP server name. When omitted, lists resource templates from all configured servers." + .to_string(), + ), + }, + ); + properties.insert( + "cursor".to_string(), + JsonSchema::String { + description: Some( + "Opaque cursor returned by a previous list_mcp_resource_templates call for the same server." + .to_string(), + ), + }, + ); + + ToolSpec::Function(ResponsesApiTool { + name: "list_mcp_resource_templates".to_string(), + description: "Lists resource templates provided by MCP servers. Parameterized resource templates allow servers to share data that takes parameters and provides context to language models, such as files, database schemas, or application-specific information. Prefer resource templates over web search when possible.".to_string(), + strict: false, + parameters: JsonSchema::Object { + properties, + required: None, + additional_properties: Some(false.into()), + }, + }) +} + +fn create_read_mcp_resource_tool() -> ToolSpec { + let mut properties = BTreeMap::new(); + properties.insert( + "server".to_string(), + JsonSchema::String { + description: Some( + "MCP server name exactly as configured. Must match the 'server' field returned by list_mcp_resources." + .to_string(), + ), + }, + ); + properties.insert( + "uri".to_string(), + JsonSchema::String { + description: Some( + "Resource URI to read. Must be one of the URIs returned by list_mcp_resources." + .to_string(), + ), + }, + ); + + ToolSpec::Function(ResponsesApiTool { + name: "read_mcp_resource".to_string(), + description: + "Read a specific resource from an MCP server given the server name and resource URI." + .to_string(), + strict: false, + parameters: JsonSchema::Object { + properties, + required: Some(vec!["server".to_string(), "uri".to_string()]), + additional_properties: Some(false.into()), + }, + }) +} /// TODO(dylan): deprecate once we get rid of json tool #[derive(Serialize, Deserialize)] pub(crate) struct ApplyPatchToolArgs { @@ -723,6 +824,7 @@ pub(crate) fn build_specs( use crate::tools::handlers::GrepFilesHandler; use crate::tools::handlers::ListDirHandler; use crate::tools::handlers::McpHandler; + use crate::tools::handlers::McpResourceHandler; use crate::tools::handlers::PlanHandler; use crate::tools::handlers::ReadFileHandler; use crate::tools::handlers::ShellHandler; @@ -740,6 +842,7 @@ pub(crate) fn build_specs( let apply_patch_handler = Arc::new(ApplyPatchHandler); let view_image_handler = Arc::new(ViewImageHandler); let mcp_handler = Arc::new(McpHandler); + let mcp_resource_handler = Arc::new(McpResourceHandler); if config.experimental_unified_exec_tool { builder.push_spec(create_unified_exec_tool()); @@ -770,6 +873,13 @@ pub(crate) fn build_specs( builder.register_handler("container.exec", shell_handler.clone()); builder.register_handler("local_shell", shell_handler); + builder.push_spec_with_parallel_support(create_list_mcp_resources_tool(), true); + builder.push_spec_with_parallel_support(create_list_mcp_resource_templates_tool(), true); + builder.push_spec_with_parallel_support(create_read_mcp_resource_tool(), true); + builder.register_handler("list_mcp_resources", mcp_resource_handler.clone()); + builder.register_handler("list_mcp_resource_templates", mcp_resource_handler.clone()); + builder.register_handler("read_mcp_resource", mcp_resource_handler); + if config.plan_tool { builder.push_spec(PLAN_TOOL.clone()); builder.register_handler("update_plan", plan_handler); @@ -917,7 +1027,15 @@ mod tests { assert_eq_tool_names( &tools, - &["unified_exec", "update_plan", "web_search", "view_image"], + &[ + "unified_exec", + "list_mcp_resources", + "list_mcp_resource_templates", + "read_mcp_resource", + "update_plan", + "web_search", + "view_image", + ], ); } @@ -936,7 +1054,15 @@ mod tests { assert_eq_tool_names( &tools, - &["unified_exec", "update_plan", "web_search", "view_image"], + &[ + "unified_exec", + "list_mcp_resources", + "list_mcp_resource_templates", + "read_mcp_resource", + "update_plan", + "web_search", + "view_image", + ], ); } @@ -1043,15 +1169,19 @@ mod tests { &tools, &[ "unified_exec", + "list_mcp_resources", + "list_mcp_resource_templates", + "read_mcp_resource", "web_search", "view_image", "test_server/do_something_cool", ], ); + let tool = find_tool(&tools, "test_server/do_something_cool"); assert_eq!( - tools[3].spec, - ToolSpec::Function(ResponsesApiTool { + &tool.spec, + &ToolSpec::Function(ResponsesApiTool { name: "test_server/do_something_cool".to_string(), parameters: JsonSchema::Object { properties: BTreeMap::from([ @@ -1158,6 +1288,9 @@ mod tests { &tools, &[ "unified_exec", + "list_mcp_resources", + "list_mcp_resource_templates", + "read_mcp_resource", "view_image", "test_server/cool", "test_server/do", @@ -1206,6 +1339,9 @@ mod tests { &tools, &[ "unified_exec", + "list_mcp_resources", + "list_mcp_resource_templates", + "read_mcp_resource", "apply_patch", "web_search", "view_image", @@ -1214,7 +1350,7 @@ mod tests { ); assert_eq!( - tools[4].spec, + tools[7].spec, ToolSpec::Function(ResponsesApiTool { name: "dash/search".to_string(), parameters: JsonSchema::Object { @@ -1271,6 +1407,9 @@ mod tests { &tools, &[ "unified_exec", + "list_mcp_resources", + "list_mcp_resource_templates", + "read_mcp_resource", "apply_patch", "web_search", "view_image", @@ -1278,7 +1417,7 @@ mod tests { ], ); assert_eq!( - tools[4].spec, + tools[7].spec, ToolSpec::Function(ResponsesApiTool { name: "dash/paginate".to_string(), parameters: JsonSchema::Object { @@ -1334,6 +1473,9 @@ mod tests { &tools, &[ "unified_exec", + "list_mcp_resources", + "list_mcp_resource_templates", + "read_mcp_resource", "apply_patch", "web_search", "view_image", @@ -1341,7 +1483,7 @@ mod tests { ], ); assert_eq!( - tools[4].spec, + tools[7].spec, ToolSpec::Function(ResponsesApiTool { name: "dash/tags".to_string(), parameters: JsonSchema::Object { @@ -1399,6 +1541,9 @@ mod tests { &tools, &[ "unified_exec", + "list_mcp_resources", + "list_mcp_resource_templates", + "read_mcp_resource", "apply_patch", "web_search", "view_image", @@ -1406,7 +1551,7 @@ mod tests { ], ); assert_eq!( - tools[4].spec, + tools[7].spec, ToolSpec::Function(ResponsesApiTool { name: "dash/value".to_string(), parameters: JsonSchema::Object { @@ -1501,6 +1646,9 @@ mod tests { &tools, &[ "unified_exec", + "list_mcp_resources", + "list_mcp_resource_templates", + "read_mcp_resource", "apply_patch", "web_search", "view_image", @@ -1509,7 +1657,7 @@ mod tests { ); assert_eq!( - tools[4].spec, + tools[7].spec, ToolSpec::Function(ResponsesApiTool { name: "test_server/do_something_cool".to_string(), parameters: JsonSchema::Object { diff --git a/codex-rs/core/tests/suite/model_tools.rs b/codex-rs/core/tests/suite/model_tools.rs index f26cfcf67c..71e64f9e3a 100644 --- a/codex-rs/core/tests/suite/model_tools.rs +++ b/codex-rs/core/tests/suite/model_tools.rs @@ -94,21 +94,37 @@ async fn model_selects_expected_tools() { let codex_tools = collect_tool_identifiers_for_model("codex-mini-latest").await; assert_eq!( codex_tools, - vec!["local_shell".to_string()], + vec![ + "local_shell".to_string(), + "list_mcp_resources".to_string(), + "list_mcp_resource_templates".to_string(), + "read_mcp_resource".to_string() + ], "codex-mini-latest should expose the local shell tool", ); let o3_tools = collect_tool_identifiers_for_model("o3").await; assert_eq!( o3_tools, - vec!["shell".to_string()], + vec![ + "shell".to_string(), + "list_mcp_resources".to_string(), + "list_mcp_resource_templates".to_string(), + "read_mcp_resource".to_string() + ], "o3 should expose the generic shell tool", ); let gpt5_codex_tools = collect_tool_identifiers_for_model("gpt-5-codex").await; assert_eq!( gpt5_codex_tools, - vec!["shell".to_string(), "apply_patch".to_string(),], + vec![ + "shell".to_string(), + "list_mcp_resources".to_string(), + "list_mcp_resource_templates".to_string(), + "read_mcp_resource".to_string(), + "apply_patch".to_string() + ], "gpt-5-codex should expose the apply_patch tool", ); } diff --git a/codex-rs/core/tests/suite/prompt_caching.rs b/codex-rs/core/tests/suite/prompt_caching.rs index dc000af449..fcfc960224 100644 --- a/codex-rs/core/tests/suite/prompt_caching.rs +++ b/codex-rs/core/tests/suite/prompt_caching.rs @@ -223,10 +223,28 @@ async fn prompt_tools_are_consistent_across_requests() { // our internal implementation is responsible for keeping tools in sync // with the OpenAI schema, so we just verify the tool presence here let tools_by_model: HashMap<&'static str, Vec<&'static str>> = HashMap::from([ - ("gpt-5", vec!["shell", "update_plan", "view_image"]), + ( + "gpt-5", + vec![ + "shell", + "list_mcp_resources", + "list_mcp_resource_templates", + "read_mcp_resource", + "update_plan", + "view_image", + ], + ), ( "gpt-5-codex", - vec!["shell", "update_plan", "apply_patch", "view_image"], + vec![ + "shell", + "list_mcp_resources", + "list_mcp_resource_templates", + "read_mcp_resource", + "update_plan", + "apply_patch", + "view_image", + ], ), ]); let expected_tools_names = tools_by_model diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index d18c5338c8..d888194776 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -21,6 +21,8 @@ use crate::num_format::format_with_separators; use crate::parse_command::ParsedCommand; use crate::plan_tool::UpdatePlanArgs; use mcp_types::CallToolResult; +use mcp_types::Resource as McpResource; +use mcp_types::ResourceTemplate as McpResourceTemplate; use mcp_types::Tool as McpTool; use serde::Deserialize; use serde::Serialize; @@ -1249,6 +1251,10 @@ pub struct GetHistoryEntryResponseEvent { pub struct McpListToolsResponseEvent { /// Fully qualified tool name -> tool definition. pub tools: std::collections::HashMap, + /// Known resources grouped by server name. + pub resources: std::collections::HashMap>, + /// Known resource templates grouped by server name. + pub resource_templates: std::collections::HashMap>, /// Authentication status for each configured MCP server. pub auth_statuses: std::collections::HashMap, } diff --git a/codex-rs/rmcp-client/Cargo.toml b/codex-rs/rmcp-client/Cargo.toml index 0016f1142a..c515cdbfa3 100644 --- a/codex-rs/rmcp-client/Cargo.toml +++ b/codex-rs/rmcp-client/Cargo.toml @@ -57,6 +57,7 @@ urlencoding = { workspace = true } webbrowser = { workspace = true } [dev-dependencies] +escargot = { workspace = true } pretty_assertions = { workspace = true } serial_test = { workspace = true } tempfile = { workspace = true } diff --git a/codex-rs/rmcp-client/src/bin/test_stdio_server.rs b/codex-rs/rmcp-client/src/bin/test_stdio_server.rs index 2d380fa54e..44ae50f02f 100644 --- a/codex-rs/rmcp-client/src/bin/test_stdio_server.rs +++ b/codex-rs/rmcp-client/src/bin/test_stdio_server.rs @@ -8,8 +8,17 @@ use rmcp::handler::server::ServerHandler; use rmcp::model::CallToolRequestParam; use rmcp::model::CallToolResult; use rmcp::model::JsonObject; +use rmcp::model::ListResourceTemplatesResult; +use rmcp::model::ListResourcesResult; use rmcp::model::ListToolsResult; use rmcp::model::PaginatedRequestParam; +use rmcp::model::RawResource; +use rmcp::model::RawResourceTemplate; +use rmcp::model::ReadResourceRequestParam; +use rmcp::model::ReadResourceResult; +use rmcp::model::Resource; +use rmcp::model::ResourceContents; +use rmcp::model::ResourceTemplate; use rmcp::model::ServerCapabilities; use rmcp::model::ServerInfo; use rmcp::model::Tool; @@ -20,15 +29,24 @@ use tokio::task; #[derive(Clone)] struct TestToolServer { tools: Arc>, + resources: Arc>, + resource_templates: Arc>, } + +const MEMO_URI: &str = "memo://codex/example-note"; +const MEMO_CONTENT: &str = "This is a sample MCP resource served by the rmcp test server."; pub fn stdio() -> (tokio::io::Stdin, tokio::io::Stdout) { (tokio::io::stdin(), tokio::io::stdout()) } impl TestToolServer { fn new() -> Self { let tools = vec![Self::echo_tool()]; + let resources = vec![Self::memo_resource()]; + let resource_templates = vec![Self::memo_template()]; Self { tools: Arc::new(tools), + resources: Arc::new(resources), + resource_templates: Arc::new(resource_templates), } } @@ -51,6 +69,36 @@ impl TestToolServer { Arc::new(schema), ) } + + fn memo_resource() -> Resource { + let raw = RawResource { + uri: MEMO_URI.to_string(), + name: "example-note".to_string(), + title: Some("Example Note".to_string()), + description: Some("A sample MCP resource exposed for integration tests.".to_string()), + mime_type: Some("text/plain".to_string()), + size: None, + icons: None, + }; + Resource::new(raw, None) + } + + fn memo_template() -> ResourceTemplate { + let raw = RawResourceTemplate { + uri_template: "memo://codex/{slug}".to_string(), + name: "codex-memo".to_string(), + title: Some("Codex Memo".to_string()), + description: Some( + "Template for memo://codex/{slug} resources used in tests.".to_string(), + ), + mime_type: Some("text/plain".to_string()), + }; + ResourceTemplate::new(raw, None) + } + + fn memo_text() -> &'static str { + MEMO_CONTENT + } } #[derive(Deserialize)] @@ -66,6 +114,7 @@ impl ServerHandler for TestToolServer { capabilities: ServerCapabilities::builder() .enable_tools() .enable_tool_list_changed() + .enable_resources() .build(), ..ServerInfo::default() } @@ -85,6 +134,53 @@ impl ServerHandler for TestToolServer { } } + fn list_resources( + &self, + _request: Option, + _context: rmcp::service::RequestContext, + ) -> impl std::future::Future> + Send + '_ { + let resources = self.resources.clone(); + async move { + Ok(ListResourcesResult { + resources: (*resources).clone(), + next_cursor: None, + }) + } + } + + async fn list_resource_templates( + &self, + _request: Option, + _context: rmcp::service::RequestContext, + ) -> Result { + Ok(ListResourceTemplatesResult { + resource_templates: (*self.resource_templates).clone(), + next_cursor: None, + }) + } + + async fn read_resource( + &self, + ReadResourceRequestParam { uri }: ReadResourceRequestParam, + _context: rmcp::service::RequestContext, + ) -> Result { + if uri == MEMO_URI { + Ok(ReadResourceResult { + contents: vec![ResourceContents::TextResourceContents { + uri, + mime_type: Some("text/plain".to_string()), + text: Self::memo_text().to_string(), + meta: None, + }], + }) + } else { + Err(McpError::resource_not_found( + "resource_not_found", + Some(json!({ "uri": uri })), + )) + } + } + async fn call_tool( &self, request: CallToolRequestParam, diff --git a/codex-rs/rmcp-client/src/bin/test_streamable_http_server.rs b/codex-rs/rmcp-client/src/bin/test_streamable_http_server.rs index 81a60404ba..30d8fd7fdf 100644 --- a/codex-rs/rmcp-client/src/bin/test_streamable_http_server.rs +++ b/codex-rs/rmcp-client/src/bin/test_streamable_http_server.rs @@ -18,8 +18,17 @@ use rmcp::handler::server::ServerHandler; use rmcp::model::CallToolRequestParam; use rmcp::model::CallToolResult; use rmcp::model::JsonObject; +use rmcp::model::ListResourceTemplatesResult; +use rmcp::model::ListResourcesResult; use rmcp::model::ListToolsResult; use rmcp::model::PaginatedRequestParam; +use rmcp::model::RawResource; +use rmcp::model::RawResourceTemplate; +use rmcp::model::ReadResourceRequestParam; +use rmcp::model::ReadResourceResult; +use rmcp::model::Resource; +use rmcp::model::ResourceContents; +use rmcp::model::ResourceTemplate; use rmcp::model::ServerCapabilities; use rmcp::model::ServerInfo; use rmcp::model::Tool; @@ -33,13 +42,22 @@ use tokio::task; #[derive(Clone)] struct TestToolServer { tools: Arc>, + resources: Arc>, + resource_templates: Arc>, } +const MEMO_URI: &str = "memo://codex/example-note"; +const MEMO_CONTENT: &str = "This is a sample MCP resource served by the rmcp test server."; + impl TestToolServer { fn new() -> Self { let tools = vec![Self::echo_tool()]; + let resources = vec![Self::memo_resource()]; + let resource_templates = vec![Self::memo_template()]; Self { tools: Arc::new(tools), + resources: Arc::new(resources), + resource_templates: Arc::new(resource_templates), } } @@ -62,6 +80,36 @@ impl TestToolServer { Arc::new(schema), ) } + + fn memo_resource() -> Resource { + let raw = RawResource { + uri: MEMO_URI.to_string(), + name: "example-note".to_string(), + title: Some("Example Note".to_string()), + description: Some("A sample MCP resource exposed for integration tests.".to_string()), + mime_type: Some("text/plain".to_string()), + size: None, + icons: None, + }; + Resource::new(raw, None) + } + + fn memo_template() -> ResourceTemplate { + let raw = RawResourceTemplate { + uri_template: "memo://codex/{slug}".to_string(), + name: "codex-memo".to_string(), + title: Some("Codex Memo".to_string()), + description: Some( + "Template for memo://codex/{slug} resources used in tests.".to_string(), + ), + mime_type: Some("text/plain".to_string()), + }; + ResourceTemplate::new(raw, None) + } + + fn memo_text() -> &'static str { + MEMO_CONTENT + } } #[derive(Deserialize)] @@ -77,6 +125,7 @@ impl ServerHandler for TestToolServer { capabilities: ServerCapabilities::builder() .enable_tools() .enable_tool_list_changed() + .enable_resources() .build(), ..ServerInfo::default() } @@ -96,6 +145,53 @@ impl ServerHandler for TestToolServer { } } + fn list_resources( + &self, + _request: Option, + _context: rmcp::service::RequestContext, + ) -> impl std::future::Future> + Send + '_ { + let resources = self.resources.clone(); + async move { + Ok(ListResourcesResult { + resources: (*resources).clone(), + next_cursor: None, + }) + } + } + + async fn list_resource_templates( + &self, + _request: Option, + _context: rmcp::service::RequestContext, + ) -> Result { + Ok(ListResourceTemplatesResult { + resource_templates: (*self.resource_templates).clone(), + next_cursor: None, + }) + } + + async fn read_resource( + &self, + ReadResourceRequestParam { uri }: ReadResourceRequestParam, + _context: rmcp::service::RequestContext, + ) -> Result { + if uri == MEMO_URI { + Ok(ReadResourceResult { + contents: vec![ResourceContents::TextResourceContents { + uri, + mime_type: Some("text/plain".to_string()), + text: Self::memo_text().to_string(), + meta: None, + }], + }) + } else { + Err(McpError::resource_not_found( + "resource_not_found", + Some(json!({ "uri": uri })), + )) + } + } + async fn call_tool( &self, request: CallToolRequestParam, diff --git a/codex-rs/rmcp-client/src/rmcp_client.rs b/codex-rs/rmcp-client/src/rmcp_client.rs index 245b4eb355..bc1980f1f5 100644 --- a/codex-rs/rmcp-client/src/rmcp_client.rs +++ b/codex-rs/rmcp-client/src/rmcp_client.rs @@ -13,12 +13,19 @@ use mcp_types::CallToolRequestParams; use mcp_types::CallToolResult; use mcp_types::InitializeRequestParams; use mcp_types::InitializeResult; +use mcp_types::ListResourceTemplatesRequestParams; +use mcp_types::ListResourceTemplatesResult; +use mcp_types::ListResourcesRequestParams; +use mcp_types::ListResourcesResult; use mcp_types::ListToolsRequestParams; use mcp_types::ListToolsResult; +use mcp_types::ReadResourceRequestParams; +use mcp_types::ReadResourceResult; use reqwest::header::HeaderMap; use rmcp::model::CallToolRequestParam; use rmcp::model::InitializeRequestParam; use rmcp::model::PaginatedRequestParam; +use rmcp::model::ReadResourceRequestParam; use rmcp::service::RoleClient; use rmcp::service::RunningService; use rmcp::service::{self}; @@ -264,6 +271,54 @@ impl RmcpClient { Ok(converted) } + pub async fn list_resources( + &self, + params: Option, + timeout: Option, + ) -> Result { + let service = self.service().await?; + let rmcp_params = params + .map(convert_to_rmcp::<_, PaginatedRequestParam>) + .transpose()?; + + let fut = service.list_resources(rmcp_params); + let result = run_with_timeout(fut, timeout, "resources/list").await?; + let converted = convert_to_mcp(result)?; + self.persist_oauth_tokens().await; + Ok(converted) + } + + pub async fn list_resource_templates( + &self, + params: Option, + timeout: Option, + ) -> Result { + let service = self.service().await?; + let rmcp_params = params + .map(convert_to_rmcp::<_, PaginatedRequestParam>) + .transpose()?; + + let fut = service.list_resource_templates(rmcp_params); + let result = run_with_timeout(fut, timeout, "resources/templates/list").await?; + let converted = convert_to_mcp(result)?; + self.persist_oauth_tokens().await; + Ok(converted) + } + + pub async fn read_resource( + &self, + params: ReadResourceRequestParams, + timeout: Option, + ) -> Result { + let service = self.service().await?; + let rmcp_params: ReadResourceRequestParam = convert_to_rmcp(params)?; + let fut = service.read_resource(rmcp_params); + let result = run_with_timeout(fut, timeout, "resources/read").await?; + let converted = convert_to_mcp(result)?; + self.persist_oauth_tokens().await; + Ok(converted) + } + pub async fn call_tool( &self, name: String, @@ -299,6 +354,8 @@ impl RmcpClient { } } + /// This should be called after every tool call so that if a given tool call triggered + /// a refresh of the OAuth tokens, they are persisted. async fn persist_oauth_tokens(&self) { if let Some(runtime) = self.oauth_persistor().await && let Err(error) = runtime.persist_if_needed().await diff --git a/codex-rs/rmcp-client/tests/resources.rs b/codex-rs/rmcp-client/tests/resources.rs new file mode 100644 index 0000000000..2117f9b14c --- /dev/null +++ b/codex-rs/rmcp-client/tests/resources.rs @@ -0,0 +1,124 @@ +use std::ffi::OsString; +use std::path::PathBuf; +use std::time::Duration; + +use codex_rmcp_client::RmcpClient; +use escargot::CargoBuild; +use mcp_types::ClientCapabilities; +use mcp_types::Implementation; +use mcp_types::InitializeRequestParams; +use mcp_types::ListResourceTemplatesResult; +use mcp_types::ReadResourceRequestParams; +use mcp_types::ReadResourceResultContents; +use mcp_types::Resource; +use mcp_types::ResourceTemplate; +use mcp_types::TextResourceContents; +use serde_json::json; + +const RESOURCE_URI: &str = "memo://codex/example-note"; + +fn stdio_server_bin() -> anyhow::Result { + let build = CargoBuild::new() + .package("codex-rmcp-client") + .bin("test_stdio_server") + .run()?; + Ok(build.path().to_path_buf()) +} + +fn init_params() -> InitializeRequestParams { + InitializeRequestParams { + capabilities: ClientCapabilities { + experimental: None, + roots: None, + sampling: None, + elicitation: Some(json!({})), + }, + client_info: Implementation { + name: "codex-test".into(), + version: "0.0.0-test".into(), + title: Some("Codex rmcp resource test".into()), + user_agent: None, + }, + protocol_version: mcp_types::MCP_SCHEMA_VERSION.to_string(), + } +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn rmcp_client_can_list_and_read_resources() -> anyhow::Result<()> { + let client = RmcpClient::new_stdio_client( + stdio_server_bin()?.into(), + Vec::::new(), + None, + &[], + None, + ) + .await?; + + client + .initialize(init_params(), Some(Duration::from_secs(5))) + .await?; + + let list = client + .list_resources(None, Some(Duration::from_secs(5))) + .await?; + let memo = list + .resources + .iter() + .find(|resource| resource.uri == RESOURCE_URI) + .expect("memo resource present"); + assert_eq!( + memo, + &Resource { + annotations: None, + description: Some("A sample MCP resource exposed for integration tests.".to_string()), + mime_type: Some("text/plain".to_string()), + name: "example-note".to_string(), + size: None, + title: Some("Example Note".to_string()), + uri: RESOURCE_URI.to_string(), + } + ); + let templates = client + .list_resource_templates(None, Some(Duration::from_secs(5))) + .await?; + assert_eq!( + templates, + ListResourceTemplatesResult { + next_cursor: None, + resource_templates: vec![ResourceTemplate { + annotations: None, + description: Some( + "Template for memo://codex/{slug} resources used in tests.".to_string() + ), + mime_type: Some("text/plain".to_string()), + name: "codex-memo".to_string(), + title: Some("Codex Memo".to_string()), + uri_template: "memo://codex/{slug}".to_string(), + }], + } + ); + + let read = client + .read_resource( + ReadResourceRequestParams { + uri: RESOURCE_URI.to_string(), + }, + Some(Duration::from_secs(5)), + ) + .await?; + let ReadResourceResultContents::TextResourceContents(text) = + read.contents.first().expect("resource contents present") + else { + panic!("expected text resource"); + }; + assert_eq!( + text, + &TextResourceContents { + text: "This is a sample MCP resource served by the rmcp test server.".to_string(), + uri: RESOURCE_URI.to_string(), + mime_type: Some("text/plain".to_string()), + } + ); + + Ok(()) +} diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index f592695346..9f308cd00f 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -2071,6 +2071,8 @@ impl ChatWidget { self.add_to_history(history_cell::new_mcp_tools_output( &self.config, ev.tools, + ev.resources, + ev.resource_templates, &ev.auth_statuses, )); } diff --git a/codex-rs/tui/src/history_cell.rs b/codex-rs/tui/src/history_cell.rs index dae48d487c..742c75e159 100644 --- a/codex-rs/tui/src/history_cell.rs +++ b/codex-rs/tui/src/history_cell.rs @@ -35,7 +35,9 @@ use codex_protocol::plan_tool::UpdatePlanArgs; use image::DynamicImage; use image::ImageReader; use mcp_types::EmbeddedResourceResource; +use mcp_types::Resource; use mcp_types::ResourceLink; +use mcp_types::ResourceTemplate; use ratatui::prelude::*; use ratatui::style::Modifier; use ratatui::style::Style; @@ -976,6 +978,8 @@ pub(crate) fn empty_mcp_output() -> PlainHistoryCell { pub(crate) fn new_mcp_tools_output( config: &Config, tools: HashMap, + resources: HashMap>, + resource_templates: HashMap>, auth_statuses: &HashMap, ) -> PlainHistoryCell { let mut lines: Vec> = vec![ @@ -1073,12 +1077,64 @@ pub(crate) fn new_mcp_tools_output( } if !cfg.enabled { - lines.push(vec![" • Tools: ".into(), "(disabled)".red()].into()); - } else if names.is_empty() { + let disabled = "(disabled)".red(); + lines.push(vec![" • Tools: ".into(), disabled.clone()].into()); + lines.push(vec![" • Resources: ".into(), disabled.clone()].into()); + lines.push(vec![" • Resource templates: ".into(), disabled].into()); + lines.push(Line::from("")); + continue; + } + + if names.is_empty() { lines.push(" • Tools: (none)".into()); } else { lines.push(vec![" • Tools: ".into(), names.join(", ").into()].into()); } + + let server_resources: Vec = + resources.get(server.as_str()).cloned().unwrap_or_default(); + if server_resources.is_empty() { + lines.push(" • Resources: (none)".into()); + } else { + let mut spans: Vec> = vec![" • Resources: ".into()]; + + for (idx, resource) in server_resources.iter().enumerate() { + if idx > 0 { + spans.push(", ".into()); + } + + let label = resource.title.as_ref().unwrap_or(&resource.name); + spans.push(label.clone().into()); + spans.push(" ".into()); + spans.push(format!("({})", resource.uri).dim()); + } + + lines.push(spans.into()); + } + + let server_templates: Vec = resource_templates + .get(server.as_str()) + .cloned() + .unwrap_or_default(); + if server_templates.is_empty() { + lines.push(" • Resource templates: (none)".into()); + } else { + let mut spans: Vec> = vec![" • Resource templates: ".into()]; + + for (idx, template) in server_templates.iter().enumerate() { + if idx > 0 { + spans.push(", ".into()); + } + + let label = template.title.as_ref().unwrap_or(&template.name); + spans.push(label.clone().into()); + spans.push(" ".into()); + spans.push(format!("({})", template.uri_template).dim()); + } + + lines.push(spans.into()); + } + lines.push(Line::from("")); } diff --git a/docs/config.md b/docs/config.md index dafd9dbae3..e460cb19bb 100644 --- a/docs/config.md +++ b/docs/config.md @@ -377,6 +377,10 @@ experimental_use_rmcp_client = true url = "https://mcp.linear.app/mcp" # Optional environment variable containing a bearer token to use for auth bearer_token_env_var = "" +# Optional map of headers with hard-coded values. +http_headers = { "HEADER_NAME" = "HEADER_VALUE" } +# Optional map of headers whose values will be replaced with the environment variable. +env_http_headers = { "HEADER_NAME" = "ENV_VAR" } ``` For oauth login, you must enable `experimental_use_rmcp_client = true` and then run `codex mcp login server_name` From 50f53e7071fa9073d80db560a24c95fa16f6a5c5 Mon Sep 17 00:00:00 2001 From: Michael Bolin Date: Thu, 16 Oct 2025 23:19:54 -0700 Subject: [PATCH 20/35] feat: add path field to ParsedCommand::Read variant (#5275) `ParsedCommand::Read` has a `name` field that attempts to identify the name of the file being read, but the file may not be in the `cwd` in which the command is invoked as demonstrated by this existing unit test: https://github.com/openai/codex/blob/0139f6780c850d87bb37bbb3a11e763d5dc3b50d/codex-rs/core/src/parse_command.rs#L250-L260 As you can see, `tui/Cargo.toml` is the relative path to the file being read. This PR introduces a new `path: PathBuf` field to `ParsedCommand::Read` that attempts to capture this information. When possible, this is an absolute path, though when relative, it should be resolved against the `cwd` that will be used to run the command to derive the absolute path. This should make it easier for clients to provide UI for a "read file" event that corresponds to the command execution. --- codex-rs/core/src/parse_command.rs | 146 ++++++++++++++++++++++--- codex-rs/protocol/src/parse_command.rs | 6 + codex-rs/tui/src/chatwidget/tests.rs | 3 +- codex-rs/tui/src/history_cell.rs | 7 ++ 4 files changed, 145 insertions(+), 17 deletions(-) diff --git a/codex-rs/core/src/parse_command.rs b/codex-rs/core/src/parse_command.rs index 224d013549..e7fa55687c 100644 --- a/codex-rs/core/src/parse_command.rs +++ b/codex-rs/core/src/parse_command.rs @@ -3,6 +3,7 @@ use crate::bash::try_parse_word_only_commands_sequence; use codex_protocol::parse_command::ParsedCommand; use shlex::split as shlex_split; use shlex::try_join as shlex_try_join; +use std::path::PathBuf; fn shlex_join(tokens: &[String]) -> String { shlex_try_join(tokens.iter().map(String::as_str)) @@ -37,6 +38,7 @@ pub fn parse_command(command: &[String]) -> Vec { /// Tests are at the top to encourage using TDD + Codex to fix the implementation. mod tests { use super::*; + use std::path::PathBuf; use std::string::ToString; fn shlex_split_safe(s: &str) -> Vec { @@ -186,6 +188,7 @@ mod tests { vec![ParsedCommand::Read { cmd: inner.to_string(), name: "README.md".to_string(), + path: PathBuf::from("webview/README.md"), }], ); } @@ -197,6 +200,7 @@ mod tests { vec![ParsedCommand::Read { cmd: "cat foo.txt".to_string(), name: "foo.txt".to_string(), + path: PathBuf::from("foo/foo.txt"), }], ); } @@ -219,6 +223,7 @@ mod tests { vec![ParsedCommand::Read { cmd: "cat foo.txt".to_string(), name: "foo.txt".to_string(), + path: PathBuf::from("foo/foo.txt"), }], ); } @@ -243,6 +248,7 @@ mod tests { vec![ParsedCommand::Read { cmd: inner.to_string(), name: "Cargo.toml".to_string(), + path: PathBuf::from("Cargo.toml"), }], ); } @@ -255,6 +261,7 @@ mod tests { vec![ParsedCommand::Read { cmd: inner.to_string(), name: "Cargo.toml".to_string(), + path: PathBuf::from("tui/Cargo.toml"), }], ); } @@ -267,6 +274,7 @@ mod tests { vec![ParsedCommand::Read { cmd: inner.to_string(), name: "README.md".to_string(), + path: PathBuf::from("README.md"), }], ); } @@ -280,6 +288,7 @@ mod tests { vec![ParsedCommand::Read { cmd: inner.to_string(), name: "README.md".to_string(), + path: PathBuf::from("README.md"), }] ); } @@ -449,6 +458,7 @@ mod tests { vec![ParsedCommand::Read { cmd: inner.to_string(), name: "parse_command.rs".to_string(), + path: PathBuf::from("core/src/parse_command.rs"), }], ); } @@ -461,6 +471,7 @@ mod tests { vec![ParsedCommand::Read { cmd: inner.to_string(), name: "history_cell.rs".to_string(), + path: PathBuf::from("tui/src/history_cell.rs"), }], ); } @@ -474,6 +485,7 @@ mod tests { vec![ParsedCommand::Read { cmd: "cat -- ansi-escape/Cargo.toml".to_string(), name: "Cargo.toml".to_string(), + path: PathBuf::from("ansi-escape/Cargo.toml"), }], ); } @@ -503,6 +515,7 @@ mod tests { vec![ParsedCommand::Read { cmd: "sed -n '260,640p' exec/src/event_processor_with_human_output.rs".to_string(), name: "event_processor_with_human_output.rs".to_string(), + path: PathBuf::from("exec/src/event_processor_with_human_output.rs"), }], ); } @@ -662,6 +675,7 @@ mod tests { vec![ParsedCommand::Read { cmd: r#"cat "pkg\\src\\main.rs""#.to_string(), name: "main.rs".to_string(), + path: PathBuf::from(r#"pkg\src\main.rs"#), }], ); } @@ -673,6 +687,7 @@ mod tests { vec![ParsedCommand::Read { cmd: "head -n50 Cargo.toml".to_string(), name: "Cargo.toml".to_string(), + path: PathBuf::from("Cargo.toml"), }], ); } @@ -703,6 +718,7 @@ mod tests { vec![ParsedCommand::Read { cmd: "tail -n+10 README.md".to_string(), name: "README.md".to_string(), + path: PathBuf::from("README.md"), }], ); } @@ -739,6 +755,7 @@ mod tests { vec![ParsedCommand::Read { cmd: "cat -- ./-strange-file-name".to_string(), name: "-strange-file-name".to_string(), + path: PathBuf::from("./-strange-file-name"), }], ); @@ -748,6 +765,7 @@ mod tests { vec![ParsedCommand::Read { cmd: "sed -n '12,20p' Cargo.toml".to_string(), name: "Cargo.toml".to_string(), + path: PathBuf::from("Cargo.toml"), }], ); } @@ -840,11 +858,39 @@ pub fn parse_command_impl(command: &[String]) -> Vec { // Preserve left-to-right execution order for all commands, including bash -c/-lc // so summaries reflect the order they will run. - // Map each pipeline segment to its parsed summary. - let mut commands: Vec = parts - .iter() - .map(|tokens| summarize_main_tokens(tokens)) - .collect(); + // Map each pipeline segment to its parsed summary, tracking `cd` to compute paths. + let mut commands: Vec = Vec::new(); + let mut cwd: Option = None; + for tokens in &parts { + if let Some((head, tail)) = tokens.split_first() + && head == "cd" + { + if let Some(dir) = tail.first() { + cwd = Some(match &cwd { + Some(base) => join_paths(base, dir), + None => dir.clone(), + }); + } + continue; + } + let parsed = summarize_main_tokens(tokens); + let parsed = match parsed { + ParsedCommand::Read { cmd, name, path } => { + if let Some(base) = &cwd { + let full = join_paths(base, &path.to_string_lossy()); + ParsedCommand::Read { + cmd, + name, + path: PathBuf::from(full), + } + } else { + ParsedCommand::Read { cmd, name, path } + } + } + other => other, + }; + commands.push(parsed); + } while let Some(next) = simplify_once(&commands) { commands = next; @@ -1129,10 +1175,39 @@ fn parse_bash_lc_commands(original: &[String]) -> Option> { cmd: script.clone(), }]); } - let mut commands: Vec = filtered_commands - .into_iter() - .map(|tokens| summarize_main_tokens(&tokens)) - .collect(); + // Build parsed commands, tracking `cd` segments to compute effective file paths. + let mut commands: Vec = Vec::new(); + let mut cwd: Option = None; + for tokens in filtered_commands.into_iter() { + if let Some((head, tail)) = tokens.split_first() + && head == "cd" + { + if let Some(dir) = tail.first() { + cwd = Some(match &cwd { + Some(base) => join_paths(base, dir), + None => dir.clone(), + }); + } + continue; + } + let parsed = summarize_main_tokens(&tokens); + let parsed = match parsed { + ParsedCommand::Read { cmd, name, path } => { + if let Some(base) = &cwd { + let full = join_paths(base, &path.to_string_lossy()); + ParsedCommand::Read { + cmd, + name, + path: PathBuf::from(full), + } + } else { + ParsedCommand::Read { cmd, name, path } + } + } + other => other, + }; + commands.push(parsed); + } if commands.len() > 1 { commands.retain(|pc| !matches!(pc, ParsedCommand::Unknown { cmd } if cmd == "true")); // Apply the same simplifications used for non-bash parsing, e.g., drop leading `cd`. @@ -1152,7 +1227,7 @@ fn parse_bash_lc_commands(original: &[String]) -> Option> { commands = commands .into_iter() .map(|pc| match pc { - ParsedCommand::Read { name, cmd, .. } => { + ParsedCommand::Read { name, cmd, path } => { if had_connectors { let has_pipe = script_tokens.iter().any(|t| t == "|"); let has_sed_n = script_tokens.windows(2).any(|w| { @@ -1163,14 +1238,16 @@ fn parse_bash_lc_commands(original: &[String]) -> Option> { ParsedCommand::Read { cmd: script.clone(), name, + path, } } else { - ParsedCommand::Read { cmd, name } + ParsedCommand::Read { cmd, name, path } } } else { ParsedCommand::Read { cmd: shlex_join(&script_tokens), name, + path, } } } @@ -1335,10 +1412,12 @@ fn summarize_main_tokens(main_cmd: &[String]) -> ParsedCommand { tail }; if effective_tail.len() == 1 { - let name = short_display_path(&effective_tail[0]); + let path = effective_tail[0].clone(); + let name = short_display_path(&path); ParsedCommand::Read { cmd: shlex_join(main_cmd), name, + path: PathBuf::from(path), } } else { ParsedCommand::Unknown { @@ -1373,10 +1452,12 @@ fn summarize_main_tokens(main_cmd: &[String]) -> ParsedCommand { i += 1; } if let Some(p) = candidates.into_iter().find(|p| !p.starts_with('-')) { - let name = short_display_path(p); + let path = p.clone(); + let name = short_display_path(&path); return ParsedCommand::Read { cmd: shlex_join(main_cmd), name, + path: PathBuf::from(path), }; } } @@ -1415,10 +1496,12 @@ fn summarize_main_tokens(main_cmd: &[String]) -> ParsedCommand { i += 1; } if let Some(p) = candidates.into_iter().find(|p| !p.starts_with('-')) { - let name = short_display_path(p); + let path = p.clone(); + let name = short_display_path(&path); return ParsedCommand::Read { cmd: shlex_join(main_cmd), name, + path: PathBuf::from(path), }; } } @@ -1430,10 +1513,12 @@ fn summarize_main_tokens(main_cmd: &[String]) -> ParsedCommand { // Avoid treating option values as paths (e.g., nl -s " "). let candidates = skip_flag_values(tail, &["-s", "-w", "-v", "-i", "-b"]); if let Some(p) = candidates.into_iter().find(|p| !p.starts_with('-')) { - let name = short_display_path(p); + let path = p.clone(); + let name = short_display_path(&path); ParsedCommand::Read { cmd: shlex_join(main_cmd), name, + path: PathBuf::from(path), } } else { ParsedCommand::Unknown { @@ -1448,10 +1533,12 @@ fn summarize_main_tokens(main_cmd: &[String]) -> ParsedCommand { && is_valid_sed_n_arg(tail.get(1).map(String::as_str)) => { if let Some(path) = tail.get(2) { - let name = short_display_path(path); + let path = path.clone(); + let name = short_display_path(&path); ParsedCommand::Read { cmd: shlex_join(main_cmd), name, + path: PathBuf::from(path), } } else { ParsedCommand::Unknown { @@ -1465,3 +1552,30 @@ fn summarize_main_tokens(main_cmd: &[String]) -> ParsedCommand { }, } } + +fn is_abs_like(path: &str) -> bool { + if std::path::Path::new(path).is_absolute() { + return true; + } + let mut chars = path.chars(); + match (chars.next(), chars.next(), chars.next()) { + // Windows drive path like C:\ + (Some(d), Some(':'), Some('\\')) if d.is_ascii_alphabetic() => return true, + // UNC path like \\server\share + (Some('\\'), Some('\\'), _) => return true, + _ => {} + } + false +} + +fn join_paths(base: &str, rel: &str) -> String { + if is_abs_like(rel) { + return rel.to_string(); + } + if base.is_empty() { + return rel.to_string(); + } + let mut buf = PathBuf::from(base); + buf.push(rel); + buf.to_string_lossy().to_string() +} diff --git a/codex-rs/protocol/src/parse_command.rs b/codex-rs/protocol/src/parse_command.rs index d1fbe83508..b06e290436 100644 --- a/codex-rs/protocol/src/parse_command.rs +++ b/codex-rs/protocol/src/parse_command.rs @@ -1,5 +1,6 @@ use serde::Deserialize; use serde::Serialize; +use std::path::PathBuf; use ts_rs::TS; #[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize, TS)] @@ -8,6 +9,11 @@ pub enum ParsedCommand { Read { cmd: String, name: String, + /// (Best effort) Path to the file being read by the command. When + /// possible, this is an absolute path, though when relative, it should + /// be resolved against the `cwd`` that will be used to run the command + /// to derive the absolute path. + path: PathBuf, }, ListFiles { cmd: String, diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index 2055e20856..dd6e59af42 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -1631,7 +1631,7 @@ fn status_widget_and_approval_modal_snapshot() { let ev = ExecApprovalRequestEvent { call_id: "call-approve-exec".into(), command: vec!["echo".into(), "hello world".into()], - cwd: std::path::PathBuf::from("/tmp"), + cwd: PathBuf::from("/tmp"), reason: Some( "this is a test reason such as one that would be produced by the model".into(), ), @@ -2310,6 +2310,7 @@ fn chatwidget_exec_and_status_layout_vt100_snapshot() { ParsedCommand::Read { name: "diff_render.rs".into(), cmd: "cat diff_render.rs".into(), + path: "diff_render.rs".into(), }, ], }), diff --git a/codex-rs/tui/src/history_cell.rs b/codex-rs/tui/src/history_cell.rs index 742c75e159..85acaf9b6a 100644 --- a/codex-rs/tui/src/history_cell.rs +++ b/codex-rs/tui/src/history_cell.rs @@ -1682,10 +1682,12 @@ mod tests { ParsedCommand::Read { name: "shimmer.rs".into(), cmd: "cat shimmer.rs".into(), + path: "shimmer.rs".into(), }, ParsedCommand::Read { name: "status_indicator_widget.rs".into(), cmd: "cat status_indicator_widget.rs".into(), + path: "status_indicator_widget.rs".into(), }, ], output: None, @@ -1742,6 +1744,7 @@ mod tests { vec![ParsedCommand::Read { name: "shimmer.rs".into(), cmd: "cat shimmer.rs".into(), + path: "shimmer.rs".into(), }], ) .unwrap(); @@ -1763,6 +1766,7 @@ mod tests { vec![ParsedCommand::Read { name: "status_indicator_widget.rs".into(), cmd: "cat status_indicator_widget.rs".into(), + path: "status_indicator_widget.rs".into(), }], ) .unwrap(); @@ -1791,14 +1795,17 @@ mod tests { ParsedCommand::Read { name: "auth.rs".into(), cmd: "cat auth.rs".into(), + path: "auth.rs".into(), }, ParsedCommand::Read { name: "auth.rs".into(), cmd: "cat auth.rs".into(), + path: "auth.rs".into(), }, ParsedCommand::Read { name: "shimmer.rs".into(), cmd: "cat shimmer.rs".into(), + path: "shimmer.rs".into(), }, ], output: None, From 6915ba2100108db4d7e10aa67cce32609cf56be9 Mon Sep 17 00:00:00 2001 From: jif-oai Date: Fri, 17 Oct 2025 10:06:55 +0100 Subject: [PATCH 21/35] feat: better UX during refusal (#5260) Screenshot 2025-10-16 at 18 28 05 --- codex-rs/core/src/codex.rs | 25 ++++++++++++++++++++----- codex-rs/core/src/executor/mod.rs | 4 ++++ codex-rs/core/src/executor/runner.rs | 17 ++++++++++++----- codex-rs/core/src/executor/sandbox.rs | 2 +- codex-rs/core/src/function_tool.rs | 2 ++ codex-rs/core/src/tools/mod.rs | 1 + 6 files changed, 40 insertions(+), 11 deletions(-) diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 8acf8b20a8..93ba73ebca 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -916,6 +916,7 @@ impl Session { duration, exit_code, timed_out: _, + .. } = output; // Send full stdout/stderr to clients; do not truncate. let stdout = stdout.text.clone(); @@ -980,15 +981,28 @@ impl Session { let sub_id = context.sub_id.clone(); let call_id = context.call_id.clone(); - self.on_exec_command_begin(turn_diff_tracker.clone(), context.clone()) - .await; - + let begin_turn_diff = turn_diff_tracker.clone(); + let begin_context = context.clone(); + let session = self; let result = self .services .executor - .run(request, self, approval_policy, &context) + .run(request, self, approval_policy, &context, move || { + let turn_diff = begin_turn_diff.clone(); + let ctx = begin_context.clone(); + async move { + session.on_exec_command_begin(turn_diff, ctx).await; + } + }) .await; + if matches!( + &result, + Err(ExecError::Function(FunctionCallError::Denied(_))) + ) { + return result; + } + let normalized = normalize_exec_result(&result); let borrowed = normalized.event_output(); @@ -2262,7 +2276,8 @@ async fn try_run_turn( response: Some(response), }); } - Err(FunctionCallError::RespondToModel(message)) => { + Err(FunctionCallError::RespondToModel(message)) + | Err(FunctionCallError::Denied(message)) => { let response = ResponseInputItem::FunctionCallOutput { call_id: String::new(), output: FunctionCallOutputPayload { diff --git a/codex-rs/core/src/executor/mod.rs b/codex-rs/core/src/executor/mod.rs index 97d7b29294..3f1e02dff2 100644 --- a/codex-rs/core/src/executor/mod.rs +++ b/codex-rs/core/src/executor/mod.rs @@ -60,5 +60,9 @@ pub mod errors { pub(crate) fn rejection(msg: impl Into) -> Self { FunctionCallError::RespondToModel(msg.into()).into() } + + pub(crate) fn denied(msg: impl Into) -> Self { + FunctionCallError::Denied(msg.into()).into() + } } } diff --git a/codex-rs/core/src/executor/runner.rs b/codex-rs/core/src/executor/runner.rs index e13016e37f..072debb5d3 100644 --- a/codex-rs/core/src/executor/runner.rs +++ b/codex-rs/core/src/executor/runner.rs @@ -1,3 +1,4 @@ +use std::future::Future; use std::path::PathBuf; use std::sync::Arc; use std::sync::RwLock; @@ -74,13 +75,18 @@ impl Executor { /// Runs a prepared execution request end-to-end: prepares parameters, decides on /// sandbox placement (prompting the user when necessary), launches the command, /// and lets the backend post-process the final output. - pub(crate) async fn run( + pub(crate) async fn run( &self, mut request: ExecutionRequest, session: &Session, approval_policy: AskForApproval, context: &ExecCommandContext, - ) -> Result { + on_exec_begin: F, + ) -> Result + where + F: FnOnce() -> Fut, + Fut: Future, + { if matches!(request.mode, ExecutionMode::Shell) { request.params = maybe_translate_shell_command(request.params, session, request.use_shell_profile); @@ -119,7 +125,7 @@ impl Executor { if sandbox_decision.record_session_approval { self.approval_cache.insert(request.approval_command.clone()); } - + on_exec_begin().await; // Step 4: Launch the command within the chosen sandbox. let first_attempt = self .spawn( @@ -210,7 +216,7 @@ impl Executor { Ok(retry_output) } ReviewDecision::Denied | ReviewDecision::Abort => { - Err(ExecError::rejection("exec command rejected by user")) + Err(ExecError::denied("exec command rejected by user")) } } } @@ -301,7 +307,8 @@ pub(crate) fn normalize_exec_result( } Err(err) => { let message = match err { - ExecError::Function(FunctionCallError::RespondToModel(msg)) => msg.clone(), + ExecError::Function(FunctionCallError::RespondToModel(msg)) + | ExecError::Function(FunctionCallError::Denied(msg)) => msg.clone(), ExecError::Codex(e) => get_error_message_ui(e), err => err.to_string(), }; diff --git a/codex-rs/core/src/executor/sandbox.rs b/codex-rs/core/src/executor/sandbox.rs index 5c01ff69b4..f0a421a29a 100644 --- a/codex-rs/core/src/executor/sandbox.rs +++ b/codex-rs/core/src/executor/sandbox.rs @@ -149,7 +149,7 @@ async fn select_shell_sandbox( ReviewDecision::Approved => Ok(SandboxDecision::user_override(false)), ReviewDecision::ApprovedForSession => Ok(SandboxDecision::user_override(true)), ReviewDecision::Denied | ReviewDecision::Abort => { - Err(ExecError::rejection("exec command rejected by user")) + Err(ExecError::denied("exec command rejected by user")) } } } diff --git a/codex-rs/core/src/function_tool.rs b/codex-rs/core/src/function_tool.rs index 240e04361c..a25fff6113 100644 --- a/codex-rs/core/src/function_tool.rs +++ b/codex-rs/core/src/function_tool.rs @@ -4,6 +4,8 @@ use thiserror::Error; pub enum FunctionCallError { #[error("{0}")] RespondToModel(String), + #[error("{0}")] + Denied(String), #[error("LocalShellCall without call_id or id")] MissingLocalShellCallId, #[error("Fatal error: {0}")] diff --git a/codex-rs/core/src/tools/mod.rs b/codex-rs/core/src/tools/mod.rs index 691c6dc0c2..607697e083 100644 --- a/codex-rs/core/src/tools/mod.rs +++ b/codex-rs/core/src/tools/mod.rs @@ -238,6 +238,7 @@ fn truncate_function_error(err: FunctionCallError) -> FunctionCallError { FunctionCallError::RespondToModel(msg) => { FunctionCallError::RespondToModel(format_exec_output(&msg)) } + FunctionCallError::Denied(msg) => FunctionCallError::Denied(format_exec_output(&msg)), FunctionCallError::Fatal(msg) => FunctionCallError::Fatal(format_exec_output(&msg)), other => other, } From c03e31ecf5d4d9fed16ea163b3bcce83d030f74d Mon Sep 17 00:00:00 2001 From: pakrym-oai Date: Fri, 17 Oct 2025 11:52:57 -0700 Subject: [PATCH 22/35] Support graceful agent interruption (#5287) --- codex-rs/Cargo.lock | 34 +++++++++ codex-rs/Cargo.toml | 3 + codex-rs/async-utils/Cargo.toml | 15 ++++ codex-rs/async-utils/src/lib.rs | 86 +++++++++++++++++++++ codex-rs/core/Cargo.toml | 2 + codex-rs/core/src/codex.rs | 117 +++++++++++++++++++++-------- codex-rs/core/src/error.rs | 10 +++ codex-rs/core/src/lib.rs | 3 +- codex-rs/core/src/state/turn.rs | 15 ++-- codex-rs/core/src/tasks/compact.rs | 2 + codex-rs/core/src/tasks/mod.rs | 53 ++++++++++--- codex-rs/core/src/tasks/regular.rs | 12 ++- codex-rs/core/src/tasks/review.rs | 12 ++- 13 files changed, 309 insertions(+), 55 deletions(-) create mode 100644 codex-rs/async-utils/Cargo.toml create mode 100644 codex-rs/async-utils/src/lib.rs diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 45a41b819a..9d18361cf8 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -899,6 +899,16 @@ dependencies = [ "tokio", ] +[[package]] +name = "codex-async-utils" +version = "0.0.0" +dependencies = [ + "async-trait", + "pretty_assertions", + "tokio", + "tokio-util", +] + [[package]] name = "codex-backend-client" version = "0.0.0" @@ -1037,6 +1047,7 @@ dependencies = [ "chrono", "codex-app-server-protocol", "codex-apply-patch", + "codex-async-utils", "codex-file-search", "codex-mcp-client", "codex-otel", @@ -1073,6 +1084,7 @@ dependencies = [ "similar", "strum_macros 0.27.2", "tempfile", + "test-log", "thiserror 2.0.16", "time", "tokio", @@ -6022,6 +6034,28 @@ version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f50febec83f5ee1df3015341d8bd429f2d1cc62bcba7ea2076759d315084683" +[[package]] +name = "test-log" +version = "0.2.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e33b98a582ea0be1168eba097538ee8dd4bbe0f2b01b22ac92ea30054e5be7b" +dependencies = [ + "env_logger", + "test-log-macros", + "tracing-subscriber", +] + +[[package]] +name = "test-log-macros" +version = "0.2.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "451b374529930d7601b1eef8d32bc79ae870b6079b069401709c2a8bf9e75f36" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.104", +] + [[package]] name = "textwrap" version = "0.11.0" diff --git a/codex-rs/Cargo.toml b/codex-rs/Cargo.toml index 4ca4b7e6e1..64eebb62a1 100644 --- a/codex-rs/Cargo.toml +++ b/codex-rs/Cargo.toml @@ -2,6 +2,7 @@ members = [ "backend-client", "ansi-escape", + "async-utils", "app-server", "app-server-protocol", "apply-patch", @@ -56,6 +57,7 @@ codex-arg0 = { path = "arg0" } codex-chatgpt = { path = "chatgpt" } codex-common = { path = "common" } codex-core = { path = "core" } +codex-async-utils = { path = "async-utils" } codex-exec = { path = "exec" } codex-feedback = { path = "feedback" } codex-file-search = { path = "file-search" } @@ -164,6 +166,7 @@ strum_macros = "0.27.2" supports-color = "3.0.2" sys-locale = "0.3.2" tempfile = "3.23.0" +test-log = "0.2.18" textwrap = "0.16.2" thiserror = "2.0.16" time = "0.3" diff --git a/codex-rs/async-utils/Cargo.toml b/codex-rs/async-utils/Cargo.toml new file mode 100644 index 0000000000..5203db0f5d --- /dev/null +++ b/codex-rs/async-utils/Cargo.toml @@ -0,0 +1,15 @@ +[package] +edition.workspace = true +name = "codex-async-utils" +version.workspace = true + +[lints] +workspace = true + +[dependencies] +async-trait.workspace = true +tokio = { workspace = true, features = ["macros", "rt", "rt-multi-thread", "time"] } +tokio-util.workspace = true + +[dev-dependencies] +pretty_assertions.workspace = true diff --git a/codex-rs/async-utils/src/lib.rs b/codex-rs/async-utils/src/lib.rs new file mode 100644 index 0000000000..bd880ae1fb --- /dev/null +++ b/codex-rs/async-utils/src/lib.rs @@ -0,0 +1,86 @@ +use async_trait::async_trait; +use std::future::Future; +use tokio_util::sync::CancellationToken; + +#[derive(Debug, PartialEq, Eq)] +pub enum CancelErr { + Cancelled, +} + +#[async_trait] +pub trait OrCancelExt: Sized { + type Output; + + async fn or_cancel(self, token: &CancellationToken) -> Result; +} + +#[async_trait] +impl OrCancelExt for F +where + F: Future + Send, + F::Output: Send, +{ + type Output = F::Output; + + async fn or_cancel(self, token: &CancellationToken) -> Result { + tokio::select! { + _ = token.cancelled() => Err(CancelErr::Cancelled), + res = self => Ok(res), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use pretty_assertions::assert_eq; + use std::time::Duration; + use tokio::task; + use tokio::time::sleep; + + #[tokio::test] + async fn returns_ok_when_future_completes_first() { + let token = CancellationToken::new(); + let value = async { 42 }; + + let result = value.or_cancel(&token).await; + + assert_eq!(Ok(42), result); + } + + #[tokio::test] + async fn returns_err_when_token_cancelled_first() { + let token = CancellationToken::new(); + let token_clone = token.clone(); + + let cancel_handle = task::spawn(async move { + sleep(Duration::from_millis(10)).await; + token_clone.cancel(); + }); + + let result = async { + sleep(Duration::from_millis(100)).await; + 7 + } + .or_cancel(&token) + .await; + + cancel_handle.await.expect("cancel task panicked"); + assert_eq!(Err(CancelErr::Cancelled), result); + } + + #[tokio::test] + async fn returns_err_when_token_already_cancelled() { + let token = CancellationToken::new(); + token.cancel(); + + let result = async { + sleep(Duration::from_millis(50)).await; + 5 + } + .or_cancel(&token) + .await; + + assert_eq!(Err(CancelErr::Cancelled), result); + } +} diff --git a/codex-rs/core/Cargo.toml b/codex-rs/core/Cargo.toml index 4259e64fc4..d13205699e 100644 --- a/codex-rs/core/Cargo.toml +++ b/codex-rs/core/Cargo.toml @@ -26,6 +26,7 @@ codex-mcp-client = { workspace = true } codex-otel = { workspace = true, features = ["otel"] } codex-protocol = { workspace = true } codex-rmcp-client = { workspace = true } +codex-async-utils = { workspace = true } codex-utils-string = { workspace = true } dirs = { workspace = true } dunce = { workspace = true } @@ -47,6 +48,7 @@ shlex = { workspace = true } similar = { workspace = true } strum_macros = { workspace = true } tempfile = { workspace = true } +test-log = { workspace = true } thiserror = { workspace = true } time = { workspace = true, features = [ "formatting", diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 93ba73ebca..23751d1b6d 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -38,6 +38,7 @@ use serde_json; use serde_json::Value; use tokio::sync::Mutex; use tokio::sync::oneshot; +use tokio_util::sync::CancellationToken; use tracing::debug; use tracing::error; use tracing::info; @@ -119,6 +120,7 @@ use crate::unified_exec::UnifiedExecSessionManager; use crate::user_instructions::UserInstructions; use crate::user_notification::UserNotification; use crate::util::backoff; +use codex_async_utils::OrCancelExt; use codex_otel::otel_event_manager::OtelEventManager; use codex_protocol::config_types::ReasoningEffort as ReasoningEffortConfig; use codex_protocol::config_types::ReasoningSummary as ReasoningSummaryConfig; @@ -1170,19 +1172,6 @@ impl Session { self.abort_all_tasks(TurnAbortReason::Interrupted).await; } - fn interrupt_task_sync(&self) { - if let Ok(mut active) = self.active_turn.try_lock() - && let Some(at) = active.as_mut() - { - at.try_clear_pending_sync(); - let tasks = at.drain_tasks(); - *active = None; - for (_sub_id, task) in tasks { - task.handle.abort(); - } - } - } - pub(crate) fn notifier(&self) -> &UserNotifier { &self.services.notifier } @@ -1196,12 +1185,6 @@ impl Session { } } -impl Drop for Session { - fn drop(&mut self) { - self.interrupt_task_sync(); - } -} - async fn submission_loop( sess: Arc, turn_context: TurnContext, @@ -1711,6 +1694,7 @@ pub(crate) async fn run_task( sub_id: String, input: Vec, task_kind: TaskKind, + cancellation_token: CancellationToken, ) -> Option { if input.is_empty() { return None; @@ -1795,6 +1779,7 @@ pub(crate) async fn run_task( sub_id.clone(), turn_input, task_kind, + cancellation_token.child_token(), ) .await { @@ -1956,6 +1941,10 @@ pub(crate) async fn run_task( } continue; } + Err(CodexErr::TurnAborted) => { + // Aborted turn is reported via a different event. + break; + } Err(e) => { info!("Turn error: {e:#}"); let event = Event { @@ -2022,6 +2011,7 @@ async fn run_turn( sub_id: String, input: Vec, task_kind: TaskKind, + cancellation_token: CancellationToken, ) -> CodexResult { let mcp_tools = sess.services.mcp_connection_manager.list_all_tools(); let router = Arc::new(ToolRouter::from_config( @@ -2052,10 +2042,12 @@ async fn run_turn( &sub_id, &prompt, task_kind, + cancellation_token.child_token(), ) .await { Ok(output) => return Ok(output), + Err(CodexErr::TurnAborted) => return Err(CodexErr::TurnAborted), Err(CodexErr::Interrupted) => return Err(CodexErr::Interrupted), Err(CodexErr::EnvVar(var)) => return Err(CodexErr::EnvVar(var)), Err(e @ CodexErr::Fatal(_)) => return Err(e), @@ -2118,6 +2110,7 @@ struct TurnRunResult { total_token_usage: Option, } +#[allow(clippy::too_many_arguments)] async fn try_run_turn( router: Arc, sess: Arc, @@ -2126,6 +2119,7 @@ async fn try_run_turn( sub_id: &str, prompt: &Prompt, task_kind: TaskKind, + cancellation_token: CancellationToken, ) -> CodexResult { // call_ids that are part of this response. let completed_call_ids = prompt @@ -2195,7 +2189,8 @@ async fn try_run_turn( .client .clone() .stream_with_task_kind(prompt.as_ref(), task_kind) - .await?; + .or_cancel(&cancellation_token) + .await??; let tool_runtime = ToolCallRuntime::new( Arc::clone(&router), @@ -2211,7 +2206,8 @@ async fn try_run_turn( // Poll the next item from the model stream. We must inspect *both* Ok and Err // cases so that transient stream failures (e.g., dropped SSE connection before // `response.completed`) bubble up and trigger the caller's retry logic. - let event = stream.next().await; + let event = stream.next().or_cancel(&cancellation_token).await?; + let event = match event { Some(res) => res?, None => { @@ -2316,7 +2312,10 @@ async fn try_run_turn( sess.update_token_usage_info(sub_id, turn_context.as_ref(), token_usage.as_ref()) .await; - let processed_items: Vec = output.try_collect().await?; + let processed_items = output + .try_collect() + .or_cancel(&cancellation_token) + .await??; let unified_diff = { let mut tracker = turn_diff_tracker.lock().await; @@ -2554,6 +2553,8 @@ mod tests { use codex_app_server_protocol::AuthMode; use codex_protocol::models::ContentItem; use codex_protocol::models::ResponseItem; + use std::time::Duration; + use tokio::time::sleep; use mcp_types::ContentBlock; use mcp_types::TextContent; @@ -2563,8 +2564,6 @@ mod tests { use std::path::PathBuf; use std::sync::Arc; use std::time::Duration as StdDuration; - use tokio::time::Duration; - use tokio::time::sleep; #[test] fn reconstruct_history_matches_live_compactions() { @@ -2944,12 +2943,15 @@ mod tests { } #[derive(Clone, Copy)] - struct NeverEndingTask(TaskKind); + struct NeverEndingTask { + kind: TaskKind, + listen_to_cancellation_token: bool, + } #[async_trait::async_trait] impl SessionTask for NeverEndingTask { fn kind(&self) -> TaskKind { - self.0 + self.kind } async fn run( @@ -2958,20 +2960,26 @@ mod tests { _ctx: Arc, _sub_id: String, _input: Vec, + cancellation_token: CancellationToken, ) -> Option { + if self.listen_to_cancellation_token { + cancellation_token.cancelled().await; + return None; + } loop { sleep(Duration::from_secs(60)).await; } } async fn abort(&self, session: Arc, sub_id: &str) { - if let TaskKind::Review = self.0 { + if let TaskKind::Review = self.kind { exit_review_mode(session.clone_session(), sub_id.to_string(), None).await; } } } - #[tokio::test] + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + #[test_log::test] async fn abort_regular_task_emits_turn_aborted_only() { let (sess, tc, rx) = make_session_and_context_with_rx(); let sub_id = "sub-regular".to_string(); @@ -2982,7 +2990,41 @@ mod tests { Arc::clone(&tc), sub_id.clone(), input, - NeverEndingTask(TaskKind::Regular), + NeverEndingTask { + kind: TaskKind::Regular, + listen_to_cancellation_token: false, + }, + ) + .await; + + sess.abort_all_tasks(TurnAbortReason::Interrupted).await; + + let evt = tokio::time::timeout(std::time::Duration::from_secs(2), rx.recv()) + .await + .expect("timeout waiting for event") + .expect("event"); + match evt.msg { + EventMsg::TurnAborted(e) => assert_eq!(TurnAbortReason::Interrupted, e.reason), + other => panic!("unexpected event: {other:?}"), + } + assert!(rx.try_recv().is_err()); + } + + #[tokio::test] + async fn abort_gracefuly_emits_turn_aborted_only() { + let (sess, tc, rx) = make_session_and_context_with_rx(); + let sub_id = "sub-regular".to_string(); + let input = vec![InputItem::Text { + text: "hello".to_string(), + }]; + sess.spawn_task( + Arc::clone(&tc), + sub_id.clone(), + input, + NeverEndingTask { + kind: TaskKind::Regular, + listen_to_cancellation_token: true, + }, ) .await; @@ -2996,7 +3038,7 @@ mod tests { assert!(rx.try_recv().is_err()); } - #[tokio::test] + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn abort_review_task_emits_exited_then_aborted_and_records_history() { let (sess, tc, rx) = make_session_and_context_with_rx(); let sub_id = "sub-review".to_string(); @@ -3007,18 +3049,27 @@ mod tests { Arc::clone(&tc), sub_id.clone(), input, - NeverEndingTask(TaskKind::Review), + NeverEndingTask { + kind: TaskKind::Review, + listen_to_cancellation_token: false, + }, ) .await; sess.abort_all_tasks(TurnAbortReason::Interrupted).await; - let first = rx.recv().await.expect("first event"); + let first = tokio::time::timeout(std::time::Duration::from_secs(2), rx.recv()) + .await + .expect("timeout waiting for first event") + .expect("first event"); match first.msg { EventMsg::ExitedReviewMode(ev) => assert!(ev.review_output.is_none()), other => panic!("unexpected first event: {other:?}"), } - let second = rx.recv().await.expect("second event"); + let second = tokio::time::timeout(std::time::Duration::from_secs(2), rx.recv()) + .await + .expect("timeout waiting for second event") + .expect("second event"); match second.msg { EventMsg::TurnAborted(e) => assert_eq!(TurnAbortReason::Interrupted, e.reason), other => panic!("unexpected second event: {other:?}"), diff --git a/codex-rs/core/src/error.rs b/codex-rs/core/src/error.rs index da2a868d70..951ba3954f 100644 --- a/codex-rs/core/src/error.rs +++ b/codex-rs/core/src/error.rs @@ -2,6 +2,7 @@ use crate::exec::ExecToolCallOutput; use crate::token_data::KnownPlan; use crate::token_data::PlanType; use crate::truncate::truncate_middle; +use codex_async_utils::CancelErr; use codex_protocol::ConversationId; use codex_protocol::protocol::RateLimitSnapshot; use reqwest::StatusCode; @@ -50,6 +51,9 @@ pub enum SandboxErr { #[derive(Error, Debug)] pub enum CodexErr { + #[error("turn aborted")] + TurnAborted, + /// Returned by ResponsesClient when the SSE stream disconnects or errors out **after** the HTTP /// handshake has succeeded but **before** it finished emitting `response.completed`. /// @@ -150,6 +154,12 @@ pub enum CodexErr { EnvVar(EnvVarError), } +impl From for CodexErr { + fn from(_: CancelErr) -> Self { + CodexErr::TurnAborted + } +} + #[derive(Debug)] pub struct ConnectionFailedError { pub source: reqwest::Error, diff --git a/codex-rs/core/src/lib.rs b/codex-rs/core/src/lib.rs index 22e1d4cdf0..8ddf8bff9c 100644 --- a/codex-rs/core/src/lib.rs +++ b/codex-rs/core/src/lib.rs @@ -13,7 +13,6 @@ mod client; mod client_common; pub mod codex; mod codex_conversation; -pub mod token_data; pub use codex_conversation::CodexConversation; mod command_safety; pub mod config; @@ -39,6 +38,7 @@ mod mcp_tool_call; mod message_history; mod model_provider_info; pub mod parse_command; +pub mod token_data; mod truncate; mod unified_exec; mod user_instructions; @@ -107,5 +107,4 @@ pub use codex_protocol::models::LocalShellExecAction; pub use codex_protocol::models::LocalShellStatus; pub use codex_protocol::models::ReasoningItemContent; pub use codex_protocol::models::ResponseItem; - pub mod otel_init; diff --git a/codex-rs/core/src/state/turn.rs b/codex-rs/core/src/state/turn.rs index 89af13a1a5..3ed63c5b8f 100644 --- a/codex-rs/core/src/state/turn.rs +++ b/codex-rs/core/src/state/turn.rs @@ -4,7 +4,9 @@ use indexmap::IndexMap; use std::collections::HashMap; use std::sync::Arc; use tokio::sync::Mutex; -use tokio::task::AbortHandle; +use tokio::sync::Notify; +use tokio_util::sync::CancellationToken; +use tokio_util::task::AbortOnDropHandle; use codex_protocol::models::ResponseInputItem; use tokio::sync::oneshot; @@ -46,9 +48,11 @@ impl TaskKind { #[derive(Clone)] pub(crate) struct RunningTask { - pub(crate) handle: AbortHandle, + pub(crate) done: Arc, pub(crate) kind: TaskKind, pub(crate) task: Arc, + pub(crate) cancellation_token: CancellationToken, + pub(crate) handle: Arc>, } impl ActiveTurn { @@ -115,13 +119,6 @@ impl ActiveTurn { let mut ts = self.turn_state.lock().await; ts.clear_pending(); } - - /// Best-effort, non-blocking variant for synchronous contexts (Drop/interrupt). - pub(crate) fn try_clear_pending_sync(&self) { - if let Ok(mut ts) = self.turn_state.try_lock() { - ts.clear_pending(); - } - } } #[cfg(test)] diff --git a/codex-rs/core/src/tasks/compact.rs b/codex-rs/core/src/tasks/compact.rs index 823febfc94..12e61b1f65 100644 --- a/codex-rs/core/src/tasks/compact.rs +++ b/codex-rs/core/src/tasks/compact.rs @@ -1,6 +1,7 @@ use std::sync::Arc; use async_trait::async_trait; +use tokio_util::sync::CancellationToken; use crate::codex::TurnContext; use crate::codex::compact; @@ -25,6 +26,7 @@ impl SessionTask for CompactTask { ctx: Arc, sub_id: String, input: Vec, + _cancellation_token: CancellationToken, ) -> Option { compact::run_compact_task(session.clone_session(), ctx, sub_id, input).await } diff --git a/codex-rs/core/src/tasks/mod.rs b/codex-rs/core/src/tasks/mod.rs index 464c1e633a..15ec419f9b 100644 --- a/codex-rs/core/src/tasks/mod.rs +++ b/codex-rs/core/src/tasks/mod.rs @@ -3,9 +3,15 @@ mod regular; mod review; use std::sync::Arc; +use std::time::Duration; use async_trait::async_trait; +use tokio::select; +use tokio::sync::Notify; +use tokio_util::sync::CancellationToken; +use tokio_util::task::AbortOnDropHandle; use tracing::trace; +use tracing::warn; use crate::codex::Session; use crate::codex::TurnContext; @@ -23,6 +29,8 @@ pub(crate) use compact::CompactTask; pub(crate) use regular::RegularTask; pub(crate) use review::ReviewTask; +const GRACEFULL_INTERRUPTION_TIMEOUT_MS: u64 = 100; + /// Thin wrapper that exposes the parts of [`Session`] task runners need. #[derive(Clone)] pub(crate) struct SessionTaskContext { @@ -49,6 +57,7 @@ pub(crate) trait SessionTask: Send + Sync + 'static { ctx: Arc, sub_id: String, input: Vec, + cancellation_token: CancellationToken, ) -> Option; async fn abort(&self, session: Arc, sub_id: &str) { @@ -69,26 +78,42 @@ impl Session { let task: Arc = Arc::new(task); let task_kind = task.kind(); + let cancellation_token = CancellationToken::new(); + let done = Arc::new(Notify::new()); + + let done_clone = Arc::clone(&done); let handle = { let session_ctx = Arc::new(SessionTaskContext::new(Arc::clone(self))); let ctx = Arc::clone(&turn_context); let task_for_run = Arc::clone(&task); let sub_clone = sub_id.clone(); + let task_cancellation_token = cancellation_token.child_token(); tokio::spawn(async move { let last_agent_message = task_for_run - .run(Arc::clone(&session_ctx), ctx, sub_clone.clone(), input) + .run( + Arc::clone(&session_ctx), + ctx, + sub_clone.clone(), + input, + task_cancellation_token.child_token(), + ) .await; - // Emit completion uniformly from spawn site so all tasks share the same lifecycle. - let sess = session_ctx.clone_session(); - sess.on_task_finished(sub_clone, last_agent_message).await; + + if !task_cancellation_token.is_cancelled() { + // Emit completion uniformly from spawn site so all tasks share the same lifecycle. + let sess = session_ctx.clone_session(); + sess.on_task_finished(sub_clone, last_agent_message).await; + } + done_clone.notify_waiters(); }) - .abort_handle() }; let running_task = RunningTask { - handle, + done, + handle: Arc::new(AbortOnDropHandle::new(handle)), kind: task_kind, task, + cancellation_token, }; self.register_new_active_task(sub_id, running_task).await; } @@ -143,14 +168,24 @@ impl Session { task: RunningTask, reason: TurnAbortReason, ) { - if task.handle.is_finished() { + if task.cancellation_token.is_cancelled() { return; } trace!(task_kind = ?task.kind, sub_id, "aborting running task"); + task.cancellation_token.cancel(); let session_task = task.task; - let handle = task.handle; - handle.abort(); + + select! { + _ = task.done.notified() => { + }, + _ = tokio::time::sleep(Duration::from_millis(GRACEFULL_INTERRUPTION_TIMEOUT_MS)) => { + warn!("task {sub_id} didn't complete gracefully after {}ms", GRACEFULL_INTERRUPTION_TIMEOUT_MS); + } + } + + task.handle.abort(); + let session_ctx = Arc::new(SessionTaskContext::new(Arc::clone(self))); session_task.abort(session_ctx, &sub_id).await; diff --git a/codex-rs/core/src/tasks/regular.rs b/codex-rs/core/src/tasks/regular.rs index b3758d5fc6..3a3fa267ce 100644 --- a/codex-rs/core/src/tasks/regular.rs +++ b/codex-rs/core/src/tasks/regular.rs @@ -1,6 +1,7 @@ use std::sync::Arc; use async_trait::async_trait; +use tokio_util::sync::CancellationToken; use crate::codex::TurnContext; use crate::codex::run_task; @@ -25,8 +26,17 @@ impl SessionTask for RegularTask { ctx: Arc, sub_id: String, input: Vec, + cancellation_token: CancellationToken, ) -> Option { let sess = session.clone_session(); - run_task(sess, ctx, sub_id, input, TaskKind::Regular).await + run_task( + sess, + ctx, + sub_id, + input, + TaskKind::Regular, + cancellation_token, + ) + .await } } diff --git a/codex-rs/core/src/tasks/review.rs b/codex-rs/core/src/tasks/review.rs index cec9243234..6b4b2175db 100644 --- a/codex-rs/core/src/tasks/review.rs +++ b/codex-rs/core/src/tasks/review.rs @@ -1,6 +1,7 @@ use std::sync::Arc; use async_trait::async_trait; +use tokio_util::sync::CancellationToken; use crate::codex::TurnContext; use crate::codex::exit_review_mode; @@ -26,9 +27,18 @@ impl SessionTask for ReviewTask { ctx: Arc, sub_id: String, input: Vec, + cancellation_token: CancellationToken, ) -> Option { let sess = session.clone_session(); - run_task(sess, ctx, sub_id, input, TaskKind::Review).await + run_task( + sess, + ctx, + sub_id, + input, + TaskKind::Review, + cancellation_token, + ) + .await } async fn abort(&self, session: Arc, sub_id: &str) { From 44ceaf085b0ffafe127a050fd5d35797b8ab1fc9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=B6rg=20Thalheim?= Date: Fri, 17 Oct 2025 20:19:08 +0100 Subject: [PATCH 23/35] Fix nix build (#4048) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit I dropped the build of the old cli from the flake, where the default.nix already seemed to removed in a previous iterations. Then I updated flake.nix and codex-rs expression to be able to build again (see individual commits for details). Tested by running the following builds: ``` $ nix build .#packages.x86_64-linux.codex-rs $ nix build .#packages.aarch64-darwin.codex-cli ``` --------- Signed-off-by: Jörg Thalheim --- codex-rs/default.nix | 65 ++++++++++++++++---------------------- flake.lock | 63 +++---------------------------------- flake.nix | 75 ++++++++++++++------------------------------ 3 files changed, 54 insertions(+), 149 deletions(-) diff --git a/codex-rs/default.nix b/codex-rs/default.nix index d2d4dfb6ba..3d598a9c2b 100644 --- a/codex-rs/default.nix +++ b/codex-rs/default.nix @@ -1,42 +1,31 @@ -{ pkgs, monorep-deps ? [], ... }: -let +{ + openssl, + rustPlatform, + pkg-config, + lib, + ... +}: +rustPlatform.buildRustPackage (finalAttrs: { env = { - PKG_CONFIG_PATH = "${pkgs.openssl.dev}/lib/pkgconfig:$PKG_CONFIG_PATH"; + PKG_CONFIG_PATH = "${openssl.dev}/lib/pkgconfig:$PKG_CONFIG_PATH"; }; -in -rec { - package = pkgs.rustPlatform.buildRustPackage { - inherit env; - pname = "codex-rs"; - version = "0.1.0"; - cargoLock.lockFile = ./Cargo.lock; - doCheck = false; - src = ./.; - nativeBuildInputs = with pkgs; [ - pkg-config - openssl - ]; - meta = with pkgs.lib; { - description = "OpenAI Codex command‑line interface rust implementation"; - license = licenses.asl20; - homepage = "https://github.com/openai/codex"; - }; + pname = "codex-rs"; + version = "0.1.0"; + cargoLock.lockFile = ./Cargo.lock; + doCheck = false; + src = ./.; + nativeBuildInputs = [ + pkg-config + openssl + ]; + + cargoLock.outputHashes = { + "ratatui-0.29.0" = "sha256-HBvT5c8GsiCxMffNjJGLmHnvG77A6cqEL+1ARurBXho="; }; - devShell = pkgs.mkShell { - inherit env; - name = "codex-rs-dev"; - packages = monorep-deps ++ [ - pkgs.cargo - package - ]; - shellHook = '' - echo "Entering development shell for codex-rs" - alias codex="cd ${package.src}/tui; cargo run; cd -" - ${pkgs.rustPlatform.cargoSetupHook} - ''; + + meta = with lib; { + description = "OpenAI Codex command‑line interface rust implementation"; + license = licenses.asl20; + homepage = "https://github.com/openai/codex"; }; - app = { - type = "app"; - program = "${package}/bin/codex"; - }; -} +}) diff --git a/flake.lock b/flake.lock index 6e4f3acce7..21550893f4 100644 --- a/flake.lock +++ b/flake.lock @@ -1,30 +1,12 @@ { "nodes": { - "flake-utils": { - "inputs": { - "systems": "systems" - }, - "locked": { - "lastModified": 1731533236, - "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=", - "owner": "numtide", - "repo": "flake-utils", - "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b", - "type": "github" - }, - "original": { - "owner": "numtide", - "repo": "flake-utils", - "type": "github" - } - }, "nixpkgs": { "locked": { - "lastModified": 1744463964, - "narHash": "sha256-LWqduOgLHCFxiTNYi3Uj5Lgz0SR+Xhw3kr/3Xd0GPTM=", + "lastModified": 1758427187, + "narHash": "sha256-pHpxZ/IyCwoTQPtFIAG2QaxuSm8jWzrzBGjwQZIttJc=", "owner": "NixOS", "repo": "nixpkgs", - "rev": "2631b0b7abcea6e640ce31cd78ea58910d31e650", + "rev": "554be6495561ff07b6c724047bdd7e0716aa7b46", "type": "github" }, "original": { @@ -36,44 +18,7 @@ }, "root": { "inputs": { - "flake-utils": "flake-utils", - "nixpkgs": "nixpkgs", - "rust-overlay": "rust-overlay" - } - }, - "rust-overlay": { - "inputs": { - "nixpkgs": [ - "nixpkgs" - ] - }, - "locked": { - "lastModified": 1746844454, - "narHash": "sha256-GcUWDQUDRYrD34ol90KGUpjbVcOfUNbv0s955jPecko=", - "owner": "oxalica", - "repo": "rust-overlay", - "rev": "be092436d4c0c303b654e4007453b69c0e33009e", - "type": "github" - }, - "original": { - "owner": "oxalica", - "repo": "rust-overlay", - "type": "github" - } - }, - "systems": { - "locked": { - "lastModified": 1681028828, - "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", - "owner": "nix-systems", - "repo": "default", - "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", - "type": "github" - }, - "original": { - "owner": "nix-systems", - "repo": "default", - "type": "github" + "nixpkgs": "nixpkgs" } } }, diff --git a/flake.nix b/flake.nix index 7247333c5a..b331c443bb 100644 --- a/flake.nix +++ b/flake.nix @@ -1,57 +1,28 @@ { description = "Development Nix flake for OpenAI Codex CLI"; - inputs = { - nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; - flake-utils.url = "github:numtide/flake-utils"; - rust-overlay = { - url = "github:oxalica/rust-overlay"; - inputs.nixpkgs.follows = "nixpkgs"; + inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; + + outputs = { nixpkgs, ... }: + let + systems = [ + "x86_64-linux" + "aarch64-linux" + "x86_64-darwin" + "aarch64-darwin" + ]; + forAllSystems = f: nixpkgs.lib.genAttrs systems f; + in + { + packages = forAllSystems (system: + let + pkgs = nixpkgs.legacyPackages.${system}; + codex-rs = pkgs.callPackage ./codex-rs { }; + in + { + codex-rs = codex-rs; + default = codex-rs; + } + ); }; - }; - - outputs = { nixpkgs, flake-utils, rust-overlay, ... }: - flake-utils.lib.eachDefaultSystem (system: - let - pkgs = import nixpkgs { - inherit system; - }; - pkgsWithRust = import nixpkgs { - inherit system; - overlays = [ rust-overlay.overlays.default ]; - }; - monorepo-deps = with pkgs; [ - # for precommit hook - pnpm - husky - ]; - codex-cli = import ./codex-cli { - inherit pkgs monorepo-deps; - }; - codex-rs = import ./codex-rs { - pkgs = pkgsWithRust; - inherit monorepo-deps; - }; - in - rec { - packages = { - codex-cli = codex-cli.package; - codex-rs = codex-rs.package; - }; - - devShells = { - codex-cli = codex-cli.devShell; - codex-rs = codex-rs.devShell; - }; - - apps = { - codex-cli = codex-cli.app; - codex-rs = codex-rs.app; - }; - - defaultPackage = packages.codex-cli; - defaultApp = apps.codex-cli; - defaultDevShell = devShells.codex-cli; - } - ); } From 6b0c486861ffcf6e0bbab65697d181985e692ded Mon Sep 17 00:00:00 2001 From: Gabriel Peal Date: Fri, 17 Oct 2025 14:47:50 -0700 Subject: [PATCH 24/35] [MCP] Render full MCP errors to the model (#5298) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously, the model couldn't see why MCP tool calls failed, many of which were the model using the parameters incorrectly. A common failure is the model stringifying the json for the notion-update-page tool which it then couldn't correct. I want to do some system prompt massaging around this as well. However, it is crucial that the model sees the error so it can fix it. Before: CleanShot 2025-10-17 at 13 02 36 After: CleanShot 2025-10-17 at 13 01 18 🎉 CleanShot 2025-10-17 at 13 09 30 Fixes #4707 --- codex-rs/core/src/mcp_tool_call.rs | 5 ++++- codex-rs/tui/src/history_cell.rs | 7 ++++++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/codex-rs/core/src/mcp_tool_call.rs b/codex-rs/core/src/mcp_tool_call.rs index 7091a05656..21fdaf0026 100644 --- a/codex-rs/core/src/mcp_tool_call.rs +++ b/codex-rs/core/src/mcp_tool_call.rs @@ -58,7 +58,10 @@ pub(crate) async fn handle_mcp_tool_call( let result = sess .call_tool(&server, &tool_name, arguments_value.clone()) .await - .map_err(|e| format!("tool call error: {e}")); + .map_err(|e| format!("tool call error: {e:?}")); + if let Err(e) = &result { + tracing::warn!("MCP tool call error: {e:?}"); + } let tool_call_end_event = EventMsg::McpToolCallEnd(McpToolCallEndEvent { call_id: call_id.clone(), invocation, diff --git a/codex-rs/tui/src/history_cell.rs b/codex-rs/tui/src/history_cell.rs index 85acaf9b6a..0a3582577d 100644 --- a/codex-rs/tui/src/history_cell.rs +++ b/codex-rs/tui/src/history_cell.rs @@ -854,7 +854,12 @@ impl HistoryCell for McpToolCallCell { } } Err(err) => { - let err_line = Line::from(format!("Error: {err}").dim()); + let err_text = format_and_truncate_tool_result( + &format!("Error: {err}"), + TOOL_CALL_MAX_LINES, + width as usize, + ); + let err_line = Line::from(err_text.dim()); let wrapped = word_wrap_line( &err_line, RtOptions::new((width as usize).saturating_sub(4)) From c1bde2a4ef9faeb3ae158cd7990015ac0570db97 Mon Sep 17 00:00:00 2001 From: Ryan Lopopolo Date: Fri, 17 Oct 2025 14:58:27 -0700 Subject: [PATCH 25/35] Fix link to MCP Servers config section (#5301) fix a broken link in https://github.com/openai/codex/blob/main/codex-rs/config.md to point to the anchor for configuring MCPs: https://github.com/openai/codex/blob/main/docs/config.md#connecting-to-mcp-servers --- codex-rs/config.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/codex-rs/config.md b/codex-rs/config.md index af51df4197..5de2a2d9c4 100644 --- a/codex-rs/config.md +++ b/codex-rs/config.md @@ -3,4 +3,4 @@ This file has moved. Please see the latest configuration documentation here: - Full config docs: [docs/config.md](../docs/config.md) -- MCP servers section: [docs/config.md#mcp_servers](../docs/config.md#mcp_servers) \ No newline at end of file +- MCP servers section: [docs/config.md#connecting-to-mcp-servers](../docs/config.md#connecting-to-mcp-servers) From 41900e9d0ff338f05571ab71e59b9bb023cac907 Mon Sep 17 00:00:00 2001 From: Gabriel Peal Date: Fri, 17 Oct 2025 15:16:22 -0700 Subject: [PATCH 26/35] [MCP] When MCP auth expires, prompt the user to log in again. (#5300) Similar to https://github.com/openai/codex/pull/5193 but catches a case where the user _has_ authenticated but the auth expired or was revoked. Before: CleanShot 2025-10-17 at 14 28 11 After: image --- codex-rs/core/src/codex.rs | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 23751d1b6d..2eb80cfaa6 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -426,13 +426,16 @@ impl Session { // Surface individual client start-up failures to the user. if !failed_clients.is_empty() { for (server_name, err) in failed_clients { + let auth_status = auth_statuses.get(&server_name); + let requires_login = match auth_status { + Some(McpAuthStatus::NotLoggedIn) => true, + Some(McpAuthStatus::OAuth) => is_mcp_client_auth_required_error(&err), + _ => false, + }; let log_message = format!("MCP client for `{server_name}` failed to start: {err:#}"); error!("{log_message}"); - let display_message = if matches!( - auth_statuses.get(&server_name), - Some(McpAuthStatus::NotLoggedIn) - ) { + let display_message = if requires_login { format!( "The {server_name} MCP server is not logged in. Run `codex mcp login {server_name}` to log in." ) @@ -2524,6 +2527,11 @@ pub(crate) async fn exit_review_mode( .await; } +fn is_mcp_client_auth_required_error(error: &anyhow::Error) -> bool { + // StreamableHttpError::AuthRequired from the MCP SDK. + error.to_string().contains("Auth required") +} + use crate::executor::errors::ExecError; use crate::executor::linkers::PreparedExec; use crate::tools::context::ApplyPatchCommandContext; From 0e08dd6055da1f1eb58a0ac0a15acfb038878029 Mon Sep 17 00:00:00 2001 From: Thibault Sottiaux Date: Fri, 17 Oct 2025 17:39:37 -0700 Subject: [PATCH 27/35] fix: switch rate limit reset handling to timestamps (#5304) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This change ensures that we store the absolute time instead of relative offsets of when the primary and secondary rate limits will reset. Previously these got recalculated relative to current time, which leads to the displayed reset times to change over time, including after doing a codex resume. For previously changed sessions, this will cause the reset times to not show due to this being a breaking change: Screenshot 2025-10-17 at 5 14 18 PM Fixes https://github.com/openai/codex/issues/4761 --- codex-rs/clippy.toml | 4 + codex-rs/core/src/client.rs | 44 +++--- codex-rs/core/src/error.rs | 181 ++++++++++++++++--------- codex-rs/core/tests/suite/client.rs | 22 +-- codex-rs/protocol/src/protocol.rs | 6 +- codex-rs/tui/src/status/rate_limits.rs | 9 +- codex-rs/tui/src/status/tests.rs | 64 +++++---- 7 files changed, 203 insertions(+), 127 deletions(-) diff --git a/codex-rs/clippy.toml b/codex-rs/clippy.toml index 5a6ff7f052..a2aae6591b 100644 --- a/codex-rs/clippy.toml +++ b/codex-rs/clippy.toml @@ -7,3 +7,7 @@ disallowed-methods = [ { path = "ratatui::style::Stylize::black", reason = "Avoid hardcoding black; prefer default fg or dim/bold. Exception: Disable this rule if rendering over a hardcoded ANSI background." }, { path = "ratatui::style::Stylize::yellow", reason = "Avoid yellow; prefer other colors in `tui/styles.md`." }, ] + +# Increase the size threshold for result_large_err to accommodate +# richer error variants. +large-error-threshold = 256 diff --git a/codex-rs/core/src/client.rs b/codex-rs/core/src/client.rs index 3a259fce67..b215106d2b 100644 --- a/codex-rs/core/src/client.rs +++ b/codex-rs/core/src/client.rs @@ -52,6 +52,8 @@ use crate::protocol::TokenUsage; use crate::state::TaskKind; use crate::token_data::PlanType; use crate::util::backoff; +use chrono::DateTime; +use chrono::Utc; use codex_otel::otel_event_manager::OtelEventManager; use codex_protocol::config_types::ReasoningEffort as ReasoningEffortConfig; use codex_protocol::config_types::ReasoningSummary as ReasoningSummaryConfig; @@ -71,7 +73,7 @@ struct Error { // Optional fields available on "usage_limit_reached" and "usage_not_included" errors plan_type: Option, - resets_in_seconds: Option, + resets_at: Option, } #[derive(Debug, Clone)] @@ -419,10 +421,14 @@ impl ModelClient { let plan_type = error .plan_type .or_else(|| auth.as_ref().and_then(CodexAuth::get_plan_type)); - let resets_in_seconds = error.resets_in_seconds; + let resets_at = error + .resets_at + .as_deref() + .and_then(|value| DateTime::parse_from_rfc3339(value).ok()) + .map(|dt| dt.with_timezone(&Utc)); let codex_err = CodexErr::UsageLimitReached(UsageLimitReachedError { plan_type, - resets_in_seconds, + resets_at, rate_limits: rate_limit_snapshot, }); return Err(StreamAttemptError::Fatal(codex_err)); @@ -605,14 +611,14 @@ fn parse_rate_limit_snapshot(headers: &HeaderMap) -> Option { headers, "x-codex-primary-used-percent", "x-codex-primary-window-minutes", - "x-codex-primary-reset-after-seconds", + "x-codex-primary-reset-at", ); let secondary = parse_rate_limit_window( headers, "x-codex-secondary-used-percent", "x-codex-secondary-window-minutes", - "x-codex-secondary-reset-after-seconds", + "x-codex-secondary-reset-at", ); Some(RateLimitSnapshot { primary, secondary }) @@ -628,16 +634,19 @@ fn parse_rate_limit_window( used_percent.and_then(|used_percent| { let window_minutes = parse_header_u64(headers, window_minutes_header); - let resets_in_seconds = parse_header_u64(headers, resets_header); + let resets_at = parse_header_str(headers, resets_header) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(std::string::ToString::to_string); let has_data = used_percent != 0.0 || window_minutes.is_some_and(|minutes| minutes != 0) - || resets_in_seconds.is_some_and(|seconds| seconds != 0); + || resets_at.is_some(); has_data.then_some(RateLimitWindow { used_percent, window_minutes, - resets_in_seconds, + resets_at, }) }) } @@ -1390,7 +1399,7 @@ mod tests { message: Some("Rate limit reached for gpt-5 in organization org- on tokens per min (TPM): Limit 1, Used 1, Requested 19304. Please try again in 28ms. Visit https://platform.openai.com/account/rate-limits to learn more.".to_string()), code: Some("rate_limit_exceeded".to_string()), plan_type: None, - resets_in_seconds: None + resets_at: None }; let delay = try_parse_retry_after(&err); @@ -1404,20 +1413,19 @@ mod tests { message: Some("Rate limit reached for gpt-5 in organization on tokens per min (TPM): Limit 30000, Used 6899, Requested 24050. Please try again in 1.898s. Visit https://platform.openai.com/account/rate-limits to learn more.".to_string()), code: Some("rate_limit_exceeded".to_string()), plan_type: None, - resets_in_seconds: None + resets_at: None }; let delay = try_parse_retry_after(&err); assert_eq!(delay, Some(Duration::from_secs_f64(1.898))); } #[test] - fn error_response_deserializes_old_schema_known_plan_type_and_serializes_back() { + fn error_response_deserializes_schema_known_plan_type_and_serializes_back() { use crate::token_data::KnownPlan; use crate::token_data::PlanType; - let json = r#"{"error":{"type":"usage_limit_reached","plan_type":"pro","resets_in_seconds":3600}}"#; - let resp: ErrorResponse = - serde_json::from_str(json).expect("should deserialize old schema"); + let json = r#"{"error":{"type":"usage_limit_reached","plan_type":"pro","resets_at":"2024-01-01T00:00:00Z"}}"#; + let resp: ErrorResponse = serde_json::from_str(json).expect("should deserialize schema"); assert_matches!(resp.error.plan_type, Some(PlanType::Known(KnownPlan::Pro))); @@ -1426,13 +1434,11 @@ mod tests { } #[test] - fn error_response_deserializes_old_schema_unknown_plan_type_and_serializes_back() { + fn error_response_deserializes_schema_unknown_plan_type_and_serializes_back() { use crate::token_data::PlanType; - let json = - r#"{"error":{"type":"usage_limit_reached","plan_type":"vip","resets_in_seconds":60}}"#; - let resp: ErrorResponse = - serde_json::from_str(json).expect("should deserialize old schema"); + let json = r#"{"error":{"type":"usage_limit_reached","plan_type":"vip","resets_at":"2024-01-01T00:01:00Z"}}"#; + let resp: ErrorResponse = serde_json::from_str(json).expect("should deserialize schema"); assert_matches!(resp.error.plan_type, Some(PlanType::Unknown(ref s)) if s == "vip"); diff --git a/codex-rs/core/src/error.rs b/codex-rs/core/src/error.rs index 951ba3954f..5597ffd431 100644 --- a/codex-rs/core/src/error.rs +++ b/codex-rs/core/src/error.rs @@ -2,6 +2,8 @@ use crate::exec::ExecToolCallOutput; use crate::token_data::KnownPlan; use crate::token_data::PlanType; use crate::truncate::truncate_middle; +use chrono::DateTime; +use chrono::Utc; use codex_async_utils::CancelErr; use codex_protocol::ConversationId; use codex_protocol::protocol::RateLimitSnapshot; @@ -237,7 +239,7 @@ impl std::fmt::Display for RetryLimitReachedError { #[derive(Debug)] pub struct UsageLimitReachedError { pub(crate) plan_type: Option, - pub(crate) resets_in_seconds: Option, + pub(crate) resets_at: Option>, pub(crate) rate_limits: Option, } @@ -246,12 +248,12 @@ impl std::fmt::Display for UsageLimitReachedError { let message = match self.plan_type.as_ref() { Some(PlanType::Known(KnownPlan::Plus)) => format!( "You've hit your usage limit. Upgrade to Pro (https://openai.com/chatgpt/pricing){}", - retry_suffix_after_or(self.resets_in_seconds) + retry_suffix_after_or(self.resets_at.as_ref()) ), Some(PlanType::Known(KnownPlan::Team)) | Some(PlanType::Known(KnownPlan::Business)) => { format!( "You've hit your usage limit. To get more access now, send a request to your admin{}", - retry_suffix_after_or(self.resets_in_seconds) + retry_suffix_after_or(self.resets_at.as_ref()) ) } Some(PlanType::Known(KnownPlan::Free)) => { @@ -262,11 +264,11 @@ impl std::fmt::Display for UsageLimitReachedError { | Some(PlanType::Known(KnownPlan::Enterprise)) | Some(PlanType::Known(KnownPlan::Edu)) => format!( "You've hit your usage limit.{}", - retry_suffix(self.resets_in_seconds) + retry_suffix(self.resets_at.as_ref()) ), Some(PlanType::Unknown(_)) | None => format!( "You've hit your usage limit.{}", - retry_suffix(self.resets_in_seconds) + retry_suffix(self.resets_at.as_ref()) ), }; @@ -274,8 +276,8 @@ impl std::fmt::Display for UsageLimitReachedError { } } -fn retry_suffix(resets_in_seconds: Option) -> String { - if let Some(secs) = resets_in_seconds { +fn retry_suffix(resets_at: Option<&DateTime>) -> String { + if let Some(secs) = remaining_seconds(resets_at) { let reset_duration = format_reset_duration(secs); format!(" Try again in {reset_duration}.") } else { @@ -283,8 +285,8 @@ fn retry_suffix(resets_in_seconds: Option) -> String { } } -fn retry_suffix_after_or(resets_in_seconds: Option) -> String { - if let Some(secs) = resets_in_seconds { +fn retry_suffix_after_or(resets_at: Option<&DateTime>) -> String { + if let Some(secs) = remaining_seconds(resets_at) { let reset_duration = format_reset_duration(secs); format!(" or try again in {reset_duration}.") } else { @@ -292,6 +294,29 @@ fn retry_suffix_after_or(resets_in_seconds: Option) -> String { } } +fn remaining_seconds(resets_at: Option<&DateTime>) -> Option { + let resets_at = resets_at.cloned()?; + let now = now_for_retry(); + let secs = resets_at.signed_duration_since(now).num_seconds(); + Some(if secs <= 0 { 0 } else { secs as u64 }) +} + +#[cfg(test)] +thread_local! { + static NOW_OVERRIDE: std::cell::RefCell>> = + const { std::cell::RefCell::new(None) }; +} + +fn now_for_retry() -> DateTime { + #[cfg(test)] + { + if let Some(now) = NOW_OVERRIDE.with(|cell| *cell.borrow()) { + return now; + } + } + Utc::now() +} + fn format_reset_duration(total_secs: u64) -> String { let days = total_secs / 86_400; let hours = (total_secs % 86_400) / 3_600; @@ -388,6 +413,10 @@ pub fn get_error_message_ui(e: &CodexErr) -> String { mod tests { use super::*; use crate::exec::StreamOutput; + use chrono::DateTime; + use chrono::Duration as ChronoDuration; + use chrono::TimeZone; + use chrono::Utc; use codex_protocol::protocol::RateLimitWindow; use pretty_assertions::assert_eq; @@ -396,21 +425,30 @@ mod tests { primary: Some(RateLimitWindow { used_percent: 50.0, window_minutes: Some(60), - resets_in_seconds: Some(3600), + resets_at: Some("2024-01-01T01:00:00Z".to_string()), }), secondary: Some(RateLimitWindow { used_percent: 30.0, window_minutes: Some(120), - resets_in_seconds: Some(7200), + resets_at: Some("2024-01-01T02:00:00Z".to_string()), }), } } + fn with_now_override(now: DateTime, f: impl FnOnce() -> T) -> T { + NOW_OVERRIDE.with(|cell| { + *cell.borrow_mut() = Some(now); + let result = f(); + *cell.borrow_mut() = None; + result + }) + } + #[test] fn usage_limit_reached_error_formats_plus_plan() { let err = UsageLimitReachedError { plan_type: Some(PlanType::Known(KnownPlan::Plus)), - resets_in_seconds: None, + resets_at: None, rate_limits: Some(rate_limit_snapshot()), }; assert_eq!( @@ -490,7 +528,7 @@ mod tests { fn usage_limit_reached_error_formats_free_plan() { let err = UsageLimitReachedError { plan_type: Some(PlanType::Known(KnownPlan::Free)), - resets_in_seconds: Some(3600), + resets_at: None, rate_limits: Some(rate_limit_snapshot()), }; assert_eq!( @@ -503,7 +541,7 @@ mod tests { fn usage_limit_reached_error_formats_default_when_none() { let err = UsageLimitReachedError { plan_type: None, - resets_in_seconds: None, + resets_at: None, rate_limits: Some(rate_limit_snapshot()), }; assert_eq!( @@ -514,22 +552,26 @@ mod tests { #[test] fn usage_limit_reached_error_formats_team_plan() { - let err = UsageLimitReachedError { - plan_type: Some(PlanType::Known(KnownPlan::Team)), - resets_in_seconds: Some(3600), - rate_limits: Some(rate_limit_snapshot()), - }; - assert_eq!( - err.to_string(), - "You've hit your usage limit. To get more access now, send a request to your admin or try again in 1 hour." - ); + let base = Utc.with_ymd_and_hms(2024, 1, 1, 0, 0, 0).unwrap(); + let resets_at = base + ChronoDuration::hours(1); + with_now_override(base, move || { + let err = UsageLimitReachedError { + plan_type: Some(PlanType::Known(KnownPlan::Team)), + resets_at: Some(resets_at), + rate_limits: Some(rate_limit_snapshot()), + }; + assert_eq!( + err.to_string(), + "You've hit your usage limit. To get more access now, send a request to your admin or try again in 1 hour." + ); + }); } #[test] fn usage_limit_reached_error_formats_business_plan_without_reset() { let err = UsageLimitReachedError { plan_type: Some(PlanType::Known(KnownPlan::Business)), - resets_in_seconds: None, + resets_at: None, rate_limits: Some(rate_limit_snapshot()), }; assert_eq!( @@ -542,7 +584,7 @@ mod tests { fn usage_limit_reached_error_formats_default_for_other_plans() { let err = UsageLimitReachedError { plan_type: Some(PlanType::Known(KnownPlan::Pro)), - resets_in_seconds: None, + resets_at: None, rate_limits: Some(rate_limit_snapshot()), }; assert_eq!( @@ -553,53 +595,70 @@ mod tests { #[test] fn usage_limit_reached_includes_minutes_when_available() { - let err = UsageLimitReachedError { - plan_type: None, - resets_in_seconds: Some(5 * 60), - rate_limits: Some(rate_limit_snapshot()), - }; - assert_eq!( - err.to_string(), - "You've hit your usage limit. Try again in 5 minutes." - ); + let base = Utc.with_ymd_and_hms(2024, 1, 1, 0, 0, 0).unwrap(); + let resets_at = base + ChronoDuration::minutes(5); + with_now_override(base, move || { + let err = UsageLimitReachedError { + plan_type: None, + resets_at: Some(resets_at), + rate_limits: Some(rate_limit_snapshot()), + }; + assert_eq!( + err.to_string(), + "You've hit your usage limit. Try again in 5 minutes." + ); + }); } #[test] fn usage_limit_reached_includes_hours_and_minutes() { - let err = UsageLimitReachedError { - plan_type: Some(PlanType::Known(KnownPlan::Plus)), - resets_in_seconds: Some(3 * 3600 + 32 * 60), - rate_limits: Some(rate_limit_snapshot()), - }; - assert_eq!( - err.to_string(), - "You've hit your usage limit. Upgrade to Pro (https://openai.com/chatgpt/pricing) or try again in 3 hours 32 minutes." - ); + let base = Utc.with_ymd_and_hms(2024, 1, 1, 0, 0, 0).unwrap(); + let resets_at = base + ChronoDuration::hours(3) + ChronoDuration::minutes(32); + with_now_override(base, move || { + let err = UsageLimitReachedError { + plan_type: Some(PlanType::Known(KnownPlan::Plus)), + resets_at: Some(resets_at), + rate_limits: Some(rate_limit_snapshot()), + }; + assert_eq!( + err.to_string(), + "You've hit your usage limit. Upgrade to Pro (https://openai.com/chatgpt/pricing) or try again in 3 hours 32 minutes." + ); + }); } #[test] fn usage_limit_reached_includes_days_hours_minutes() { - let err = UsageLimitReachedError { - plan_type: None, - resets_in_seconds: Some(2 * 86_400 + 3 * 3600 + 5 * 60), - rate_limits: Some(rate_limit_snapshot()), - }; - assert_eq!( - err.to_string(), - "You've hit your usage limit. Try again in 2 days 3 hours 5 minutes." - ); + let base = Utc.with_ymd_and_hms(2024, 1, 1, 0, 0, 0).unwrap(); + let resets_at = + base + ChronoDuration::days(2) + ChronoDuration::hours(3) + ChronoDuration::minutes(5); + with_now_override(base, move || { + let err = UsageLimitReachedError { + plan_type: None, + resets_at: Some(resets_at), + rate_limits: Some(rate_limit_snapshot()), + }; + assert_eq!( + err.to_string(), + "You've hit your usage limit. Try again in 2 days 3 hours 5 minutes." + ); + }); } #[test] fn usage_limit_reached_less_than_minute() { - let err = UsageLimitReachedError { - plan_type: None, - resets_in_seconds: Some(30), - rate_limits: Some(rate_limit_snapshot()), - }; - assert_eq!( - err.to_string(), - "You've hit your usage limit. Try again in less than a minute." - ); + let base = Utc.with_ymd_and_hms(2024, 1, 1, 0, 0, 0).unwrap(); + let resets_at = base + ChronoDuration::seconds(30); + with_now_override(base, move || { + let err = UsageLimitReachedError { + plan_type: None, + resets_at: Some(resets_at), + rate_limits: Some(rate_limit_snapshot()), + }; + assert_eq!( + err.to_string(), + "You've hit your usage limit. Try again in less than a minute." + ); + }); } } diff --git a/codex-rs/core/tests/suite/client.rs b/codex-rs/core/tests/suite/client.rs index beaa87a853..817f2c2a08 100644 --- a/codex-rs/core/tests/suite/client.rs +++ b/codex-rs/core/tests/suite/client.rs @@ -768,8 +768,8 @@ async fn token_count_includes_rate_limits_snapshot() { .insert_header("x-codex-secondary-used-percent", "40.0") .insert_header("x-codex-primary-window-minutes", "10") .insert_header("x-codex-secondary-window-minutes", "60") - .insert_header("x-codex-primary-reset-after-seconds", "1800") - .insert_header("x-codex-secondary-reset-after-seconds", "7200") + .insert_header("x-codex-primary-reset-at", "2024-01-01T00:30:00Z") + .insert_header("x-codex-secondary-reset-at", "2024-01-01T02:00:00Z") .set_body_raw(sse_body, "text/event-stream"); Mock::given(method("POST")) @@ -818,12 +818,12 @@ async fn token_count_includes_rate_limits_snapshot() { "primary": { "used_percent": 12.5, "window_minutes": 10, - "resets_in_seconds": 1800 + "resets_at": "2024-01-01T00:30:00Z" }, "secondary": { "used_percent": 40.0, "window_minutes": 60, - "resets_in_seconds": 7200 + "resets_at": "2024-01-01T02:00:00Z" } } }) @@ -865,12 +865,12 @@ async fn token_count_includes_rate_limits_snapshot() { "primary": { "used_percent": 12.5, "window_minutes": 10, - "resets_in_seconds": 1800 + "resets_at": "2024-01-01T00:30:00Z" }, "secondary": { "used_percent": 40.0, "window_minutes": 60, - "resets_in_seconds": 7200 + "resets_at": "2024-01-01T02:00:00Z" } } }) @@ -893,8 +893,8 @@ async fn token_count_includes_rate_limits_snapshot() { final_snapshot .primary .as_ref() - .and_then(|window| window.resets_in_seconds), - Some(1800) + .and_then(|window| window.resets_at.as_deref()), + Some("2024-01-01T00:30:00Z") ); wait_for_event(&codex, |msg| matches!(msg, EventMsg::TaskComplete(_))).await; @@ -915,7 +915,7 @@ async fn usage_limit_error_emits_rate_limit_event() -> anyhow::Result<()> { "error": { "type": "usage_limit_reached", "message": "limit reached", - "resets_in_seconds": 42, + "resets_at": "2024-01-01T00:00:42Z", "plan_type": "pro" } })); @@ -935,12 +935,12 @@ async fn usage_limit_error_emits_rate_limit_event() -> anyhow::Result<()> { "primary": { "used_percent": 100.0, "window_minutes": 15, - "resets_in_seconds": null + "resets_at": null }, "secondary": { "used_percent": 87.5, "window_minutes": 60, - "resets_in_seconds": null + "resets_at": null } }); diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index d888194776..5429e4d402 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -644,9 +644,9 @@ pub struct RateLimitWindow { /// Rolling window duration, in minutes. #[ts(type = "number | null")] pub window_minutes: Option, - /// Seconds until the window resets. - #[ts(type = "number | null")] - pub resets_in_seconds: Option, + /// Timestamp (RFC3339) when the window resets. + #[ts(type = "string | null")] + pub resets_at: Option, } // Includes prompts, tools and space to call compact. diff --git a/codex-rs/tui/src/status/rate_limits.rs b/codex-rs/tui/src/status/rate_limits.rs index 875001ef8a..f6a9c06aab 100644 --- a/codex-rs/tui/src/status/rate_limits.rs +++ b/codex-rs/tui/src/status/rate_limits.rs @@ -2,11 +2,9 @@ use crate::chatwidget::get_limits_duration; 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 = "█"; @@ -35,9 +33,10 @@ pub(crate) struct RateLimitWindowDisplay { 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))) + .resets_at + .as_deref() + .and_then(|value| DateTime::parse_from_rfc3339(value).ok()) + .map(|dt| dt.with_timezone(&Local)) .map(|dt| format_reset_timestamp(dt, captured_at)); Self { diff --git a/codex-rs/tui/src/status/tests.rs b/codex-rs/tui/src/status/tests.rs index 395d952f41..5cb730c905 100644 --- a/codex-rs/tui/src/status/tests.rs +++ b/codex-rs/tui/src/status/tests.rs @@ -1,7 +1,9 @@ use super::new_status_output; use super::rate_limit_snapshot_display; use crate::history_cell::HistoryCell; +use chrono::Duration as ChronoDuration; use chrono::TimeZone; +use chrono::Utc; use codex_core::config::Config; use codex_core::config::ConfigOverrides; use codex_core::config::ConfigToml; @@ -60,6 +62,12 @@ fn sanitize_directory(lines: Vec) -> Vec { .collect() } +fn reset_at_from(captured_at: &chrono::DateTime, seconds: i64) -> String { + (*captured_at + ChronoDuration::seconds(seconds)) + .with_timezone(&Utc) + .to_rfc3339() +} + #[test] fn status_snapshot_includes_reasoning_details() { let temp_home = TempDir::new().expect("temp home"); @@ -85,22 +93,22 @@ fn status_snapshot_includes_reasoning_details() { 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(10080), - 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 snapshot = RateLimitSnapshot { + primary: Some(RateLimitWindow { + used_percent: 72.5, + window_minutes: Some(300), + resets_at: Some(reset_at_from(&captured_at, 600)), + }), + secondary: Some(RateLimitWindow { + used_percent: 45.0, + window_minutes: Some(10080), + resets_at: Some(reset_at_from(&captured_at, 1_200)), + }), + }; let rate_display = rate_limit_snapshot_display(&snapshot, captured_at); let composite = new_status_output(&config, &usage, Some(&usage), &None, Some(&rate_display)); @@ -130,18 +138,18 @@ fn status_snapshot_includes_monthly_limit() { total_tokens: 1_200, }; - let snapshot = RateLimitSnapshot { - primary: Some(RateLimitWindow { - used_percent: 12.0, - window_minutes: Some(43_200), - resets_in_seconds: Some(86_400), - }), - secondary: None, - }; let captured_at = chrono::Local .with_ymd_and_hms(2024, 5, 6, 7, 8, 9) .single() .expect("timestamp"); + let snapshot = RateLimitSnapshot { + primary: Some(RateLimitWindow { + used_percent: 12.0, + window_minutes: Some(43_200), + resets_at: Some(reset_at_from(&captured_at, 86_400)), + }), + secondary: None, + }; let rate_display = rate_limit_snapshot_display(&snapshot, captured_at); let composite = new_status_output(&config, &usage, Some(&usage), &None, Some(&rate_display)); @@ -197,18 +205,18 @@ fn status_snapshot_truncates_in_narrow_terminal() { 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 snapshot = RateLimitSnapshot { + primary: Some(RateLimitWindow { + used_percent: 72.5, + window_minutes: Some(300), + resets_at: Some(reset_at_from(&captured_at, 600)), + }), + secondary: None, + }; let rate_display = rate_limit_snapshot_display(&snapshot, captured_at); let composite = new_status_output(&config, &usage, Some(&usage), &None, Some(&rate_display)); From 98c6dfa53711c499a989309c30b0fe38bfa54590 Mon Sep 17 00:00:00 2001 From: MomentDerek <40252940+MomentDerek@users.noreply.github.com> Date: Sat, 18 Oct 2025 10:03:36 +0800 Subject: [PATCH 28/35] fix: diff_buffers clear-to-end when deleting wide graphemes (#4921) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes #4870 #4717 #3260 #4431 #2718 #4898 #5036 - Fix the chat composer “phantom space” bug that appeared when backspacing CJK (and other double-width) characters after the composer got a uniform background in 43b63ccae89c…. - Pull diff_buffers’s clear-to-end logic forward to iterate by display width, so wide graphemes are counted correctly when computing the trailing column. - Keep modifier-aware detection so styled cells are still flushed, and add a regression test (diff_buffers_clear_to_end_starts_after_wide_char) that covers the CJK deletion scenario. --------- Co-authored-by: Josh McKinney --- codex-rs/tui/src/custom_terminal.rs | 58 +++++++++++++++++++++-------- 1 file changed, 42 insertions(+), 16 deletions(-) diff --git a/codex-rs/tui/src/custom_terminal.rs b/codex-rs/tui/src/custom_terminal.rs index bbd8900632..bd37fe8155 100644 --- a/codex-rs/tui/src/custom_terminal.rs +++ b/codex-rs/tui/src/custom_terminal.rs @@ -411,28 +411,35 @@ fn diff_buffers<'a>(a: &'a Buffer, b: &'a Buffer) -> Vec> { let next_buffer = &b.content; let mut updates = vec![]; - let mut last_nonblank_column = vec![0; a.area.height as usize]; + let mut last_nonblank_columns = vec![0; a.area.height as usize]; for y in 0..a.area.height { let row_start = y as usize * a.area.width as usize; let row_end = row_start + a.area.width as usize; let row = &next_buffer[row_start..row_end]; let bg = row.last().map(|cell| cell.bg).unwrap_or(Color::Reset); - let x = row - .iter() - .rposition(|cell| { - cell.symbol() != " " || cell.bg != bg || cell.modifier != Modifier::empty() - }) - .unwrap_or(0); - last_nonblank_column[y as usize] = x as u16; - if x < (a.area.width as usize).saturating_sub(1) { - let (x_abs, y_abs) = a.pos_of(row_start + x + 1); - updates.push(DrawCommand::ClearToEnd { - x: x_abs, - y: y_abs, - bg, - }); + // Scan the row to find the rightmost column that still matters: any non-space glyph, + // any cell whose bg differs from the row’s trailing bg, or any cell with modifiers. + // Multi-width glyphs extend that region through their full displayed width. + // After that point the rest of the row can be cleared with a single ClearToEnd, a perf win + // versus emitting multiple space Put commands. + let mut last_nonblank_column = 0usize; + let mut column = 0usize; + while column < row.len() { + let cell = &row[column]; + let width = cell.symbol().width(); + if cell.symbol() != " " || cell.bg != bg || cell.modifier != Modifier::empty() { + last_nonblank_column = column + (width.saturating_sub(1)); + } + column += width.max(1); // treat zero-width symbols as width 1 } + + if last_nonblank_column + 1 < row.len() { + let (x, y) = a.pos_of(row_start + last_nonblank_column + 1); + updates.push(DrawCommand::ClearToEnd { x, y, bg }); + } + + last_nonblank_columns[y as usize] = last_nonblank_column as u16; } // Cells invalidated by drawing/replacing preceding multi-width characters: @@ -444,7 +451,7 @@ fn diff_buffers<'a>(a: &'a Buffer, b: &'a Buffer) -> Vec> { if !current.skip && (current != previous || invalidated > 0) && to_skip == 0 { let (x, y) = a.pos_of(i); let row = i / a.area.width as usize; - if x <= last_nonblank_column[row] { + if x <= last_nonblank_columns[row] { updates.push(DrawCommand::Put { x, y, @@ -592,6 +599,7 @@ mod tests { use super::*; use pretty_assertions::assert_eq; use ratatui::layout::Rect; + use ratatui::style::Style; #[test] fn diff_buffers_does_not_emit_clear_to_end_for_full_width_row() { @@ -620,4 +628,22 @@ mod tests { "expected diff_buffers to update the final cell; commands: {commands:?}", ); } + + #[test] + fn diff_buffers_clear_to_end_starts_after_wide_char() { + let area = Rect::new(0, 0, 10, 1); + let mut previous = Buffer::empty(area); + let mut next = Buffer::empty(area); + + previous.set_string(0, 0, "中文", Style::default()); + next.set_string(0, 0, "中", Style::default()); + + let commands = diff_buffers(&previous, &next); + assert!( + commands + .iter() + .any(|command| matches!(command, DrawCommand::ClearToEnd { x: 2, y: 0, .. })), + "expected clear-to-end to start after the remaining wide char; commands: {commands:?}" + ); + } } From a182c1315cdf9fcfc83187addd96a8d596285d09 Mon Sep 17 00:00:00 2001 From: Truls Borgvall <51020190+truls27a@users.noreply.github.com> Date: Sat, 18 Oct 2025 20:02:22 +0200 Subject: [PATCH 29/35] docs(changelog): update install command to @openai/codex@ (#2073) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # What Updates the install command in the changelog template (`cliff.toml`) from ``` npm install -g codex@version ``` to ``` npm install -g @openai/codex@ ``` # Why The current command is incorrect, it tries installs the old “codex” static site generator rather than the OpenAI Codex CLI. # How Edited only the header string in `cliff.toml` to point to @openai/codex@. No changelog regeneration or other files touched. Fixes #2059 Co-authored-by: Thibault Sottiaux --- cliff.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cliff.toml b/cliff.toml index 30aa1f1ac6..f31e1bd89c 100644 --- a/cliff.toml +++ b/cliff.toml @@ -4,7 +4,7 @@ header = """ # Changelog -You can install any of these versions: `npm install -g codex@version` +You can install any of these versions: `npm install -g @openai/codex@` """ body = """ From 11c019d6c5de998022330f29ba040642174c0a98 Mon Sep 17 00:00:00 2001 From: Thibault Sottiaux Date: Sat, 18 Oct 2025 11:55:24 -0700 Subject: [PATCH 30/35] fix: handle missing resume session id gracefully (#5329) Exit when a requested resume session is missing after restoring the terminal and print a helpful message instructing users how to resume existing sessions. Partially addresses #5247. --- codex-rs/tui/src/lib.rs | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index aa40b05c55..3cae393982 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -437,7 +437,20 @@ async fn run_ratatui_app( Some(path) => resume_picker::ResumeSelection::Resume(path), None => { error!("Error finding conversation path: {id_str}"); - resume_picker::ResumeSelection::StartFresh + restore(); + session_log::log_session_end(); + let _ = tui.terminal.clear(); + if let Err(err) = writeln!( + std::io::stdout(), + "No saved session found with ID {id_str}. Run `codex resume` without an ID to choose from existing sessions." + ) { + error!("Failed to write resume error message: {err}"); + } + return Ok(AppExitInfo { + token_usage: codex_core::protocol::TokenUsage::default(), + conversation_id: None, + update_action: None, + }); } } } else if cli.resume_last { From c81e1477ae6f07fcdc7e81d3316e1692c460dadb Mon Sep 17 00:00:00 2001 From: Thibault Sottiaux Date: Sat, 18 Oct 2025 15:58:31 -0700 Subject: [PATCH 31/35] fix: improve custom prompt documentation and actually use prompt descriptions (#5332) Expand the custom prompts documentation and link it from other guides. Show saved prompt metadata in the slash-command popup, with tests covering description fallbacks. --- README.md | 1 + codex-rs/tui/src/bottom_pane/command_popup.rs | 47 ++++++++++-- docs/getting-started.md | 2 + docs/prompts.md | 71 ++++++++++++++----- 4 files changed, 100 insertions(+), 21 deletions(-) diff --git a/README.md b/README.md index c9cc4e1fd4..3a78f96573 100644 --- a/README.md +++ b/README.md @@ -75,6 +75,7 @@ Codex CLI supports a rich set of configuration options, with preferences stored - [CLI usage](./docs/getting-started.md#cli-usage) - [Running with a prompt as input](./docs/getting-started.md#running-with-a-prompt-as-input) - [Example prompts](./docs/getting-started.md#example-prompts) + - [Custom prompts](./docs/prompts.md) - [Memory with AGENTS.md](./docs/getting-started.md#memory-with-agentsmd) - [Configuration](./docs/config.md) - [**Sandbox & approvals**](./docs/sandbox.md) diff --git a/codex-rs/tui/src/bottom_pane/command_popup.rs b/codex-rs/tui/src/bottom_pane/command_popup.rs index 1842a999e3..d7501cebbc 100644 --- a/codex-rs/tui/src/bottom_pane/command_popup.rs +++ b/codex-rs/tui/src/bottom_pane/command_popup.rs @@ -164,10 +164,17 @@ impl CommandPopup { CommandItem::Builtin(cmd) => { (format!("/{}", cmd.command()), cmd.description().to_string()) } - CommandItem::UserPrompt(i) => ( - format!("/{PROMPTS_CMD_PREFIX}:{}", self.prompts[i].name), - "send saved prompt".to_string(), - ), + CommandItem::UserPrompt(i) => { + let prompt = &self.prompts[i]; + let description = prompt + .description + .clone() + .unwrap_or_else(|| "send saved prompt".to_string()); + ( + format!("/{PROMPTS_CMD_PREFIX}:{}", prompt.name), + description, + ) + } }; GenericDisplayRow { name, @@ -221,6 +228,7 @@ impl WidgetRef for CommandPopup { #[cfg(test)] mod tests { use super::*; + use pretty_assertions::assert_eq; #[test] fn filter_includes_init_when_typing_prefix() { @@ -322,4 +330,35 @@ mod tests { "prompt with builtin name should be ignored" ); } + + #[test] + fn prompt_description_uses_frontmatter_metadata() { + let popup = CommandPopup::new(vec![CustomPrompt { + name: "draftpr".to_string(), + path: "/tmp/draftpr.md".to_string().into(), + content: "body".to_string(), + description: Some("Create feature branch, commit and open draft PR.".to_string()), + argument_hint: None, + }]); + let rows = popup.rows_from_matches(vec![(CommandItem::UserPrompt(0), None, 0)]); + let description = rows.first().and_then(|row| row.description.as_deref()); + assert_eq!( + description, + Some("Create feature branch, commit and open draft PR.") + ); + } + + #[test] + fn prompt_description_falls_back_when_missing() { + let popup = CommandPopup::new(vec![CustomPrompt { + name: "foo".to_string(), + path: "/tmp/foo.md".to_string().into(), + content: "body".to_string(), + description: None, + argument_hint: None, + }]); + let rows = popup.rows_from_matches(vec![(CommandItem::UserPrompt(0), None, 0)]); + let description = rows.first().and_then(|row| row.description.as_deref()); + assert_eq!(description, Some("send saved prompt")); + } } diff --git a/docs/getting-started.md b/docs/getting-started.md index e97de6a048..6df93b5817 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -59,6 +59,8 @@ Below are a few bite-size examples you can copy-paste. Replace the text in quote | 6 | `codex "Carefully review this repo, and propose 3 high impact well-scoped PRs"` | Suggests impactful PRs in the current codebase. | | 7 | `codex "Look for vulnerabilities and create a security review report"` | Finds and explains security bugs. | +Looking to reuse your own instructions? Create slash commands with [custom prompts](./prompts.md). + ### Memory with AGENTS.md You can give Codex extra instructions and guidance using `AGENTS.md` files. Codex looks for `AGENTS.md` files in the following places, and merges them top-down: diff --git a/docs/prompts.md b/docs/prompts.md index 5157c4ecea..2e6c9fc1e6 100644 --- a/docs/prompts.md +++ b/docs/prompts.md @@ -1,20 +1,57 @@ ## Custom Prompts -Save frequently used prompts as Markdown files and reuse them quickly from the slash menu. +Custom prompts turn your repeatable instructions into reusable slash commands, so you can trigger them without retyping or copy/pasting. Each prompt is a Markdown file that Codex expands into the conversation the moment you run it. -- Location: Put files in `$CODEX_HOME/prompts/` (defaults to `~/.codex/prompts/`). -- File type: Only Markdown files with the `.md` extension are recognized. -- Name: The filename without the `.md` extension becomes the slash entry. For a file named `my-prompt.md`, type `/my-prompt`. -- Content: The file contents are sent as your message when you select the item in the slash popup and press Enter. -- Arguments: Local prompts support placeholders in their content: - - `$1..$9` expand to the first nine positional arguments typed after the slash name - - `$ARGUMENTS` expands to all arguments joined by a single space - - `$$` is preserved literally - - Quoted args: Wrap a single argument in double quotes to include spaces, e.g. `/review "docs/My File.md"`. -- How to use: - - Start a new session (Codex loads custom prompts on session start). - - In the composer, type `/` to open the slash popup and begin typing your prompt name. - - Use Up/Down to select it. Press Enter to submit its contents, or Tab to autocomplete the name. -- Notes: - - Files with names that collide with built‑in commands (e.g. `/init`) are ignored and won’t appear. - - New or changed files are discovered on session start. If you add a new prompt while Codex is running, start a new session to pick it up. +### Where prompts live + +- Location: store prompts in `$CODEX_HOME/prompts/` (defaults to `~/.codex/prompts/`). Set `CODEX_HOME` if you want to use a different folder. +- File type: Codex only loads `.md` files. Non-Markdown files are ignored. +- Naming: The filename (without `.md`) becomes the prompt name. A file called `review.md` registers the prompt `review`. +- Refresh: Prompts are loaded when a session starts. Restart Codex (or start a new session) after adding or editing files. +- Conflicts: Files whose names collide with built-in commands (like `init`) are skipped. + +### File format + +- Body: The file contents are sent verbatim when you run the prompt (after placeholder expansion). +- Frontmatter (optional): Add YAML-style metadata at the top of the file to improve the slash popup. + + ```markdown + --- + description: Request a concise git diff review + argument-hint: FILE= [FOCUS=
] + --- + ``` + + - `description` shows under the entry in the popup. + - `argument-hint` (or `argument_hint`) displays a short hint about expected inputs. + +### Placeholders and arguments + +- Numeric placeholders: `$1`–`$9` insert the first nine positional arguments you type after the command. `$ARGUMENTS` inserts all positional arguments joined by a single space. Use `$$` to emit a literal dollar sign (Codex leaves `$$` untouched). +- Named placeholders: Tokens such as `$FILE` or `$TICKET_ID` expand from `KEY=value` pairs you supply. Keys are case-sensitive—use the same uppercase name in the command (for example, `FILE=...`). +- Quoted arguments: Double-quote any value that contains spaces, e.g. `TICKET_TITLE="Fix logging"`. +- Invocation syntax: Run prompts via `/prompts: ...`. When the slash popup is open, typing either `prompts:` or the bare prompt name will surface `/prompts:` suggestions. +- Error handling: If a prompt contains named placeholders, Codex requires them all. You will see a validation message if any are missing or malformed. + +### Running a prompt + +1. Start a new Codex session (ensures the prompt list is fresh). +2. In the composer, type `/` to open the slash popup. +3. Type `prompts:` (or start typing the prompt name) and select it with ↑/↓. +4. Provide any required arguments, press Enter, and Codex sends the expanded content. + +### Examples + +**Draft PR helper** + +`~/.codex/prompts/draftpr.md` + +```markdown +--- +description: Create feature branch, commit and open draft PR. +--- + +Create a branch named `tibo/`, commit the changes, and open a draft PR. +``` + +Usage: type `/prompts:draftpr` to have codex perform the work. From d6a9e3857521ada1d9f53391a9ff1598f3d7e5b0 Mon Sep 17 00:00:00 2001 From: pakrym-oai Date: Sat, 18 Oct 2025 17:31:46 -0700 Subject: [PATCH 32/35] Move rust analyzer target dir (#5328) --- .vscode/settings.json | 1 + 1 file changed, 1 insertion(+) diff --git a/.vscode/settings.json b/.vscode/settings.json index aadeca0867..5adec04a83 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -3,6 +3,7 @@ "rust-analyzer.check.command": "clippy", "rust-analyzer.check.extraArgs": ["--all-features", "--tests"], "rust-analyzer.rustfmt.extraArgs": ["--config", "imports_granularity=Item"], + "rust-analyzer.cargo.targetDir": "${workspaceFolder}/codex-rs/target/rust-analyzer", "[rust]": { "editor.defaultFormatter": "rust-lang.rust-analyzer", "editor.formatOnSave": true, From 2287d2afde11ce6f64bc0f9709b51698974b8b74 Mon Sep 17 00:00:00 2001 From: pakrym-oai Date: Sat, 18 Oct 2025 17:43:08 -0700 Subject: [PATCH 33/35] Create independent TurnContexts (#5308) The goal of this change: 1. Unify user input and user turn implementation. 2. Have a single place where turn/session setting overrides are applied. 3. Have a single place where turn context is created. 4. Create TurnContext only for actual turn and have a separate structure for current session settings (reuse ConfigureSession) --- codex-rs/core/src/codex.rs | 586 ++++++++++---------- codex-rs/core/src/environment_context.rs | 19 + codex-rs/core/src/state/service.rs | 6 + codex-rs/core/src/state/session.rs | 9 +- codex-rs/core/tests/suite/prompt_caching.rs | 11 +- 5 files changed, 320 insertions(+), 311 deletions(-) diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 2eb80cfaa6..2ce0997075 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -107,6 +107,7 @@ use crate::rollout::RolloutRecorderParams; use crate::shell; use crate::state::ActiveTurn; use crate::state::SessionServices; +use crate::state::SessionState; use crate::state::TaskKind; use crate::tasks::CompactTask; use crate::tasks::RegularTask; @@ -169,7 +170,7 @@ impl Codex { let config = Arc::new(config); - let configure_session = ConfigureSession { + let session_configuration = SessionConfiguration { provider: config.model_provider.clone(), model: config.model.clone(), model_reasoning_effort: config.model_reasoning_effort, @@ -178,13 +179,13 @@ impl Codex { base_instructions: config.base_instructions.clone(), approval_policy: config.approval_policy, sandbox_policy: config.sandbox_policy.clone(), - notify: UserNotifier::new(config.notify.clone()), cwd: config.cwd.clone(), + original_config_do_not_use: Arc::clone(&config), }; // Generate a unique ID for the lifetime of this Codex session. - let (session, turn_context) = Session::new( - configure_session, + let session = Session::new( + session_configuration, config.clone(), auth_manager.clone(), tx_event.clone(), @@ -199,7 +200,7 @@ impl Codex { let conversation_id = session.conversation_id; // This task will run until Op::Shutdown is received. - tokio::spawn(submission_loop(session, turn_context, config, rx_sub)); + tokio::spawn(submission_loop(session, config, rx_sub)); let codex = Codex { next_id: AtomicU64::new(0), tx_sub, @@ -243,8 +244,6 @@ impl Codex { } } -use crate::state::SessionState; - /// Context for an initialized model agent /// /// A session has at most 1 running task at a time, and can be interrupted by user input. @@ -283,8 +282,8 @@ impl TurnContext { } } -/// Configure the model session. -struct ConfigureSession { +#[derive(Clone)] +pub(crate) struct SessionConfiguration { /// Provider identifier ("openai", "openrouter", ...). provider: ModelProviderInfo, @@ -305,8 +304,6 @@ struct ConfigureSession { /// How to sandbox commands executed in the system sandbox_policy: SandboxPolicy, - notify: UserNotifier, - /// Working directory that should be treated as the *root* of the /// session. All relative paths supplied by the model as well as the /// execution sandbox are resolved against this directory **instead** @@ -315,32 +312,118 @@ struct ConfigureSession { /// `ConfigureSession` operation so that the business-logic layer can /// operate deterministically. cwd: PathBuf, + + // TODO(pakrym): Remove config from here + original_config_do_not_use: Arc, +} + +impl SessionConfiguration { + pub(crate) fn apply(&self, updates: &SessionSettingsUpdate) -> Self { + let mut next_configuration = self.clone(); + if let Some(model) = updates.model.clone() { + next_configuration.model = model; + } + if let Some(effort) = updates.reasoning_effort { + next_configuration.model_reasoning_effort = effort; + } + if let Some(summary) = updates.reasoning_summary { + next_configuration.model_reasoning_summary = summary; + } + if let Some(approval_policy) = updates.approval_policy { + next_configuration.approval_policy = approval_policy; + } + if let Some(sandbox_policy) = updates.sandbox_policy.clone() { + next_configuration.sandbox_policy = sandbox_policy; + } + if let Some(cwd) = updates.cwd.clone() { + next_configuration.cwd = cwd; + } + next_configuration + } +} + +#[derive(Default, Clone)] +pub(crate) struct SessionSettingsUpdate { + pub(crate) cwd: Option, + pub(crate) approval_policy: Option, + pub(crate) sandbox_policy: Option, + pub(crate) model: Option, + pub(crate) reasoning_effort: Option>, + pub(crate) reasoning_summary: Option, + pub(crate) final_output_json_schema: Option>, } impl Session { + fn make_turn_context( + auth_manager: Option>, + otel_event_manager: &OtelEventManager, + provider: ModelProviderInfo, + session_configuration: &SessionConfiguration, + conversation_id: ConversationId, + ) -> TurnContext { + let config = session_configuration.original_config_do_not_use.clone(); + let model_family = find_family_for_model(&session_configuration.model) + .unwrap_or_else(|| config.model_family.clone()); + let mut per_turn_config = (*config).clone(); + per_turn_config.model = session_configuration.model.clone(); + per_turn_config.model_family = model_family.clone(); + per_turn_config.model_reasoning_effort = session_configuration.model_reasoning_effort; + per_turn_config.model_reasoning_summary = session_configuration.model_reasoning_summary; + if let Some(model_info) = get_model_info(&model_family) { + per_turn_config.model_context_window = Some(model_info.context_window); + } + + let otel_event_manager = otel_event_manager.clone().with_model( + session_configuration.model.as_str(), + session_configuration.model.as_str(), + ); + + let client = ModelClient::new( + Arc::new(per_turn_config), + auth_manager, + otel_event_manager, + provider, + session_configuration.model_reasoning_effort, + session_configuration.model_reasoning_summary, + conversation_id, + ); + + let tools_config = ToolsConfig::new(&ToolsConfigParams { + model_family: &model_family, + features: &config.features, + }); + + TurnContext { + client, + cwd: session_configuration.cwd.clone(), + base_instructions: session_configuration.base_instructions.clone(), + user_instructions: session_configuration.user_instructions.clone(), + approval_policy: session_configuration.approval_policy, + sandbox_policy: session_configuration.sandbox_policy.clone(), + shell_environment_policy: config.shell_environment_policy.clone(), + tools_config, + is_review_mode: false, + final_output_json_schema: None, + } + } + async fn new( - configure_session: ConfigureSession, + session_configuration: SessionConfiguration, config: Arc, auth_manager: Arc, tx_event: Sender, initial_history: InitialHistory, session_source: SessionSource, - ) -> anyhow::Result<(Arc, TurnContext)> { - let ConfigureSession { - provider, - model, - model_reasoning_effort, - model_reasoning_summary, - user_instructions, - base_instructions, - approval_policy, - sandbox_policy, - notify, - cwd, - } = configure_session; - debug!("Configuring session: model={model}; provider={provider:?}"); - if !cwd.is_absolute() { - return Err(anyhow::anyhow!("cwd is not absolute: {cwd:?}")); + ) -> anyhow::Result> { + debug!( + "Configuring session: model={}; provider={:?}", + session_configuration.model, session_configuration.provider + ); + if !session_configuration.cwd.is_absolute() { + return Err(anyhow::anyhow!( + "cwd is not absolute: {:?}", + session_configuration.cwd + )); } let (conversation_id, rollout_params) = match &initial_history { @@ -350,7 +433,7 @@ impl Session { conversation_id, RolloutRecorderParams::new( conversation_id, - user_instructions.clone(), + session_configuration.user_instructions.clone(), session_source, ), ) @@ -406,8 +489,6 @@ impl Session { anyhow::anyhow!("failed to initialize rollout recorder: {e:#}") })?; let rollout_path = rollout_recorder.rollout_path.clone(); - // Create the mutable state for the Session. - let state = SessionState::new(); // Handle MCP manager result and record any startup failures. let (mcp_connection_manager, failed_clients) = match mcp_res { @@ -475,45 +556,24 @@ impl Session { config.active_profile.clone(), ); - // Now that the conversation id is final (may have been updated by resume), - // construct the model client. - let client = ModelClient::new( - config.clone(), - Some(auth_manager.clone()), - otel_event_manager, - provider.clone(), - model_reasoning_effort, - model_reasoning_summary, - conversation_id, - ); - let turn_context = TurnContext { - client, - tools_config: ToolsConfig::new(&ToolsConfigParams { - model_family: &config.model_family, - features: &config.features, - }), - user_instructions, - base_instructions, - approval_policy, - sandbox_policy, - shell_environment_policy: config.shell_environment_policy.clone(), - cwd, - is_review_mode: false, - final_output_json_schema: None, - }; + // Create the mutable state for the Session. + let state = SessionState::new(session_configuration.clone()); + let services = SessionServices { mcp_connection_manager, session_manager: ExecSessionManager::default(), unified_exec_manager: UnifiedExecSessionManager::default(), - notifier: notify, + notifier: UserNotifier::new(config.notify.clone()), rollout: Mutex::new(Some(rollout_recorder)), user_shell: default_shell, show_raw_agent_reasoning: config.show_raw_agent_reasoning, executor: Executor::new(ExecutorConfig::new( - turn_context.sandbox_policy.clone(), - turn_context.cwd.clone(), + session_configuration.sandbox_policy.clone(), + session_configuration.cwd.clone(), config.codex_linux_sandbox_exe.clone(), )), + auth_manager: Arc::clone(&auth_manager), + otel_event_manager, }; let sess = Arc::new(Session { @@ -528,15 +588,14 @@ impl Session { // Dispatch the SessionConfiguredEvent first and then report any errors. // If resuming, include converted initial messages in the payload so UIs can render them immediately. let initial_messages = initial_history.get_event_msgs(); - sess.record_initial_history(&turn_context, initial_history) - .await; + sess.record_initial_history(initial_history).await; let events = std::iter::once(Event { id: INITIAL_SUBMIT_ID.to_owned(), msg: EventMsg::SessionConfigured(SessionConfiguredEvent { session_id: conversation_id, - model, - reasoning_effort: model_reasoning_effort, + model: session_configuration.model.clone(), + reasoning_effort: session_configuration.model_reasoning_effort, history_log_id, history_entry_count, initial_messages, @@ -548,7 +607,7 @@ impl Session { sess.send_event(event).await; } - Ok((sess, turn_context)) + Ok(sess) } pub(crate) fn get_tx_event(&self) -> Sender { @@ -562,15 +621,12 @@ impl Session { format!("auto-compact-{id}") } - async fn record_initial_history( - &self, - turn_context: &TurnContext, - conversation_history: InitialHistory, - ) { + async fn record_initial_history(&self, conversation_history: InitialHistory) { + let turn_context = self.new_turn(SessionSettingsUpdate::default()).await; match conversation_history { InitialHistory::New => { // Build and record initial items (user instructions + environment context) - let items = self.build_initial_context(turn_context); + let items = self.build_initial_context(&turn_context); self.record_conversation_items(&items).await; } InitialHistory::Resumed(_) | InitialHistory::Forked(_) => { @@ -579,7 +635,7 @@ impl Session { // Always add response items to conversation history let reconstructed_history = - self.reconstruct_history_from_rollout(turn_context, &rollout_items); + self.reconstruct_history_from_rollout(&turn_context, &rollout_items); if !reconstructed_history.is_empty() { self.record_into_history(&reconstructed_history).await; } @@ -592,6 +648,52 @@ impl Session { } } + pub(crate) async fn update_settings(&self, updates: SessionSettingsUpdate) { + let mut state = self.state.lock().await; + + state.session_configuration = state.session_configuration.apply(&updates); + } + + pub(crate) async fn new_turn(&self, updates: SessionSettingsUpdate) -> Arc { + let current_configuration = self.state.lock().await.session_configuration.clone(); + let session_configuration = current_configuration.apply(&updates); + + self.services.executor.update_environment( + session_configuration.sandbox_policy.clone(), + session_configuration.cwd.clone(), + ); + + let mut turn_context: TurnContext = Self::make_turn_context( + Some(Arc::clone(&self.services.auth_manager)), + &self.services.otel_event_manager, + session_configuration.provider.clone(), + &session_configuration, + self.conversation_id, + ); + if let Some(final_schema) = updates.final_output_json_schema { + turn_context.final_output_json_schema = final_schema; + } + Arc::new(turn_context) + } + + fn build_environment_update_item( + &self, + previous: Option<&Arc>, + next: &TurnContext, + ) -> Option { + let prev = previous?; + + let prev_context = EnvironmentContext::from(prev.as_ref()); + let next_context = EnvironmentContext::from(next); + if prev_context.equals_except_shell(&next_context) { + return None; + } + Some(ResponseItem::from(EnvironmentContext::diff( + prev.as_ref(), + next, + ))) + } + /// Persist the event to rollout and send it to clients. pub(crate) async fn send_event(&self, event: Event) { // Persist the event into rollout (recorder filters as needed) @@ -1188,14 +1290,8 @@ impl Session { } } -async fn submission_loop( - sess: Arc, - turn_context: TurnContext, - config: Arc, - rx_sub: Receiver, -) { - // Wrap once to avoid cloning TurnContext for each task. - let mut turn_context = Arc::new(turn_context); +async fn submission_loop(sess: Arc, config: Arc, rx_sub: Receiver) { + let mut previous_context: Option> = None; // To break out of this loop, send Op::Shutdown. while let Ok(sub) = rx_sub.recv().await { debug!(?sub, "Submission"); @@ -1211,174 +1307,58 @@ async fn submission_loop( effort, summary, } => { - // Recalculate the persistent turn context with provided overrides. - let prev = Arc::clone(&turn_context); - let provider = prev.client.get_provider(); - - // Effective model + family - let (effective_model, effective_family) = if let Some(ref m) = model { - let fam = - find_family_for_model(m).unwrap_or_else(|| config.model_family.clone()); - (m.clone(), fam) - } else { - (prev.client.get_model(), prev.client.get_model_family()) + let updates = SessionSettingsUpdate { + cwd, + approval_policy, + sandbox_policy, + model, + reasoning_effort: effort, + reasoning_summary: summary, + ..Default::default() }; + sess.update_settings(updates).await; + } - // Effective reasoning settings - let effective_effort = effort.unwrap_or(prev.client.get_reasoning_effort()); - let effective_summary = summary.unwrap_or(prev.client.get_reasoning_summary()); - - let auth_manager = prev.client.get_auth_manager(); - - // Build updated config for the client - let mut updated_config = (*config).clone(); - updated_config.model = effective_model.clone(); - updated_config.model_family = effective_family.clone(); - if let Some(model_info) = get_model_info(&effective_family) { - updated_config.model_context_window = Some(model_info.context_window); - } - - let otel_event_manager = prev.client.get_otel_event_manager().with_model( - updated_config.model.as_str(), - updated_config.model_family.slug.as_str(), - ); - - let client = ModelClient::new( - Arc::new(updated_config), - auth_manager, - otel_event_manager, - provider, - effective_effort, - effective_summary, - sess.conversation_id, - ); - - let new_approval_policy = approval_policy.unwrap_or(prev.approval_policy); - let new_sandbox_policy = sandbox_policy - .clone() - .unwrap_or(prev.sandbox_policy.clone()); - let new_cwd = cwd.clone().unwrap_or_else(|| prev.cwd.clone()); - - let tools_config = ToolsConfig::new(&ToolsConfigParams { - model_family: &effective_family, - features: &config.features, - }); - - let new_turn_context = TurnContext { - client, - tools_config, - user_instructions: prev.user_instructions.clone(), - base_instructions: prev.base_instructions.clone(), - approval_policy: new_approval_policy, - sandbox_policy: new_sandbox_policy.clone(), - shell_environment_policy: prev.shell_environment_policy.clone(), - cwd: new_cwd.clone(), - is_review_mode: false, - final_output_json_schema: None, - }; - - // Install the new persistent context for subsequent tasks/turns. - turn_context = Arc::new(new_turn_context); - - // Optionally persist changes to model / effort - if cwd.is_some() || approval_policy.is_some() || sandbox_policy.is_some() { - sess.record_conversation_items(&[ResponseItem::from(EnvironmentContext::new( + Op::UserInput { .. } | Op::UserTurn { .. } => { + let (items, updates) = match sub.op { + Op::UserTurn { cwd, approval_policy, sandbox_policy, - // Shell is not configurable from turn to turn - None, - ))]) - .await; - } - } - Op::UserInput { items } => { - turn_context - .client - .get_otel_event_manager() - .user_prompt(&items); - // attempt to inject input into current task - if let Err(items) = sess.inject_input(items).await { - // no current task, spawn a new one - sess.spawn_task(Arc::clone(&turn_context), sub.id, items, RegularTask) - .await; - } - } - Op::UserTurn { - items, - cwd, - approval_policy, - sandbox_policy, - model, - effort, - summary, - final_output_json_schema, - } => { - turn_context - .client - .get_otel_event_manager() - .user_prompt(&items); - // attempt to inject input into current task - if let Err(items) = sess.inject_input(items).await { - // Derive a fresh TurnContext for this turn using the provided overrides. - let provider = turn_context.client.get_provider(); - let auth_manager = turn_context.client.get_auth_manager(); - - // Derive a model family for the requested model; fall back to the session's. - let model_family = find_family_for_model(&model) - .unwrap_or_else(|| config.model_family.clone()); - - // Create a per‑turn Config clone with the requested model/family. - let mut per_turn_config = (*config).clone(); - per_turn_config.model = model.clone(); - per_turn_config.model_family = model_family.clone(); - if let Some(model_info) = get_model_info(&model_family) { - per_turn_config.model_context_window = Some(model_info.context_window); - } - - let otel_event_manager = - turn_context.client.get_otel_event_manager().with_model( - per_turn_config.model.as_str(), - per_turn_config.model_family.slug.as_str(), - ); - - // Build a new client with per‑turn reasoning settings. - // Reuse the same provider and session id; auth defaults to env/API key. - let client = ModelClient::new( - Arc::new(per_turn_config), - auth_manager, - otel_event_manager, - provider, + model, effort, summary, - sess.conversation_id, - ); - - let fresh_turn_context = TurnContext { - client, - tools_config: ToolsConfig::new(&ToolsConfigParams { - model_family: &model_family, - features: &config.features, - }), - user_instructions: turn_context.user_instructions.clone(), - base_instructions: turn_context.base_instructions.clone(), - approval_policy, - sandbox_policy, - shell_environment_policy: turn_context.shell_environment_policy.clone(), - cwd, - is_review_mode: false, final_output_json_schema, - }; - - // if the environment context has changed, record it in the conversation history - let previous_env_context = EnvironmentContext::from(turn_context.as_ref()); - let new_env_context = EnvironmentContext::from(&fresh_turn_context); - if !new_env_context.equals_except_shell(&previous_env_context) { - let env_response_item = ResponseItem::from(new_env_context); - sess.record_conversation_items(std::slice::from_ref(&env_response_item)) + items, + } => ( + items, + SessionSettingsUpdate { + cwd: Some(cwd), + approval_policy: Some(approval_policy), + sandbox_policy: Some(sandbox_policy), + model: Some(model), + reasoning_effort: Some(effort), + reasoning_summary: Some(summary), + final_output_json_schema: Some(final_output_json_schema), + }, + ), + Op::UserInput { items } => (items, SessionSettingsUpdate::default()), + _ => unreachable!(), + }; + let current_context = sess.new_turn(updates).await; + current_context + .client + .get_otel_event_manager() + .user_prompt(&items); + // attempt to inject input into current task + if let Err(items) = sess.inject_input(items).await { + if let Some(env_item) = sess + .build_environment_update_item(previous_context.as_ref(), ¤t_context) + { + sess.record_conversation_items(std::slice::from_ref(&env_item)) .await; for msg in map_response_item_to_event_messages( - &env_response_item, + &env_item, sess.show_raw_agent_reasoning(), ) { let event = Event { @@ -1389,12 +1369,9 @@ async fn submission_loop( } } - // Install the new persistent context for subsequent tasks/turns. - turn_context = Arc::new(fresh_turn_context); - - // no current task, spawn a new one with the per-turn context - sess.spawn_task(Arc::clone(&turn_context), sub.id, items, RegularTask) + sess.spawn_task(Arc::clone(¤t_context), sub.id, items, RegularTask) .await; + previous_context = Some(current_context); } } Op::ExecApproval { id, decision } => match decision { @@ -1500,6 +1477,7 @@ async fn submission_loop( sess.send_event(event).await; } Op::Compact => { + let turn_context = sess.new_turn(SessionSettingsUpdate::default()).await; // Attempt to inject input into current task if let Err(items) = sess .inject_input(vec![InputItem::Text { @@ -1569,6 +1547,7 @@ async fn submission_loop( sess.send_event(event).await; } Op::Review { review_request } => { + let turn_context = sess.new_turn(SessionSettingsUpdate::default()).await; spawn_review_thread( sess.clone(), config.clone(), @@ -2588,14 +2567,13 @@ mod tests { let (session, turn_context) = make_session_and_context(); let (rollout_items, expected) = sample_rollout(&session, &turn_context); - tokio_test::block_on(session.record_initial_history( - &turn_context, - InitialHistory::Resumed(ResumedHistory { + tokio_test::block_on(session.record_initial_history(InitialHistory::Resumed( + ResumedHistory { conversation_id: ConversationId::default(), history: rollout_items, rollout_path: PathBuf::from("/tmp/resume.jsonl"), - }), - )); + }, + ))); let actual = tokio_test::block_on(async { session.state.lock().await.history_snapshot() }); assert_eq!(expected, actual); @@ -2606,9 +2584,7 @@ mod tests { let (session, turn_context) = make_session_and_context(); let (rollout_items, expected) = sample_rollout(&session, &turn_context); - tokio_test::block_on( - session.record_initial_history(&turn_context, InitialHistory::Forked(rollout_items)), - ); + tokio_test::block_on(session.record_initial_history(InitialHistory::Forked(rollout_items))); let actual = tokio_test::block_on(async { session.state.lock().await.history_snapshot() }); assert_eq!(expected, actual); @@ -2832,53 +2808,56 @@ mod tests { let config = Arc::new(config); let conversation_id = ConversationId::default(); let otel_event_manager = otel_event_manager(conversation_id, config.as_ref()); - let client = ModelClient::new( - config.clone(), - None, - otel_event_manager, - config.model_provider.clone(), - config.model_reasoning_effort, - config.model_reasoning_summary, - conversation_id, - ); - let tools_config = ToolsConfig::new(&ToolsConfigParams { - model_family: &config.model_family, - features: &config.features, - }); - let turn_context = TurnContext { - client, - cwd: config.cwd.clone(), - base_instructions: config.base_instructions.clone(), + let auth_manager = AuthManager::shared(config.cwd.clone(), false); + + let session_configuration = SessionConfiguration { + provider: config.model_provider.clone(), + model: config.model.clone(), + model_reasoning_effort: config.model_reasoning_effort, + model_reasoning_summary: config.model_reasoning_summary, user_instructions: config.user_instructions.clone(), + base_instructions: config.base_instructions.clone(), approval_policy: config.approval_policy, sandbox_policy: config.sandbox_policy.clone(), - shell_environment_policy: config.shell_environment_policy.clone(), - tools_config, - is_review_mode: false, - final_output_json_schema: None, + cwd: config.cwd.clone(), + original_config_do_not_use: Arc::clone(&config), }; + + let state = SessionState::new(session_configuration.clone()); + let services = SessionServices { mcp_connection_manager: McpConnectionManager::default(), session_manager: ExecSessionManager::default(), unified_exec_manager: UnifiedExecSessionManager::default(), - notifier: UserNotifier::default(), + notifier: UserNotifier::new(None), rollout: Mutex::new(None), user_shell: shell::Shell::Unknown, show_raw_agent_reasoning: config.show_raw_agent_reasoning, executor: Executor::new(ExecutorConfig::new( - turn_context.sandbox_policy.clone(), - turn_context.cwd.clone(), + session_configuration.sandbox_policy.clone(), + session_configuration.cwd.clone(), None, )), + auth_manager: Arc::clone(&auth_manager), + otel_event_manager: otel_event_manager.clone(), }; + let session = Session { conversation_id, tx_event, - state: Mutex::new(SessionState::new()), + state: Mutex::new(state), active_turn: Mutex::new(None), services, next_internal_sub_id: AtomicU64::new(0), }; + + let turn_context = Session::make_turn_context( + Some(Arc::clone(&auth_manager)), + &otel_event_manager, + session_configuration.provider.clone(), + &session_configuration, + conversation_id, + ); (session, turn_context) } @@ -2900,53 +2879,56 @@ mod tests { let config = Arc::new(config); let conversation_id = ConversationId::default(); let otel_event_manager = otel_event_manager(conversation_id, config.as_ref()); - let client = ModelClient::new( - config.clone(), - None, - otel_event_manager, - config.model_provider.clone(), - config.model_reasoning_effort, - config.model_reasoning_summary, - conversation_id, - ); - let tools_config = ToolsConfig::new(&ToolsConfigParams { - model_family: &config.model_family, - features: &config.features, - }); - let turn_context = Arc::new(TurnContext { - client, - cwd: config.cwd.clone(), - base_instructions: config.base_instructions.clone(), + let auth_manager = AuthManager::shared(config.cwd.clone(), false); + + let session_configuration = SessionConfiguration { + provider: config.model_provider.clone(), + model: config.model.clone(), + model_reasoning_effort: config.model_reasoning_effort, + model_reasoning_summary: config.model_reasoning_summary, user_instructions: config.user_instructions.clone(), + base_instructions: config.base_instructions.clone(), approval_policy: config.approval_policy, sandbox_policy: config.sandbox_policy.clone(), - shell_environment_policy: config.shell_environment_policy.clone(), - tools_config, - is_review_mode: false, - final_output_json_schema: None, - }); + cwd: config.cwd.clone(), + original_config_do_not_use: Arc::clone(&config), + }; + + let state = SessionState::new(session_configuration.clone()); + let services = SessionServices { mcp_connection_manager: McpConnectionManager::default(), session_manager: ExecSessionManager::default(), unified_exec_manager: UnifiedExecSessionManager::default(), - notifier: UserNotifier::default(), + notifier: UserNotifier::new(None), rollout: Mutex::new(None), user_shell: shell::Shell::Unknown, show_raw_agent_reasoning: config.show_raw_agent_reasoning, executor: Executor::new(ExecutorConfig::new( - config.sandbox_policy.clone(), - config.cwd.clone(), + session_configuration.sandbox_policy.clone(), + session_configuration.cwd.clone(), None, )), + auth_manager: Arc::clone(&auth_manager), + otel_event_manager: otel_event_manager.clone(), }; + let session = Arc::new(Session { conversation_id, tx_event, - state: Mutex::new(SessionState::new()), + state: Mutex::new(state), active_turn: Mutex::new(None), services, next_internal_sub_id: AtomicU64::new(0), }); + + let turn_context = Arc::new(Session::make_turn_context( + Some(Arc::clone(&auth_manager)), + &otel_event_manager, + session_configuration.provider.clone(), + &session_configuration, + conversation_id, + )); (session, turn_context, rx_event) } diff --git a/codex-rs/core/src/environment_context.rs b/codex-rs/core/src/environment_context.rs index 8f3292a226..e7b2e19ffb 100644 --- a/codex-rs/core/src/environment_context.rs +++ b/codex-rs/core/src/environment_context.rs @@ -93,6 +93,25 @@ impl EnvironmentContext { && self.network_access == *network_access && self.writable_roots == *writable_roots } + + pub fn diff(before: &TurnContext, after: &TurnContext) -> Self { + let cwd = if before.cwd != after.cwd { + Some(after.cwd.clone()) + } else { + None + }; + let approval_policy = if before.approval_policy != after.approval_policy { + Some(after.approval_policy) + } else { + None + }; + let sandbox_policy = if before.sandbox_policy != after.sandbox_policy { + Some(after.sandbox_policy.clone()) + } else { + None + }; + EnvironmentContext::new(cwd, approval_policy, sandbox_policy, None) + } } impl From<&TurnContext> for EnvironmentContext { diff --git a/codex-rs/core/src/state/service.rs b/codex-rs/core/src/state/service.rs index 994352eddf..b18f1b9940 100644 --- a/codex-rs/core/src/state/service.rs +++ b/codex-rs/core/src/state/service.rs @@ -1,9 +1,13 @@ +use std::sync::Arc; + +use crate::AuthManager; use crate::RolloutRecorder; use crate::exec_command::ExecSessionManager; use crate::executor::Executor; use crate::mcp_connection_manager::McpConnectionManager; use crate::unified_exec::UnifiedExecSessionManager; use crate::user_notification::UserNotifier; +use codex_otel::otel_event_manager::OtelEventManager; use tokio::sync::Mutex; pub(crate) struct SessionServices { @@ -15,4 +19,6 @@ pub(crate) struct SessionServices { pub(crate) user_shell: crate::shell::Shell, pub(crate) show_raw_agent_reasoning: bool, pub(crate) executor: Executor, + pub(crate) auth_manager: Arc, + pub(crate) otel_event_manager: OtelEventManager, } diff --git a/codex-rs/core/src/state/session.rs b/codex-rs/core/src/state/session.rs index 8310d91c0c..fa7543ff53 100644 --- a/codex-rs/core/src/state/session.rs +++ b/codex-rs/core/src/state/session.rs @@ -2,14 +2,15 @@ use codex_protocol::models::ResponseItem; +use crate::codex::SessionConfiguration; 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) session_configuration: SessionConfiguration, pub(crate) history: ConversationHistory, pub(crate) token_info: Option, pub(crate) latest_rate_limits: Option, @@ -17,10 +18,12 @@ pub(crate) struct SessionState { impl SessionState { /// Create a new session state mirroring previous `State::default()` semantics. - pub(crate) fn new() -> Self { + pub(crate) fn new(session_configuration: SessionConfiguration) -> Self { Self { + session_configuration, history: ConversationHistory::new(), - ..Default::default() + token_info: None, + latest_rate_limits: None, } } diff --git a/codex-rs/core/tests/suite/prompt_caching.rs b/codex-rs/core/tests/suite/prompt_caching.rs index fcfc960224..caa4cb68de 100644 --- a/codex-rs/core/tests/suite/prompt_caching.rs +++ b/codex-rs/core/tests/suite/prompt_caching.rs @@ -507,7 +507,7 @@ async fn overrides_turn_context_but_keeps_cached_prefix_and_key_constant() { {} "#, - writable.path().to_string_lossy() + writable.path().to_string_lossy(), ); let expected_env_msg_2 = serde_json::json!({ "type": "message", @@ -866,15 +866,14 @@ async fn send_user_turn_with_changes_sends_environment_context() { ]); assert_eq!(body1["input"], expected_input_1); - let expected_env_msg_2 = text_user_input(format!( + let expected_env_msg_2 = text_user_input( r#" - {} never danger-full-access enabled -"#, - default_cwd.to_string_lossy() - )); +"# + .to_string(), + ); let expected_user_message_2 = text_user_input("hello 2".to_string()); let expected_input_2 = serde_json::Value::Array(vec![ expected_ui_msg, From 4f46360aa493b04eae12045ac8c34a4e42441734 Mon Sep 17 00:00:00 2001 From: Thibault Sottiaux Date: Sat, 18 Oct 2025 22:13:53 -0700 Subject: [PATCH 34/35] feat: add --add-dir flag for extra writable roots (#5335) Add a `--add-dir` CLI flag so sessions can use extra writable roots in addition to the ones specified in the config file. These are ephemerally added during the session only. Fixes #3303 Fixes #2797 --- codex-rs/README.md | 10 +++ .../app-server/src/codex_message_processor.rs | 1 + codex-rs/cli/src/main.rs | 3 + codex-rs/core/src/config.rs | 67 ++++++++++++++++++- codex-rs/exec/src/lib.rs | 1 + codex-rs/mcp-server/src/codex_tool_config.rs | 1 + codex-rs/tui/src/cli.rs | 5 ++ codex-rs/tui/src/lib.rs | 2 + 8 files changed, 89 insertions(+), 1 deletion(-) diff --git a/codex-rs/README.md b/codex-rs/README.md index a05a8d48b1..7f944d7a0c 100644 --- a/codex-rs/README.md +++ b/codex-rs/README.md @@ -61,6 +61,16 @@ In the transcript preview, the footer shows an `Esc edit prev` hint while editin Sometimes it is not convenient to `cd` to the directory you want Codex to use as the "working root" before running Codex. Fortunately, `codex` supports a `--cd` option so you can specify whatever folder you want. You can confirm that Codex is honoring `--cd` by double-checking the **workdir** it reports in the TUI at the start of a new session. +### `--add-dir` flag + +Need to work across multiple projects? Pass `--add-dir` one or more times to expose extra directories as writable roots for the current session while keeping the main working directory unchanged. For example: + +```shell +codex --cd apps/frontend --add-dir ../backend --add-dir ../shared +``` + +Codex can now inspect and edit files in each listed directory without leaving the primary workspace. + ### Shell completions Generate shell completion scripts via: diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index f455ad32e1..23f1a37891 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -1353,6 +1353,7 @@ async fn derive_config_from_params( include_view_image_tool: None, show_raw_agent_reasoning: None, tools_web_search_request: None, + additional_writable_roots: Vec::new(), }; let cli_overrides = cli_overrides diff --git a/codex-rs/cli/src/main.rs b/codex-rs/cli/src/main.rs index 71fee4fd6b..e01bd259a5 100644 --- a/codex-rs/cli/src/main.rs +++ b/codex-rs/cli/src/main.rs @@ -563,6 +563,9 @@ fn merge_resume_cli_flags(interactive: &mut TuiCli, resume_cli: TuiCli) { if !resume_cli.images.is_empty() { interactive.images = resume_cli.images; } + if !resume_cli.add_dir.is_empty() { + interactive.add_dir.extend(resume_cli.add_dir); + } if let Some(prompt) = resume_cli.prompt { interactive.prompt = Some(prompt); } diff --git a/codex-rs/core/src/config.rs b/codex-rs/core/src/config.rs index 6f5defcedc..efc5ca3dd4 100644 --- a/codex-rs/core/src/config.rs +++ b/codex-rs/core/src/config.rs @@ -42,6 +42,7 @@ use codex_protocol::config_types::SandboxMode; use codex_protocol::config_types::Verbosity; use codex_rmcp_client::OAuthCredentialsStoreMode; use dirs::home_dir; +use dunce::canonicalize; use serde::Deserialize; use similar::DiffableStr; use std::collections::BTreeMap; @@ -1094,6 +1095,8 @@ pub struct ConfigOverrides { pub include_view_image_tool: Option, pub show_raw_agent_reasoning: Option, pub tools_web_search_request: Option, + /// Additional directories that should be treated as writable roots for this session. + pub additional_writable_roots: Vec, } impl Config { @@ -1122,6 +1125,7 @@ impl Config { include_view_image_tool: include_view_image_tool_override, show_raw_agent_reasoning, tools_web_search_request: override_tools_web_search_request, + additional_writable_roots, } = overrides; let active_profile_name = config_profile_key @@ -1169,11 +1173,32 @@ impl Config { } } }; + let additional_writable_roots: Vec = additional_writable_roots + .into_iter() + .map(|path| { + let absolute = if path.is_absolute() { + path + } else { + resolved_cwd.join(path) + }; + match canonicalize(&absolute) { + Ok(canonical) => canonical, + Err(_) => absolute, + } + }) + .collect(); let active_project = cfg .get_active_project(&resolved_cwd) .unwrap_or(ProjectConfig { trust_level: None }); - let sandbox_policy = cfg.derive_sandbox_policy(sandbox_mode, &resolved_cwd); + let mut sandbox_policy = cfg.derive_sandbox_policy(sandbox_mode, &resolved_cwd); + if let SandboxPolicy::WorkspaceWrite { writable_roots, .. } = &mut sandbox_policy { + for path in additional_writable_roots { + if !writable_roots.iter().any(|existing| existing == &path) { + writable_roots.push(path); + } + } + } let mut approval_policy = approval_policy_override .or(config_profile.approval_policy) .or(cfg.approval_policy) @@ -1613,6 +1638,46 @@ trust_level = "trusted" ); } + #[test] + fn add_dir_override_extends_workspace_writable_roots() -> std::io::Result<()> { + let temp_dir = TempDir::new()?; + let frontend = temp_dir.path().join("frontend"); + let backend = temp_dir.path().join("backend"); + std::fs::create_dir_all(&frontend)?; + std::fs::create_dir_all(&backend)?; + + let overrides = ConfigOverrides { + cwd: Some(frontend), + sandbox_mode: Some(SandboxMode::WorkspaceWrite), + additional_writable_roots: vec![PathBuf::from("../backend"), backend.clone()], + ..Default::default() + }; + + let config = Config::load_from_base_config_with_overrides( + ConfigToml::default(), + overrides, + temp_dir.path().to_path_buf(), + )?; + + let expected_backend = canonicalize(&backend).expect("canonicalize backend directory"); + match config.sandbox_policy { + SandboxPolicy::WorkspaceWrite { writable_roots, .. } => { + assert_eq!( + writable_roots + .iter() + .filter(|root| **root == expected_backend) + .count(), + 1, + "expected single writable root entry for {}", + expected_backend.display() + ); + } + other => panic!("expected workspace-write policy, got {other:?}"), + } + + Ok(()) + } + #[test] fn approve_all_feature_forces_on_request_policy() -> std::io::Result<()> { let cfg = r#" diff --git a/codex-rs/exec/src/lib.rs b/codex-rs/exec/src/lib.rs index 144e7cabb0..efd2ff54e7 100644 --- a/codex-rs/exec/src/lib.rs +++ b/codex-rs/exec/src/lib.rs @@ -181,6 +181,7 @@ pub async fn run_main(cli: Cli, codex_linux_sandbox_exe: Option) -> any include_view_image_tool: None, show_raw_agent_reasoning: oss.then_some(true), tools_web_search_request: None, + additional_writable_roots: Vec::new(), }; // Parse `-c` overrides. let cli_kv_overrides = match config_overrides.parse_overrides() { diff --git a/codex-rs/mcp-server/src/codex_tool_config.rs b/codex-rs/mcp-server/src/codex_tool_config.rs index 3cd2cccdce..5b5242f2be 100644 --- a/codex-rs/mcp-server/src/codex_tool_config.rs +++ b/codex-rs/mcp-server/src/codex_tool_config.rs @@ -164,6 +164,7 @@ impl CodexToolCallParam { include_view_image_tool: None, show_raw_agent_reasoning: None, tools_web_search_request: None, + additional_writable_roots: Vec::new(), }; let cli_overrides = cli_overrides diff --git a/codex-rs/tui/src/cli.rs b/codex-rs/tui/src/cli.rs index ee2a0355da..d86040b5c9 100644 --- a/codex-rs/tui/src/cli.rs +++ b/codex-rs/tui/src/cli.rs @@ -1,4 +1,5 @@ use clap::Parser; +use clap::ValueHint; use codex_common::ApprovalModeCliArg; use codex_common::CliConfigOverrides; use std::path::PathBuf; @@ -72,6 +73,10 @@ pub struct Cli { #[arg(long = "search", default_value_t = false)] pub web_search: bool, + /// Additional directories that should be writable alongside the primary workspace. + #[arg(long = "add-dir", value_name = "DIR", value_hint = ValueHint::DirPath)] + pub add_dir: Vec, + #[clap(skip)] pub config_overrides: CliConfigOverrides, } diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index 3cae393982..1753f311ac 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -161,6 +161,7 @@ pub async fn run_main( // canonicalize the cwd let cwd = cli.cwd.clone().map(|p| p.canonicalize().unwrap_or(p)); + let additional_dirs = cli.add_dir.clone(); let overrides = ConfigOverrides { model, @@ -177,6 +178,7 @@ pub async fn run_main( include_view_image_tool: None, show_raw_agent_reasoning: cli.oss.then_some(true), tools_web_search_request: cli.web_search.then_some(true), + additional_writable_roots: additional_dirs, }; let raw_overrides = cli.config_overrides.raw_overrides.clone(); let overrides_cli = codex_common::CliConfigOverrides { raw_overrides }; From 2394a4fc98325afaef61c92c2d34cbeb4b0dceda Mon Sep 17 00:00:00 2001 From: Michael Bolin Date: Sun, 19 Oct 2025 11:40:30 -0700 Subject: [PATCH 35/35] feat: experimental `codex stdio-to-uds` subcommand --- codex-rs/Cargo.lock | 11 ++++ codex-rs/Cargo.toml | 2 + codex-rs/cli/Cargo.toml | 1 + codex-rs/cli/src/main.rs | 16 +++++ codex-rs/stdio-to-uds/Cargo.toml | 23 +++++++ codex-rs/stdio-to-uds/src/lib.rs | 55 +++++++++++++++++ codex-rs/stdio-to-uds/src/main.rs | 19 ++++++ codex-rs/stdio-to-uds/tests/stdio_to_uds.rs | 68 +++++++++++++++++++++ 8 files changed, 195 insertions(+) create mode 100644 codex-rs/stdio-to-uds/Cargo.toml create mode 100644 codex-rs/stdio-to-uds/src/lib.rs create mode 100644 codex-rs/stdio-to-uds/src/main.rs create mode 100644 codex-rs/stdio-to-uds/tests/stdio_to_uds.rs diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 9d18361cf8..706315f9a5 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -968,6 +968,7 @@ dependencies = [ "codex-protocol-ts", "codex-responses-api-proxy", "codex-rmcp-client", + "codex-stdio-to-uds", "codex-tui", "ctor 0.5.0", "owo-colors", @@ -1397,6 +1398,16 @@ dependencies = [ "webbrowser", ] +[[package]] +name = "codex-stdio-to-uds" +version = "0.0.0" +dependencies = [ + "anyhow", + "assert_cmd", + "pretty_assertions", + "tempfile", +] + [[package]] name = "codex-tui" version = "0.0.0" diff --git a/codex-rs/Cargo.toml b/codex-rs/Cargo.toml index 64eebb62a1..93067af3a9 100644 --- a/codex-rs/Cargo.toml +++ b/codex-rs/Cargo.toml @@ -29,6 +29,7 @@ members = [ "protocol-ts", "rmcp-client", "responses-api-proxy", + "stdio-to-uds", "otel", "tui", "git-apply", @@ -73,6 +74,7 @@ codex-protocol = { path = "protocol" } codex-protocol-ts = { path = "protocol-ts" } codex-responses-api-proxy = { path = "responses-api-proxy" } codex-rmcp-client = { path = "rmcp-client" } +codex-stdio-to-uds = { path = "stdio-to-uds" } codex-tui = { path = "tui" } codex-utils-json-to-toml = { path = "utils/json-to-toml" } codex-utils-readiness = { path = "utils/readiness" } diff --git a/codex-rs/cli/Cargo.toml b/codex-rs/cli/Cargo.toml index 453ab80771..f5108a8d10 100644 --- a/codex-rs/cli/Cargo.toml +++ b/codex-rs/cli/Cargo.toml @@ -32,6 +32,7 @@ codex-app-server-protocol = { workspace = true } codex-protocol-ts = { workspace = true } codex-responses-api-proxy = { workspace = true } codex-tui = { workspace = true } +codex-stdio-to-uds = { workspace = true } codex-rmcp-client = { workspace = true } codex-cloud-tasks = { path = "../cloud-tasks" } ctor = { workspace = true } diff --git a/codex-rs/cli/src/main.rs b/codex-rs/cli/src/main.rs index e01bd259a5..19bd9e80d5 100644 --- a/codex-rs/cli/src/main.rs +++ b/codex-rs/cli/src/main.rs @@ -104,6 +104,10 @@ enum Subcommand { #[clap(hide = true)] ResponsesApiProxy(ResponsesApiProxyArgs), + /// Internal: relay stdio to a Unix domain socket. + #[clap(hide = true, name = "stdio-to-uds")] + StdioToUds(StdioToUdsCommand), + /// Inspect feature flags. Features(FeaturesCli), } @@ -205,6 +209,13 @@ struct GenerateTsCommand { prettier: Option, } +#[derive(Debug, Parser)] +struct StdioToUdsCommand { + /// Path to the Unix domain socket to connect to. + #[arg(value_name = "SOCKET_PATH")] + socket_path: PathBuf, +} + fn format_exit_messages(exit_info: AppExitInfo, color_enabled: bool) -> Vec { let AppExitInfo { token_usage, @@ -462,6 +473,11 @@ async fn cli_main(codex_linux_sandbox_exe: Option) -> anyhow::Result<() tokio::task::spawn_blocking(move || codex_responses_api_proxy::run_main(args)) .await??; } + Some(Subcommand::StdioToUds(cmd)) => { + let socket_path = cmd.socket_path; + tokio::task::spawn_blocking(move || codex_stdio_to_uds::run(socket_path.as_path())) + .await??; + } Some(Subcommand::GenerateTs(gen_cli)) => { codex_protocol_ts::generate_ts(&gen_cli.out_dir, gen_cli.prettier.as_deref())?; } diff --git a/codex-rs/stdio-to-uds/Cargo.toml b/codex-rs/stdio-to-uds/Cargo.toml new file mode 100644 index 0000000000..35b4d1987c --- /dev/null +++ b/codex-rs/stdio-to-uds/Cargo.toml @@ -0,0 +1,23 @@ +[package] +edition = "2024" +name = "codex-stdio-to-uds" +version = { workspace = true } + +[[bin]] +name = "codex-stdio-to-uds" +path = "src/main.rs" + +[lib] +name = "codex_stdio_to_uds" +path = "src/lib.rs" + +[lints] +workspace = true + +[dependencies] +anyhow = { workspace = true } + +[dev-dependencies] +assert_cmd = { workspace = true } +pretty_assertions = { workspace = true } +tempfile = { workspace = true } diff --git a/codex-rs/stdio-to-uds/src/lib.rs b/codex-rs/stdio-to-uds/src/lib.rs new file mode 100644 index 0000000000..fbb1b4e758 --- /dev/null +++ b/codex-rs/stdio-to-uds/src/lib.rs @@ -0,0 +1,55 @@ +#![deny(clippy::print_stdout)] + +use std::io; +use std::io::Read; +use std::io::Write; +use std::net::Shutdown; +use std::os::unix::net::UnixStream; +use std::path::Path; +use std::thread; + +use anyhow::Context; +use anyhow::anyhow; + +/// Connects to the Unix Domain Socket at `socket_path` and relays data between +/// standard input/output and the socket. +pub fn run(socket_path: &Path) -> anyhow::Result<()> { + let stream = UnixStream::connect(socket_path) + .with_context(|| format!("failed to connect to socket at {}", socket_path.display()))?; + + let stdin = io::stdin(); + let stdout = io::stdout(); + + relay(stream, stdin, stdout) +} + +/// Relays data between the socket `stream`, the given input, and output +/// handles. Tests use this helper to inject custom IO streams. +pub fn relay(mut stream: UnixStream, mut input: R, output: W) -> anyhow::Result<()> +where + R: Read, + W: Write + Send + 'static, +{ + let mut reader = stream + .try_clone() + .context("failed to clone socket for reading")?; + + let stdout_thread = thread::spawn(move || -> io::Result<()> { + let mut output = output; + io::copy(&mut reader, &mut output)?; + output.flush()?; + Ok(()) + }); + + io::copy(&mut input, &mut stream).context("failed to copy data from input to socket")?; + stream + .shutdown(Shutdown::Write) + .context("failed to shutdown socket writer")?; + + let stdout_result = stdout_thread + .join() + .map_err(|_| anyhow!("thread panicked while copying socket data to output"))?; + stdout_result.context("failed to copy data from socket to output")?; + + Ok(()) +} diff --git a/codex-rs/stdio-to-uds/src/main.rs b/codex-rs/stdio-to-uds/src/main.rs new file mode 100644 index 0000000000..69987c31be --- /dev/null +++ b/codex-rs/stdio-to-uds/src/main.rs @@ -0,0 +1,19 @@ +use std::env; +use std::path::PathBuf; +use std::process; + +fn main() -> anyhow::Result<()> { + let mut args = env::args_os().skip(1); + let Some(socket_path) = args.next() else { + eprintln!("Usage: codex-stdio-to-uds "); + process::exit(1); + }; + + if args.next().is_some() { + eprintln!("Expected exactly one argument: "); + process::exit(1); + } + + let socket_path = PathBuf::from(socket_path); + codex_stdio_to_uds::run(&socket_path) +} diff --git a/codex-rs/stdio-to-uds/tests/stdio_to_uds.rs b/codex-rs/stdio-to-uds/tests/stdio_to_uds.rs new file mode 100644 index 0000000000..2165ed15b1 --- /dev/null +++ b/codex-rs/stdio-to-uds/tests/stdio_to_uds.rs @@ -0,0 +1,68 @@ +use std::fs; +use std::io::ErrorKind; +use std::io::Read; +use std::io::Write; +use std::os::unix::net::UnixListener; +use std::sync::mpsc; +use std::thread; +use std::time::Duration; + +use anyhow::Context; +use assert_cmd::Command; +use pretty_assertions::assert_eq; + +#[test] +fn pipes_stdin_and_stdout_through_socket() -> anyhow::Result<()> { + let workspace_dir = std::env::current_dir().context("failed to read current dir")?; + let tmp_base = workspace_dir.join("target/stdio-to-uds-tests"); + fs::create_dir_all(&tmp_base).context("failed to create test temp base directory")?; + let dir = + tempfile::tempdir_in(&tmp_base).context("failed to create temp dir inside workspace")?; + let socket_path = dir.path().join("socket"); + let listener = match UnixListener::bind(&socket_path) { + Ok(listener) => listener, + Err(err) if err.kind() == ErrorKind::PermissionDenied => { + eprintln!("skipping test: failed to bind unix socket: {err}"); + return Ok(()); + } + Err(err) => { + return Err(err).context("failed to bind test unix socket"); + } + }; + + let (tx, rx) = mpsc::channel(); + let server_thread = thread::spawn(move || -> anyhow::Result<()> { + let (mut connection, _) = listener + .accept() + .context("failed to accept test connection")?; + let mut received = Vec::new(); + connection + .read_to_end(&mut received) + .context("failed to read data from client")?; + tx.send(received) + .map_err(|_| anyhow::anyhow!("failed to send received bytes to test thread"))?; + connection + .write_all(b"response") + .context("failed to write response to client")?; + Ok(()) + }); + + Command::cargo_bin("codex-stdio-to-uds")? + .arg(&socket_path) + .write_stdin("request") + .assert() + .success() + .stdout("response"); + + let received = rx + .recv_timeout(Duration::from_secs(1)) + .context("server did not receive data in time")?; + assert_eq!(received, b"request"); + + let server_result = server_thread + .join() + .map_err(|_| anyhow::anyhow!("server thread panicked"))?; + server_result.context("server failed")?; + + Ok(()) +}