From 86a1e41f2ee8f580227fa9ea1e800e588c53fc02 Mon Sep 17 00:00:00 2001 From: Michael Bolin Date: Thu, 22 Jan 2026 22:38:10 -0800 Subject: [PATCH 01/36] chore: use some raw strings to reduce quoting (#9745) Small follow-ups for https://github.com/openai/codex/pull/9565. Mainly `r#`, but also added some whitespace for early returns. --- codex-rs/core/src/bash.rs | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/codex-rs/core/src/bash.rs b/codex-rs/core/src/bash.rs index 055e30d765..bb0ae7fe90 100644 --- a/codex-rs/core/src/bash.rs +++ b/codex-rs/core/src/bash.rs @@ -181,6 +181,7 @@ fn parse_double_quoted_string(node: Node, src: &str) -> Option { if node.kind() != "string" { return None; } + let mut cursor = node.walk(); for part in node.named_children(&mut cursor) { if part.kind() != "string_content" { @@ -198,6 +199,7 @@ fn parse_raw_string(node: Node, src: &str) -> Option { if node.kind() != "raw_string" { return None; } + let raw_string = node.utf8_text(src.as_bytes()).ok()?; let stripped = raw_string .strip_prefix('\'') @@ -265,17 +267,20 @@ mod tests { #[test] fn accepts_mixed_quote_concatenation() { - let cmds = parse_seq("echo \"/usr\"'/'\"local\"/bin").unwrap(); assert_eq!( - cmds, + parse_seq(r#"echo "/usr"'/'"local"/bin"#).unwrap(), + vec![vec!["echo".to_string(), "/usr/local/bin".to_string()]] + ); + assert_eq!( + parse_seq(r#"echo '/usr'"/"'local'/bin"#).unwrap(), vec![vec!["echo".to_string(), "/usr/local/bin".to_string()]] ); } #[test] fn rejects_double_quoted_strings_with_expansions() { - assert!(parse_seq("echo \"hi ${USER}\"").is_none()); - assert!(parse_seq("echo \"$HOME\"").is_none()); + assert!(parse_seq(r#"echo "hi ${USER}""#).is_none()); + assert!(parse_seq(r#"echo "$HOME""#).is_none()); } #[test] From afa08570f2865b64fb45fea58d00aba36a07cbf3 Mon Sep 17 00:00:00 2001 From: jif-oai Date: Fri, 23 Jan 2026 13:35:48 +0100 Subject: [PATCH 02/36] nit: exclude PWD for rc sourcing (#9753) --- codex-rs/core/src/shell_snapshot.rs | 48 +++++++++++++++++++++-------- 1 file changed, 35 insertions(+), 13 deletions(-) diff --git a/codex-rs/core/src/shell_snapshot.rs b/codex-rs/core/src/shell_snapshot.rs index 44e732df61..b578c4232f 100644 --- a/codex-rs/core/src/shell_snapshot.rs +++ b/codex-rs/core/src/shell_snapshot.rs @@ -28,6 +28,7 @@ pub struct ShellSnapshot { const SNAPSHOT_TIMEOUT: Duration = Duration::from_secs(10); const SNAPSHOT_RETENTION: Duration = Duration::from_secs(60 * 60 * 24 * 7); // 7 days retention. const SNAPSHOT_DIR: &str = "shell_snapshots"; +const EXCLUDED_EXPORT_VARS: &[&str] = &["PWD", "OLDPWD"]; impl ShellSnapshot { pub fn start_snapshotting( @@ -138,9 +139,9 @@ async fn write_shell_snapshot(shell_type: ShellType, output_path: &Path) -> Resu async fn capture_snapshot(shell: &Shell) -> Result { let shell_type = shell.shell_type.clone(); match shell_type { - ShellType::Zsh => run_shell_script(shell, zsh_snapshot_script()).await, - ShellType::Bash => run_shell_script(shell, bash_snapshot_script()).await, - ShellType::Sh => run_shell_script(shell, sh_snapshot_script()).await, + ShellType::Zsh => run_shell_script(shell, &zsh_snapshot_script()).await, + ShellType::Bash => run_shell_script(shell, &bash_snapshot_script()).await, + ShellType::Sh => run_shell_script(shell, &sh_snapshot_script()).await, ShellType::PowerShell => run_shell_script(shell, powershell_snapshot_script()).await, ShellType::Cmd => bail!("Shell snapshotting is not yet supported for {shell_type:?}"), } @@ -202,8 +203,13 @@ async fn run_script_with_timeout( Ok(String::from_utf8_lossy(&output.stdout).into_owned()) } -fn zsh_snapshot_script() -> &'static str { - r##"if [[ -n "$ZDOTDIR" ]]; then +fn excluded_exports_regex() -> String { + EXCLUDED_EXPORT_VARS.join("|") +} + +fn zsh_snapshot_script() -> String { + let excluded = excluded_exports_regex(); + let script = r##"if [[ -n "$ZDOTDIR" ]]; then rc="$ZDOTDIR/.zshrc" else rc="$HOME/.zshrc" @@ -229,6 +235,9 @@ export_lines=$(export -p | awk ' name=line sub(/^(export|declare -x|typeset -x) /, "", name) sub(/=.*/, "", name) + if (name ~ /^(EXCLUDED_EXPORTS)$/) { + next + } if (name ~ /^[A-Za-z_][A-Za-z0-9_]*$/) { print line } @@ -238,11 +247,13 @@ print "# exports $export_count" if [[ -n "$export_lines" ]]; then print -r -- "$export_lines" fi -"## +"##; + script.replace("EXCLUDED_EXPORTS", &excluded) } -fn bash_snapshot_script() -> &'static str { - r##"if [ -z "$BASH_ENV" ] && [ -r "$HOME/.bashrc" ]; then +fn bash_snapshot_script() -> String { + let excluded = excluded_exports_regex(); + let script = r##"if [ -z "$BASH_ENV" ] && [ -r "$HOME/.bashrc" ]; then . "$HOME/.bashrc" fi echo '# Snapshot file' @@ -268,6 +279,9 @@ export_lines=$(export -p | awk ' name=line sub(/^(export|declare -x|typeset -x) /, "", name) sub(/=.*/, "", name) + if (name ~ /^(EXCLUDED_EXPORTS)$/) { + next + } if (name ~ /^[A-Za-z_][A-Za-z0-9_]*$/) { print line } @@ -277,11 +291,13 @@ echo "# exports $export_count" if [ -n "$export_lines" ]; then printf '%s\n' "$export_lines" fi -"## +"##; + script.replace("EXCLUDED_EXPORTS", &excluded) } -fn sh_snapshot_script() -> &'static str { - r##"if [ -n "$ENV" ] && [ -r "$ENV" ]; then +fn sh_snapshot_script() -> String { + let excluded = excluded_exports_regex(); + let script = r##"if [ -n "$ENV" ] && [ -r "$ENV" ]; then . "$ENV" fi echo '# Snapshot file' @@ -320,6 +336,9 @@ if export -p >/dev/null 2>&1; then name=line sub(/^(export|declare -x|typeset -x) /, "", name) sub(/=.*/, "", name) + if (name ~ /^(EXCLUDED_EXPORTS)$/) { + next + } if (name ~ /^[A-Za-z_][A-Za-z0-9_]*$/) { print line } @@ -334,13 +353,14 @@ else echo "# exports $export_count" env | sort | while IFS='=' read -r key value; do case "$key" in - ""|[0-9]*|*[!A-Za-z0-9_]* ) continue ;; + ""|[0-9]*|*[!A-Za-z0-9_]*|EXCLUDED_EXPORTS) continue ;; esac escaped=$(printf "%s" "$value" | sed "s/'/'\"'\"'/g") printf "export %s='%s'\n" "$key" "$escaped" done fi -"## +"##; + script.replace("EXCLUDED_EXPORTS", &excluded) } fn powershell_snapshot_script() -> &'static str { @@ -494,6 +514,7 @@ mod tests { .arg(bash_snapshot_script()) .env("BASH_ENV", "/dev/null") .env("VALID_NAME", "ok") + .env("PWD", "/tmp/stale") .env("NEXTEST_BIN_EXE_codex-write-config-schema", "/path/to/bin") .env("BAD-NAME", "broken") .output()?; @@ -502,6 +523,7 @@ mod tests { let stdout = String::from_utf8_lossy(&output.stdout); assert!(stdout.contains("VALID_NAME")); + assert!(!stdout.contains("PWD=/tmp/stale")); assert!(!stdout.contains("NEXTEST_BIN_EXE_codex-write-config-schema")); assert!(!stdout.contains("BAD-NAME")); From f30f39b28b13e7048bfca7f7e8d4022e7840f443 Mon Sep 17 00:00:00 2001 From: jif-oai Date: Fri, 23 Jan 2026 13:57:59 +0100 Subject: [PATCH 03/36] feat: tui beta for collab (#9690) https://github.com/user-attachments/assets/1ca07e7a-3d82-40da-a5b0-8ab2eef0bb69 --- codex-rs/tui/src/app.rs | 611 ++++++++++++++++++++------- codex-rs/tui/src/app_event.rs | 8 +- codex-rs/tui/src/chatwidget.rs | 138 ++++++ codex-rs/tui/src/chatwidget/agent.rs | 15 + codex-rs/tui/src/custom_terminal.rs | 13 + codex-rs/tui/src/slash_command.rs | 3 + 6 files changed, 642 insertions(+), 146 deletions(-) diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 8f945436b6..fa24e110f5 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -7,6 +7,9 @@ use crate::app_event::WindowsSandboxEnableMode; use crate::app_event::WindowsSandboxFallbackReason; use crate::app_event_sender::AppEventSender; use crate::bottom_pane::ApprovalRequest; +use crate::bottom_pane::SelectionItem; +use crate::bottom_pane::SelectionViewParams; +use crate::bottom_pane::popup_consts::standard_popup_hint_line; use crate::chatwidget::ChatWidget; use crate::chatwidget::ExternalEditorState; use crate::diff_render::DiffSummary; @@ -50,9 +53,11 @@ use codex_core::protocol::SkillErrorInfo; use codex_core::protocol::TokenUsage; use codex_otel::OtelManager; use codex_protocol::ThreadId; +use codex_protocol::items::TurnItem; use codex_protocol::openai_models::ModelPreset; use codex_protocol::openai_models::ModelUpgrade; use codex_protocol::openai_models::ReasoningEffort as ReasoningEffortConfig; +use codex_protocol::protocol::SessionConfiguredEvent; use color_eyre::eyre::Result; use color_eyre::eyre::WrapErr; use crossterm::event::KeyCode; @@ -64,6 +69,7 @@ use ratatui::widgets::Paragraph; use ratatui::widgets::Wrap; use std::collections::BTreeMap; use std::collections::HashMap; +use std::collections::HashSet; use std::collections::VecDeque; use std::path::Path; use std::path::PathBuf; @@ -74,10 +80,14 @@ use std::thread; use std::time::Duration; use std::time::Instant; use tokio::select; +use tokio::sync::Mutex; use tokio::sync::broadcast; +use tokio::sync::mpsc; +use tokio::sync::mpsc::error::TryRecvError; use tokio::sync::mpsc::unbounded_channel; const EXTERNAL_EDITOR_HINT: &str = "Save and close external editor to continue."; +const THREAD_EVENT_CHANNEL_CAPACITY: usize = 1024; #[derive(Debug, Clone)] pub struct AppExitInfo { @@ -168,6 +178,117 @@ struct SessionSummary { resume_command: Option, } +#[derive(Debug, Clone)] +struct ThreadEventSnapshot { + session_configured: Option, + events: Vec, +} + +#[derive(Debug)] +struct ThreadEventStore { + session_configured: Option, + buffer: VecDeque, + user_message_ids: HashSet, + capacity: usize, + active: bool, +} + +impl ThreadEventStore { + fn new(capacity: usize) -> Self { + Self { + session_configured: None, + buffer: VecDeque::new(), + user_message_ids: HashSet::new(), + capacity, + active: false, + } + } + + fn new_with_session_configured(capacity: usize, event: Event) -> Self { + let mut store = Self::new(capacity); + store.session_configured = Some(event); + store + } + + fn push_event(&mut self, event: Event) { + match &event.msg { + EventMsg::SessionConfigured(_) => { + self.session_configured = Some(event); + return; + } + EventMsg::ItemCompleted(completed) => { + if let TurnItem::UserMessage(item) = &completed.item { + if !event.id.is_empty() && self.user_message_ids.contains(&event.id) { + return; + } + let legacy = Event { + id: event.id, + msg: item.as_legacy_event(), + }; + self.push_legacy_event(legacy); + return; + } + } + _ => {} + } + + self.push_legacy_event(event); + } + + fn push_legacy_event(&mut self, event: Event) { + if let EventMsg::UserMessage(_) = &event.msg + && !event.id.is_empty() + && !self.user_message_ids.insert(event.id.clone()) + { + return; + } + self.buffer.push_back(event); + if self.buffer.len() > self.capacity + && let Some(removed) = self.buffer.pop_front() + && matches!(removed.msg, EventMsg::UserMessage(_)) + && !removed.id.is_empty() + { + self.user_message_ids.remove(&removed.id); + } + } + + fn snapshot(&self) -> ThreadEventSnapshot { + ThreadEventSnapshot { + session_configured: self.session_configured.clone(), + events: self.buffer.iter().cloned().collect(), + } + } +} + +#[derive(Debug)] +struct ThreadEventChannel { + sender: mpsc::Sender, + receiver: Option>, + store: Arc>, +} + +impl ThreadEventChannel { + fn new(capacity: usize) -> Self { + let (sender, receiver) = mpsc::channel(capacity); + Self { + sender, + receiver: Some(receiver), + store: Arc::new(Mutex::new(ThreadEventStore::new(capacity))), + } + } + + fn new_with_session_configured(capacity: usize, event: Event) -> Self { + let (sender, receiver) = mpsc::channel(capacity); + Self { + sender, + receiver: Some(receiver), + store: Arc::new(Mutex::new(ThreadEventStore::new_with_session_configured( + capacity, event, + ))), + } + } +} + fn should_show_model_migration_prompt( current_model: &str, target_model: &str, @@ -368,12 +489,12 @@ pub(crate) struct App { windows_sandbox: WindowsSandboxState, - // TODO(jif) drop once new UX is here. - // Track external agent approvals spawned via AgentControl. - /// Map routed approval IDs to their originating external threads and original IDs. - external_approval_routes: HashMap, - /// Buffered Codex events while external approvals are pending. - paused_codex_events: VecDeque, + thread_event_channels: HashMap, + active_thread_id: Option, + active_thread_rx: Option>, + primary_thread_id: Option, + primary_session_configured: Option, + pending_primary_events: VecDeque, } #[derive(Default)] @@ -415,6 +536,248 @@ impl App { } } + fn ensure_thread_channel(&mut self, thread_id: ThreadId) -> &mut ThreadEventChannel { + self.thread_event_channels + .entry(thread_id) + .or_insert_with(|| ThreadEventChannel::new(THREAD_EVENT_CHANNEL_CAPACITY)) + } + + async fn set_thread_active(&mut self, thread_id: ThreadId, active: bool) { + if let Some(channel) = self.thread_event_channels.get_mut(&thread_id) { + let mut store = channel.store.lock().await; + store.active = active; + } + } + + async fn activate_thread_channel(&mut self, thread_id: ThreadId) { + if self.active_thread_id.is_some() { + return; + } + self.set_thread_active(thread_id, true).await; + let receiver = if let Some(channel) = self.thread_event_channels.get_mut(&thread_id) { + channel.receiver.take() + } else { + None + }; + self.active_thread_id = Some(thread_id); + self.active_thread_rx = receiver; + } + + async fn store_active_thread_receiver(&mut self) { + let Some(active_id) = self.active_thread_id else { + return; + }; + let Some(receiver) = self.active_thread_rx.take() else { + return; + }; + if let Some(channel) = self.thread_event_channels.get_mut(&active_id) { + let mut store = channel.store.lock().await; + store.active = false; + channel.receiver = Some(receiver); + } + } + + async fn activate_thread_for_replay( + &mut self, + thread_id: ThreadId, + ) -> Option<(mpsc::Receiver, ThreadEventSnapshot)> { + let channel = self.thread_event_channels.get_mut(&thread_id)?; + let receiver = channel.receiver.take()?; + let mut store = channel.store.lock().await; + store.active = true; + let snapshot = store.snapshot(); + Some((receiver, snapshot)) + } + + async fn clear_active_thread(&mut self) { + if let Some(active_id) = self.active_thread_id.take() { + self.set_thread_active(active_id, false).await; + } + self.active_thread_rx = None; + } + + async fn enqueue_thread_event(&mut self, thread_id: ThreadId, event: Event) -> Result<()> { + let (sender, store) = { + let channel = self.ensure_thread_channel(thread_id); + (channel.sender.clone(), Arc::clone(&channel.store)) + }; + + let should_send = { + let mut guard = store.lock().await; + guard.push_event(event.clone()); + guard.active + }; + + if should_send && let Err(err) = sender.send(event).await { + tracing::warn!("thread {thread_id} event channel closed: {err}"); + } + Ok(()) + } + + async fn enqueue_primary_event(&mut self, event: Event) -> Result<()> { + if let Some(thread_id) = self.primary_thread_id { + return self.enqueue_thread_event(thread_id, event).await; + } + + if let EventMsg::SessionConfigured(session) = &event.msg { + let thread_id = session.session_id; + self.primary_thread_id = Some(thread_id); + self.primary_session_configured = Some(session.clone()); + self.ensure_thread_channel(thread_id); + self.activate_thread_channel(thread_id).await; + + let pending = std::mem::take(&mut self.pending_primary_events); + for pending_event in pending { + self.enqueue_thread_event(thread_id, pending_event).await?; + } + self.enqueue_thread_event(thread_id, event).await?; + } else { + self.pending_primary_events.push_back(event); + } + Ok(()) + } + + fn open_agent_picker(&mut self) { + if self.thread_event_channels.is_empty() { + self.chat_widget + .add_info_message("No agents available yet.".to_string(), None); + return; + } + + let mut thread_ids: Vec = self.thread_event_channels.keys().cloned().collect(); + thread_ids.sort_by_key(ToString::to_string); + + let mut initial_selected_idx = None; + let items: Vec = thread_ids + .iter() + .enumerate() + .map(|(idx, thread_id)| { + if self.active_thread_id == Some(*thread_id) { + initial_selected_idx = Some(idx); + } + let id = *thread_id; + SelectionItem { + name: thread_id.to_string(), + is_current: self.active_thread_id == Some(*thread_id), + actions: vec![Box::new(move |tx| { + tx.send(AppEvent::SelectAgentThread(id)); + })], + dismiss_on_select: true, + search_value: Some(thread_id.to_string()), + ..Default::default() + } + }) + .collect(); + + self.chat_widget.show_selection_view(SelectionViewParams { + title: Some("Agents".to_string()), + subtitle: Some("Select a thread to focus".to_string()), + footer_hint: Some(standard_popup_hint_line()), + items, + initial_selected_idx, + ..Default::default() + }); + } + + async fn select_agent_thread(&mut self, tui: &mut tui::Tui, thread_id: ThreadId) -> Result<()> { + if self.active_thread_id == Some(thread_id) { + return Ok(()); + } + + let thread = match self.server.get_thread(thread_id).await { + Ok(thread) => thread, + Err(err) => { + self.chat_widget.add_error_message(format!( + "Failed to attach to agent thread {thread_id}: {err}" + )); + return Ok(()); + } + }; + + let previous_thread_id = self.active_thread_id; + self.store_active_thread_receiver().await; + self.active_thread_id = None; + let Some((receiver, snapshot)) = self.activate_thread_for_replay(thread_id).await else { + self.chat_widget + .add_error_message(format!("Agent thread {thread_id} is already active.")); + if let Some(previous_thread_id) = previous_thread_id { + self.activate_thread_channel(previous_thread_id).await; + } + return Ok(()); + }; + + self.active_thread_id = Some(thread_id); + self.active_thread_rx = Some(receiver); + + let init = self.chatwidget_init_for_forked_or_resumed_thread(tui, self.config.clone()); + let codex_op_tx = crate::chatwidget::spawn_op_forwarder(thread); + self.chat_widget = ChatWidget::new_with_op_sender(init, codex_op_tx); + + self.reset_for_thread_switch(tui)?; + self.replay_thread_snapshot(snapshot); + self.drain_active_thread_events(tui).await?; + + Ok(()) + } + + fn reset_for_thread_switch(&mut self, tui: &mut tui::Tui) -> Result<()> { + self.overlay = None; + self.transcript_cells.clear(); + self.deferred_history_lines.clear(); + self.has_emitted_history_lines = false; + self.backtrack = BacktrackState::default(); + self.backtrack_render_pending = false; + tui.terminal.clear_scrollback()?; + tui.terminal.clear()?; + Ok(()) + } + + fn reset_thread_event_state(&mut self) { + self.thread_event_channels.clear(); + self.active_thread_id = None; + self.active_thread_rx = None; + self.primary_thread_id = None; + self.pending_primary_events.clear(); + } + + async fn drain_active_thread_events(&mut self, tui: &mut tui::Tui) -> Result<()> { + let Some(mut rx) = self.active_thread_rx.take() else { + return Ok(()); + }; + + let mut disconnected = false; + loop { + match rx.try_recv() { + Ok(event) => self.handle_codex_event_now(event), + Err(TryRecvError::Empty) => break, + Err(TryRecvError::Disconnected) => { + disconnected = true; + break; + } + } + } + + if !disconnected { + self.active_thread_rx = Some(rx); + } else { + self.clear_active_thread().await; + } + + if self.backtrack_render_pending { + tui.frame_requester().schedule_frame(); + } + Ok(()) + } + + fn replay_thread_snapshot(&mut self, snapshot: ThreadEventSnapshot) { + if let Some(event) = snapshot.session_configured { + self.handle_codex_event_replay(event); + } + for event in snapshot.events { + self.handle_codex_event_replay(event); + } + } + #[allow(clippy::too_many_arguments)] pub async fn run( tui: &mut tui::Tui, @@ -587,8 +950,12 @@ impl App { pending_update_action: None, suppress_shutdown_complete: false, windows_sandbox: WindowsSandboxState::default(), - external_approval_routes: HashMap::new(), - paused_codex_events: VecDeque::new(), + thread_event_channels: HashMap::new(), + active_thread_id: None, + active_thread_rx: None, + primary_thread_id: None, + primary_session_configured: None, + pending_primary_events: VecDeque::new(), }; // On startup, if Agent mode (workspace-write) or ReadOnly is active, warn about world-writable dirs on Windows. @@ -649,6 +1016,20 @@ impl App { Some(event) = app_event_rx.recv() => { app.handle_event(tui, event).await? } + active = async { + if let Some(rx) = app.active_thread_rx.as_mut() { + rx.recv().await + } else { + None + } + }, if app.active_thread_rx.is_some() => { + if let Some(event) = active { + app.handle_active_thread_event(tui, event)?; + } else { + app.clear_active_thread().await; + } + AppRunControl::Continue + } Some(event) = tui_events.next() => { app.handle_tui_event(tui, event).await? } @@ -759,6 +1140,7 @@ impl App { otel_manager: self.otel_manager.clone(), }; self.chat_widget = ChatWidget::new(init, self.server.clone()); + self.reset_thread_event_state(); if let Some(summary) = summary { let mut lines: Vec> = vec![summary.usage_line.clone().into()]; if let Some(command) = summary.resume_command { @@ -803,6 +1185,7 @@ impl App { resumed.thread, resumed.session_configured, ); + self.reset_thread_event_state(); if let Some(summary) = summary { let mut lines: Vec> = vec![summary.usage_line.clone().into()]; @@ -852,6 +1235,7 @@ impl App { forked.thread, forked.session_configured, ); + self.reset_thread_event_state(); if let Some(summary) = summary { let mut lines: Vec> = vec![summary.usage_line.clone().into()]; @@ -928,18 +1312,7 @@ impl App { self.chat_widget.on_commit_tick(); } AppEvent::CodexEvent(event) => { - if !self.external_approval_routes.is_empty() { - // Store the events while the approval is pending. - self.paused_codex_events.push_back(event); - return Ok(AppRunControl::Continue); - } - self.handle_codex_event_now(event); - if self.backtrack_render_pending { - tui.frame_requester().schedule_frame(); - } - } - AppEvent::ExternalApprovalRequest { thread_id, event } => { - self.handle_external_approval_request(thread_id, event); + self.enqueue_primary_event(event).await?; } AppEvent::Exit(mode) => match mode { ExitMode::ShutdownFirst => self.chat_widget.submit_op(Op::Shutdown), @@ -950,72 +1323,9 @@ impl App { AppEvent::FatalExitRequest(message) => { return Ok(AppRunControl::Exit(ExitReason::Fatal(message))); } - AppEvent::CodexOp(op) => match op { - // Catch potential approvals coming from an external thread and treat them - // directly. This support both command and patch approval. In such case - // the approval get transferred to the corresponding thread and the external - // approval map (`external_approval_routes`) is updated. - Op::ExecApproval { id, decision } => { - if let Some((thread_id, original_id)) = - self.external_approval_routes.remove(&id) - { - // Approval of a sub-agent. - self.forward_external_op( - thread_id, - Op::ExecApproval { - id: original_id, - decision, - }, - ) - .await; - self.finish_external_approval(); - } else { - // This is an approval but not external. - self.chat_widget - .submit_op(Op::ExecApproval { id, decision }); - } - } - Op::PatchApproval { id, decision } => { - if let Some((thread_id, original_id)) = - self.external_approval_routes.remove(&id) - { - // Approval of a sub-agent. - self.forward_external_op( - thread_id, - Op::PatchApproval { - id: original_id, - decision, - }, - ) - .await; - self.finish_external_approval(); - } else { - // This is an approval but not external. - self.chat_widget - .submit_op(Op::PatchApproval { id, decision }); - } - } - Op::UserInputAnswer { id, response } => { - if let Some((thread_id, original_id)) = - self.external_approval_routes.remove(&id) - { - self.forward_external_op( - thread_id, - Op::UserInputAnswer { - id: original_id, - response, - }, - ) - .await; - self.finish_external_approval(); - } else { - self.chat_widget - .submit_op(Op::UserInputAnswer { id, response }); - } - } - // Standard path where this is not an external approval response. - _ => self.chat_widget.submit_op(op), - }, + AppEvent::CodexOp(op) => { + self.chat_widget.submit_op(op); + } AppEvent::DiffResult(text) => { // Clear the in-progress state in the bottom pane self.chat_widget.on_diff_complete(); @@ -1481,6 +1791,12 @@ impl App { AppEvent::OpenApprovalsPopup => { self.chat_widget.open_approvals_popup(); } + AppEvent::OpenAgentPicker => { + self.open_agent_picker(); + } + AppEvent::SelectAgentThread(thread_id) => { + self.select_agent_thread(tui, thread_id).await?; + } AppEvent::OpenSkillsList => { self.chat_widget.open_skills_list(); } @@ -1584,56 +1900,23 @@ impl App { self.chat_widget.handle_codex_event(event); } - /// Routes external approval request events through the chat widget by - /// rewriting the event id to include the originating thread. - /// - /// `thread_id` is the external thread that issued the approval request. - /// `event` is the approval request event whose id is rewritten so replies - /// can be routed back to the correct thread. - fn handle_external_approval_request(&mut self, thread_id: ThreadId, mut event: Event) { - match &mut event.msg { - EventMsg::RequestUserInput(ev) => { - let original_id = ev.turn_id.clone(); - let routing_id = format!("{thread_id}:{original_id}"); - self.external_approval_routes - .insert(routing_id.clone(), (thread_id, original_id)); - ev.turn_id = routing_id.clone(); - event.id = routing_id; - } - EventMsg::ExecApprovalRequest(_) | EventMsg::ApplyPatchApprovalRequest(_) => { - let original_id = event.id.clone(); - let routing_id = format!("{thread_id}:{original_id}"); - self.external_approval_routes - .insert(routing_id.clone(), (thread_id, original_id)); - event.id = routing_id; - } - _ => return, - } - self.chat_widget.handle_codex_event(event); + fn handle_codex_event_replay(&mut self, event: Event) { + self.handle_backtrack_event(&event.msg); + self.chat_widget.handle_codex_event_replay(event); } - async fn forward_external_op(&self, thread_id: ThreadId, op: Op) { - let thread = match self.server.get_thread(thread_id).await { - Ok(thread) => thread, - Err(err) => { - tracing::warn!("failed to find thread {thread_id} for approval response: {err}"); - return; - } - }; - if let Err(err) = thread.submit(op).await { - tracing::warn!("failed to submit approval response to thread {thread_id}: {err}"); - } - } - - fn finish_external_approval(&mut self) { - if self.external_approval_routes.is_empty() { - while let Some(event) = self.paused_codex_events.pop_front() { - self.handle_codex_event_now(event); - } + fn handle_active_thread_event(&mut self, tui: &mut tui::Tui, event: Event) -> Result<()> { + self.handle_codex_event_now(event); + if self.backtrack_render_pending { + tui.frame_requester().schedule_frame(); } + Ok(()) } async fn handle_thread_created(&mut self, thread_id: ThreadId) -> Result<()> { + if self.thread_event_channels.contains_key(&thread_id) { + return Ok(()); + } let thread = match self.server.get_thread(thread_id).await { Ok(thread) => thread, Err(err) => { @@ -1641,7 +1924,15 @@ impl App { return Ok(()); } }; - let app_event_tx = self.app_event_tx.clone(); + let event = Event { + id: String::new(), + msg: EventMsg::SessionConfigured(self.session_configured_for_thread(thread_id)), + }; + let channel = + ThreadEventChannel::new_with_session_configured(THREAD_EVENT_CHANNEL_CAPACITY, event); + let sender = channel.sender.clone(); + let store = Arc::clone(&channel.store); + self.thread_event_channels.insert(thread_id, channel); tokio::spawn(async move { loop { let event = match thread.next_event().await { @@ -1651,19 +1942,47 @@ impl App { break; } }; - match event.msg { - EventMsg::ExecApprovalRequest(_) - | EventMsg::ApplyPatchApprovalRequest(_) - | EventMsg::RequestUserInput(_) => { - app_event_tx.send(AppEvent::ExternalApprovalRequest { thread_id, event }); - } - _ => {} + let should_send = { + let mut guard = store.lock().await; + guard.push_event(event.clone()); + guard.active + }; + if should_send && let Err(err) = sender.send(event).await { + tracing::debug!("external thread {thread_id} channel closed: {err}"); + break; } } }); Ok(()) } + fn session_configured_for_thread(&self, thread_id: ThreadId) -> SessionConfiguredEvent { + let mut session_configured = + self.primary_session_configured + .clone() + .unwrap_or_else(|| SessionConfiguredEvent { + session_id: thread_id, + forked_from_id: None, + model: self.chat_widget.current_model().to_string(), + model_provider_id: self.config.model_provider_id.clone(), + approval_policy: *self.config.approval_policy.get(), + sandbox_policy: self.config.sandbox_policy.get().clone(), + cwd: self.config.cwd.clone(), + reasoning_effort: None, + history_log_id: 0, + history_entry_count: 0, + initial_messages: None, + rollout_path: PathBuf::new(), + }); + session_configured.session_id = thread_id; + session_configured.forked_from_id = None; + session_configured.history_log_id = 0; + session_configured.history_entry_count = 0; + session_configured.initial_messages = None; + session_configured.rollout_path = PathBuf::new(); + session_configured + } + fn reasoning_label(reasoning_effort: Option) -> &'static str { match reasoning_effort { Some(ReasoningEffortConfig::Minimal) => "minimal", @@ -1926,8 +2245,12 @@ mod tests { pending_update_action: None, suppress_shutdown_complete: false, windows_sandbox: WindowsSandboxState::default(), - external_approval_routes: HashMap::new(), - paused_codex_events: VecDeque::new(), + thread_event_channels: HashMap::new(), + active_thread_id: None, + active_thread_rx: None, + primary_thread_id: None, + primary_session_configured: None, + pending_primary_events: VecDeque::new(), } } @@ -1970,8 +2293,12 @@ mod tests { pending_update_action: None, suppress_shutdown_complete: false, windows_sandbox: WindowsSandboxState::default(), - external_approval_routes: HashMap::new(), - paused_codex_events: VecDeque::new(), + thread_event_channels: HashMap::new(), + active_thread_id: None, + active_thread_rx: None, + primary_thread_id: None, + primary_session_configured: None, + pending_primary_events: VecDeque::new(), }, rx, op_rx, diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs index b74899795c..f6ed6e8abc 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -43,10 +43,10 @@ pub(crate) enum WindowsSandboxFallbackReason { #[derive(Debug)] pub(crate) enum AppEvent { CodexEvent(Event), - ExternalApprovalRequest { - thread_id: ThreadId, - event: Event, - }, + /// Open the agent picker for switching active threads. + OpenAgentPicker, + /// Switch the active thread to the selected agent. + SelectAgentThread(ThreadId), /// Start a new session. NewSession, diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 93158c41cc..5a96e4e381 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -178,6 +178,7 @@ use self::interrupts::InterruptManager; mod agent; use self::agent::spawn_agent; use self::agent::spawn_agent_from_existing; +pub(crate) use self::agent::spawn_op_forwarder; mod session_header; use self::session_header::SessionHeader; mod skills; @@ -2030,6 +2031,127 @@ impl ChatWidget { widget } + pub(crate) fn new_with_op_sender( + common: ChatWidgetInit, + codex_op_tx: UnboundedSender, + ) -> Self { + let ChatWidgetInit { + config, + frame_requester, + app_event_tx, + initial_user_message, + enhanced_keys_supported, + auth_manager, + models_manager, + feedback, + is_first_run, + model, + otel_manager, + } = common; + let model = model.filter(|m| !m.trim().is_empty()); + let mut config = config; + config.model = model.clone(); + let mut rng = rand::rng(); + let placeholder = PLACEHOLDERS[rng.random_range(0..PLACEHOLDERS.len())].to_string(); + + let model_for_header = model.unwrap_or_else(|| DEFAULT_MODEL_DISPLAY_NAME.to_string()); + let fallback_custom = Settings { + model: model_for_header.clone(), + reasoning_effort: None, + developer_instructions: None, + }; + let stored_collaboration_mode = if config.features.enabled(Feature::CollaborationModes) { + initial_collaboration_mode( + models_manager.as_ref(), + fallback_custom, + config.experimental_mode, + ) + } else { + CollaborationMode::Custom(fallback_custom) + }; + + let active_cell = Some(Self::placeholder_session_header_cell( + &config, + config.features.enabled(Feature::CollaborationModes), + stored_collaboration_mode.clone(), + )); + + let mut widget = Self { + app_event_tx: app_event_tx.clone(), + frame_requester: frame_requester.clone(), + codex_op_tx, + bottom_pane: BottomPane::new(BottomPaneParams { + frame_requester, + app_event_tx, + has_input_focus: true, + enhanced_keys_supported, + placeholder_text: placeholder, + disable_paste_burst: config.disable_paste_burst, + animations_enabled: config.animations, + skills: None, + }), + active_cell, + active_cell_revision: 0, + config, + skills_all: Vec::new(), + skills_initial_state: None, + stored_collaboration_mode, + auth_manager, + models_manager, + otel_manager, + session_header: SessionHeader::new(model_for_header), + initial_user_message, + token_info: None, + rate_limit_snapshot: None, + plan_type: None, + rate_limit_warnings: RateLimitWarningState::default(), + rate_limit_switch_prompt: RateLimitSwitchPromptState::default(), + rate_limit_poller: None, + stream_controller: None, + running_commands: HashMap::new(), + suppressed_exec_calls: HashSet::new(), + last_unified_wait: None, + unified_exec_wait_streak: None, + task_complete_pending: false, + unified_exec_processes: Vec::new(), + agent_turn_running: false, + mcp_startup_status: None, + interrupts: InterruptManager::new(), + reasoning_buffer: String::new(), + full_reasoning_buffer: String::new(), + current_status_header: String::from("Working"), + retry_status_header: None, + thread_id: None, + forked_from: None, + saw_plan_update_this_turn: false, + queued_user_messages: VecDeque::new(), + show_welcome_banner: is_first_run, + suppress_session_configured_redraw: false, + pending_notification: None, + quit_shortcut_expires_at: None, + quit_shortcut_key: None, + is_review_mode: false, + pre_review_token_info: None, + needs_final_message_separator: false, + had_work_activity: false, + last_separator_elapsed_secs: None, + last_rendered_width: std::cell::Cell::new(None), + feedback, + current_rollout_path: None, + external_editor_state: ExternalEditorState::Closed, + }; + + widget.prefetch_rate_limits(); + widget + .bottom_pane + .set_steer_enabled(widget.config.features.enabled(Feature::Steer)); + widget.bottom_pane.set_collaboration_modes_enabled( + widget.config.features.enabled(Feature::CollaborationModes), + ); + + widget + } + /// Create a ChatWidget attached to an existing conversation (e.g., a fork). pub(crate) fn new_from_existing( common: ChatWidgetInit, @@ -2316,6 +2438,11 @@ impl ChatWidget { self.bottom_pane.set_footer_hint_override(items); } + pub(crate) fn show_selection_view(&mut self, params: SelectionViewParams) { + self.bottom_pane.show_selection_view(params); + self.request_redraw(); + } + pub(crate) fn can_launch_external_editor(&self) -> bool { self.bottom_pane.can_launch_external_editor() } @@ -2380,6 +2507,9 @@ impl ChatWidget { self.open_collaboration_modes_popup(); } } + SlashCommand::Agent => { + self.app_event_tx.send(AppEvent::OpenAgentPicker); + } SlashCommand::Approvals => { self.open_approvals_popup(); } @@ -2748,6 +2878,14 @@ impl ChatWidget { self.dispatch_event_msg(Some(id), msg, false); } + pub(crate) fn handle_codex_event_replay(&mut self, event: Event) { + let Event { msg, .. } = event; + if matches!(msg, EventMsg::ShutdownComplete) { + return; + } + self.dispatch_event_msg(None, msg, true); + } + /// Dispatch a protocol `EventMsg` to the appropriate handler. /// /// `id` is `Some` for live events and `None` for replayed events from diff --git a/codex-rs/tui/src/chatwidget/agent.rs b/codex-rs/tui/src/chatwidget/agent.rs index b24233c2af..c902e5c638 100644 --- a/codex-rs/tui/src/chatwidget/agent.rs +++ b/codex-rs/tui/src/chatwidget/agent.rs @@ -105,3 +105,18 @@ pub(crate) fn spawn_agent_from_existing( codex_op_tx } + +/// Spawn an op-forwarding loop for an existing thread without subscribing to events. +pub(crate) fn spawn_op_forwarder(thread: std::sync::Arc) -> UnboundedSender { + let (codex_op_tx, mut codex_op_rx) = unbounded_channel::(); + + tokio::spawn(async move { + while let Some(op) = codex_op_rx.recv().await { + if let Err(e) = thread.submit(op).await { + tracing::error!("failed to submit op: {e}"); + } + } + }); + + codex_op_tx +} diff --git a/codex-rs/tui/src/custom_terminal.rs b/codex-rs/tui/src/custom_terminal.rs index 46d16a83f0..da2f6d5f2e 100644 --- a/codex-rs/tui/src/custom_terminal.rs +++ b/codex-rs/tui/src/custom_terminal.rs @@ -381,6 +381,19 @@ where Ok(()) } + /// Clear terminal scrollback (if supported) and force a full redraw. + pub fn clear_scrollback(&mut self) -> io::Result<()> { + if self.viewport_area.is_empty() { + return Ok(()); + } + self.backend + .set_cursor_position(self.viewport_area.as_position())?; + queue!(self.backend, Clear(crossterm::terminal::ClearType::Purge))?; + std::io::Write::flush(&mut self.backend)?; + self.previous_buffer_mut().reset(); + Ok(()) + } + /// Clears the inactive buffer and swaps it with the current buffer pub fn swap_buffers(&mut self) { self.previous_buffer_mut().reset(); diff --git a/codex-rs/tui/src/slash_command.rs b/codex-rs/tui/src/slash_command.rs index f825f32fa8..2562d5e689 100644 --- a/codex-rs/tui/src/slash_command.rs +++ b/codex-rs/tui/src/slash_command.rs @@ -26,6 +26,7 @@ pub enum SlashCommand { Init, Compact, Collab, + Agent, // Undo, Diff, Mention, @@ -60,6 +61,7 @@ impl SlashCommand { SlashCommand::Ps => "list background terminals", SlashCommand::Model => "choose what model and reasoning effort to use", SlashCommand::Collab => "change collaboration mode (experimental)", + SlashCommand::Agent => "switch the active agent thread", SlashCommand::Approvals => "choose what Codex can do without approval", SlashCommand::Permissions => "choose what Codex is allowed to do", SlashCommand::ElevateSandbox => "set up elevated agent sandbox", @@ -105,6 +107,7 @@ impl SlashCommand { SlashCommand::Rollout => true, SlashCommand::TestApproval => true, SlashCommand::Collab => true, + SlashCommand::Agent => true, } } From 935d88b455ba51d7103df85305fcf3b418592751 Mon Sep 17 00:00:00 2001 From: charley-oai Date: Fri, 23 Jan 2026 10:18:19 -0800 Subject: [PATCH 04/36] Persist text element ranges and attached images across history/resume (#9116) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **Summary** - Backtrack selection now rehydrates `text_elements` and `local_image_paths` from the chosen user history cell so Esc‑Esc history edits preserve image placeholders and attachments. - Composer prefill uses the preserved elements/attachments in both `tui` and `tui2`. - Extended backtrack selection tests to cover image placeholder elements and local image paths. **Changes** - `tui/src/app_backtrack.rs`: Backtrack selection now carries text elements + local image paths; composer prefill uses them (removes TODO). - `tui2/src/app_backtrack.rs`: Same as above. - `tui/src/app.rs`: Updated backtrack test to assert restored elements/paths. - `tui2/src/app.rs`: Same test updates. ### The original scope of this PR (threading text elements and image attachments through the codex harness thoroughly/persistently) was broken into the following PRs other than this one: The diff of this PR was reduced by changing types in a starter PR: https://github.com/openai/codex/pull/9235 Then text element metadata was added to protocol, app server, and core in this PR: https://github.com/openai/codex/pull/9331 Then the end-to-end flow was completed by wiring TUI/TUI2 input, history, and restore behavior in https://github.com/openai/codex/pull/9393 Prompt expansion was supported in this PR: https://github.com/openai/codex/pull/9518 TextElement optional placeholder field was protected in https://github.com/openai/codex/pull/9545 --- codex-rs/tui/src/app.rs | 32 +++++++++++++----- codex-rs/tui/src/app_backtrack.rs | 33 ++++++++++++++----- codex-rs/tui/src/bottom_pane/chat_composer.rs | 14 +++++--- 3 files changed, 57 insertions(+), 22 deletions(-) diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index fa24e110f5..6b6b0188cc 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -2203,6 +2203,7 @@ mod tests { use codex_core::protocol::SessionSource; use codex_otel::OtelManager; use codex_protocol::ThreadId; + use codex_protocol::user_input::TextElement; use insta::assert_snapshot; use pretty_assertions::assert_eq; use ratatui::prelude::Line; @@ -2503,11 +2504,14 @@ mod tests { async fn backtrack_selection_with_duplicate_history_targets_unique_turn() { let (mut app, _app_event_rx, mut op_rx) = make_test_app_with_channels().await; - let user_cell = |text: &str| -> Arc { + let user_cell = |text: &str, + text_elements: Vec, + local_image_paths: Vec| + -> Arc { Arc::new(UserHistoryCell { message: text.to_string(), - text_elements: Vec::new(), - local_image_paths: Vec::new(), + text_elements, + local_image_paths, }) as Arc }; let agent_cell = |text: &str| -> Arc { @@ -2545,18 +2549,28 @@ mod tests { )) as Arc }; + let placeholder = "[Image #1]"; + let edited_text = format!("follow-up (edited) {placeholder}"); + let edited_range = edited_text.len().saturating_sub(placeholder.len())..edited_text.len(); + let edited_text_elements = vec![TextElement::new(edited_range.into(), None)]; + let edited_local_image_paths = vec![PathBuf::from("/tmp/fake-image.png")]; + // Simulate a transcript with duplicated history (e.g., from prior backtracks) // and an edited turn appended after a session header boundary. app.transcript_cells = vec![ make_header(true), - user_cell("first question"), + user_cell("first question", Vec::new(), Vec::new()), agent_cell("answer first"), - user_cell("follow-up"), + user_cell("follow-up", Vec::new(), Vec::new()), agent_cell("answer follow-up"), make_header(false), - user_cell("first question"), + user_cell("first question", Vec::new(), Vec::new()), agent_cell("answer first"), - user_cell("follow-up (edited)"), + user_cell( + &edited_text, + edited_text_elements.clone(), + edited_local_image_paths.clone(), + ), agent_cell("answer edited"), ]; @@ -2589,7 +2603,9 @@ mod tests { .confirm_backtrack_from_main() .expect("backtrack selection"); assert_eq!(selection.nth_user_message, 1); - assert_eq!(selection.prefill, "follow-up (edited)"); + assert_eq!(selection.prefill, edited_text); + assert_eq!(selection.text_elements, edited_text_elements); + assert_eq!(selection.local_image_paths, edited_local_image_paths); app.apply_backtrack_rollback(selection); diff --git a/codex-rs/tui/src/app_backtrack.rs b/codex-rs/tui/src/app_backtrack.rs index 9a77b6bed7..256355dd05 100644 --- a/codex-rs/tui/src/app_backtrack.rs +++ b/codex-rs/tui/src/app_backtrack.rs @@ -24,6 +24,7 @@ //! both committed history and in-flight activity without changing flush or coalescing behavior. use std::any::TypeId; +use std::path::PathBuf; use std::sync::Arc; use crate::app::App; @@ -37,6 +38,7 @@ use codex_core::protocol::ErrorEvent; use codex_core::protocol::EventMsg; use codex_core::protocol::Op; use codex_protocol::ThreadId; +use codex_protocol::user_input::TextElement; use color_eyre::eyre::Result; use crossterm::event::KeyCode; use crossterm::event::KeyEvent; @@ -78,6 +80,10 @@ pub(crate) struct BacktrackSelection { /// This is applied immediately on selection confirmation; if the rollback fails, the prefill /// remains as a convenience so the user can retry or edit. pub(crate) prefill: String, + /// Text elements associated with the selected user message. + pub(crate) text_elements: Vec, + /// Local image paths associated with the selected user message. + pub(crate) local_image_paths: Vec, } /// An in-flight rollback requested from core. @@ -198,16 +204,16 @@ impl App { } let prefill = selection.prefill.clone(); + let text_elements = selection.text_elements.clone(); + let local_image_paths = selection.local_image_paths.clone(); self.backtrack.pending_rollback = Some(PendingBacktrackRollback { selection, thread_id: self.chat_widget.thread_id(), }); self.chat_widget.submit_op(Op::ThreadRollback { num_turns }); - if !prefill.is_empty() { - // TODO: Rehydrate text_elements/local_image_paths from the selected user cell so - // backtrack preserves image placeholders and attachments. + if !prefill.is_empty() || !text_elements.is_empty() || !local_image_paths.is_empty() { self.chat_widget - .set_composer_text(prefill, Vec::new(), Vec::new()); + .set_composer_text(prefill, text_elements, local_image_paths); } } @@ -481,15 +487,24 @@ impl App { return None; } - let prefill = nth_user_position(&self.transcript_cells, nth_user_message) - .and_then(|idx| self.transcript_cells.get(idx)) - .and_then(|cell| cell.as_any().downcast_ref::()) - .map(|c| c.message.clone()) - .unwrap_or_default(); + let (prefill, text_elements, local_image_paths) = + nth_user_position(&self.transcript_cells, nth_user_message) + .and_then(|idx| self.transcript_cells.get(idx)) + .and_then(|cell| cell.as_any().downcast_ref::()) + .map(|cell| { + ( + cell.message.clone(), + cell.text_elements.clone(), + cell.local_image_paths.clone(), + ) + }) + .unwrap_or_else(|| (String::new(), Vec::new(), Vec::new())); Some(BacktrackSelection { nth_user_message, prefill, + text_elements, + local_image_paths, }) } diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index 06c2808f2a..47c2c46251 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -212,6 +212,7 @@ pub(crate) struct ChatComposer { pending_pastes: Vec<(String, String)>, large_paste_counters: HashMap, has_focus: bool, + /// Invariant: attached images are labeled `[Image #1]..[Image #N]` in vec order. attached_images: Vec, placeholder_text: String, is_task_running: bool, @@ -475,8 +476,9 @@ impl ChatComposer { /// Replace the composer content with text from an external editor. /// Clears pending paste placeholders and keeps only attachments whose - /// placeholder labels still appear in the new text. Cursor is placed at - /// the end after rebuilding elements. + /// placeholder labels still appear in the new text. Image placeholders + /// are renumbered to `[Image #1]..[Image #N]`. Cursor is placed at the end + /// after rebuilding elements. pub(crate) fn apply_external_edit(&mut self, text: String) { self.pending_pastes.clear(); @@ -538,6 +540,8 @@ impl ChatComposer { self.textarea.insert_str(&text[idx..]); } + // Keep image placeholders normalized to [Image #1].. in attachment order. + self.relabel_attached_images_and_update_placeholders(); self.textarea.set_cursor(self.textarea.text().len()); self.sync_popups(); } @@ -6081,7 +6085,7 @@ mod tests { false, ); - let placeholder = "[image 10x10]".to_string(); + let placeholder = local_image_label_text(1); composer.textarea.insert_element(&placeholder); composer.attached_images.push(AttachedImage { placeholder: placeholder.clone(), @@ -6115,7 +6119,7 @@ mod tests { false, ); - let placeholder = "[image 10x10]".to_string(); + let placeholder = local_image_label_text(1); composer.textarea.insert_element(&placeholder); composer.attached_images.push(AttachedImage { placeholder: placeholder.clone(), @@ -6165,7 +6169,7 @@ mod tests { false, ); - let placeholder = "[image 10x10]".to_string(); + let placeholder = local_image_label_text(1); composer.textarea.insert_element(&placeholder); composer.attached_images.push(AttachedImage { placeholder: placeholder.clone(), From f353d3d695260543aa85af508303345bb06ed159 Mon Sep 17 00:00:00 2001 From: Ahmed Ibrahim Date: Fri, 23 Jan 2026 11:24:48 -0800 Subject: [PATCH 05/36] prompt (#9777) # External (non-OpenAI) Pull Request Requirements Before opening this Pull Request, please read the dedicated "Contributing" markdown file or your PR may be closed: https://github.com/openai/codex/blob/main/docs/contributing.md If your PR conforms to our contribution guidelines, replace this text with a detailed and high quality description of your changes. Include a link to a bug report or enhancement request. --- .../core/templates/collaboration_mode/plan.md | 104 +++++++++++------- 1 file changed, 65 insertions(+), 39 deletions(-) diff --git a/codex-rs/core/templates/collaboration_mode/plan.md b/codex-rs/core/templates/collaboration_mode/plan.md index 32778d4408..7d78645de2 100644 --- a/codex-rs/core/templates/collaboration_mode/plan.md +++ b/codex-rs/core/templates/collaboration_mode/plan.md @@ -2,7 +2,7 @@ You work in **two phases**: -- **PHASE 1 — Understand user intent**: Align on what the user is trying to accomplish and what “success” means. Focus on intent, scope, constraints, and context. +- **PHASE 1 — Understand user intent**: Align on what the user is trying to accomplish and what “success” means. Focus on intent, scope, constraints, and preference tradeoffs. - **PHASE 2 — Technical spec & implementation plan**: Convert the intent into a decision‑complete technical spec and an implementation plan detailed enough that another agent could execute with minimal follow‑ups. --- @@ -21,11 +21,24 @@ Constraints: --- -## Evidence‑first exploration (precondition to asking) +## Two types of uncertainty (treat differently) -When a repo / codebase / workspace is available (or implied), you MUST attempt to resolve “where/how is X defined?” and other discoverable questions by **exploring first**. +### Type 1 — Discoverable facts (repo/system truth) +Examples: “Where is app‑server 2 defined?”, “Which config sets turn duration?”, “Which service emits this metric?” -Before calling `request_user_input`, do a quick investigation pass: +Rule: **Evidence-first exploration applies.** Don’t ask the user until you’ve searched. + +### Type 2 — Preferences & tradeoffs (product and engineering intent) + +Rule: **Ask early** These are often *not discoverable* and should not be silently assumed when multiple approaches are viable. + +--- + +## Evidence‑first exploration (precondition to asking discoverable questions) + +When a repo / codebase / workspace is available (or implied), you MUST attempt to resolve discoverable questions by **exploring first**. + +Before calling `request_user_input` for a discoverable fact, do a quick investigation pass: - Run at least **2 targeted searches** (exact match + a likely variant/synonym). - Check the most likely “source of truth” surfaces (service manifests, infra configs, env/config files, entrypoints, schemas/types/constants). @@ -42,7 +55,23 @@ If you must ask, incorporate what you already found: --- -## No‑trivia rule for questions +## Preference capture (you SHOULD ask when it changes the plan) + +If there are **multiple reasonable implementation approaches** with meaningful tradeoffs, you SHOULD ask the user to choose their preference even if you could assume a default. + +Treat tradeoff choice as **high-impact** unless the user explicitly said: +- “Use your best judgement,” or +- “Pick whatever is simplest,” or +- “I don’t care—ship fast.” + +When asking a preference question: +- Provide **2–4 mutually exclusive options**. +- Include a **recommended default** that matches the user’s apparent goals. +- If the user doesn’t answer, proceed with the recommended option and record it as an assumption. + +--- + +## No‑trivia rule for questions (guardrail) You MUST NOT ask questions whose answers are likely to be found by: - repo text search, @@ -52,32 +81,39 @@ unless you already attempted those and can summarize what you found. Every `request_user_input` question must: - materially change an implementation decision, OR -- disambiguate between **concrete candidates** you already found. +- disambiguate between **concrete candidates** you already found, OR +- capture a **preference/tradeoff** that is not discoverable from the repo. --- ## PHASE 1 — Understand user intent ### Purpose -Identify what the user actually wants, what matters most, and what constraints shape the solution. +Identify what the user actually wants, what matters most, and what constraints + preferences shape the solution. ### Phase 1 principles -- State what you think the user cares about. Reflect inferred priorities (speed vs quality, prototype vs production, etc.). +- State what you think the user cares about (speed vs quality, prototype vs production, etc.). - Think out loud briefly when it helps weigh tradeoffs. - Use reasonable suggestions with explicit assumptions; make it easy to accept/override. -- Ask fewer, better questions. Only ask what materially changes the spec/plan. +- Ask fewer, better questions. Ask only what materially changes the spec/plan OR captures a real tradeoff. - Think ahead: propose helpful suggestions the user may need (testing, debug mode, observability, migration path). ### Phase 1 exit criteria (Intent gate) Before moving to Phase 2, ensure you have either a **user answer** OR an **explicit assumption** for: + +**Intent basics** - Primary goal + success criteria (how we know it worked) - Primary user / audience - In-scope and out-of-scope - Constraints (time, budget, platform, security/compliance) - Current context (what exists today: code/system/data) -If any missing item materially changes the plan, ask via `request_user_input`. -If unknown but low-impact, assume a sensible default and proceed. +**Preference profile (don’t silently assume if unclear and high-impact)** +- Risk posture: prototype vs production quality bar +- Tradeoff priority: ship fast vs robust/maintainable +- Compatibility expectations: backward compatibility / migrations / downtime tolerance (if relevant) + +Use `request_user_input` to deeply understand the user's intent after exploring your environment. --- @@ -93,6 +129,7 @@ Before finalizing the plan, ensure you’ve pinned down (answer or assumption): - Data flow + key edge cases / failure modes - Testing + acceptance criteria - Rollout/monitoring expectations +- Any key preference/tradeoff decisions (and rationale) If something is high-impact and unknown, ask via `request_user_input`. Otherwise assume defaults and proceed. @@ -100,7 +137,10 @@ If something is high-impact and unknown, ask via `request_user_input`. Otherwise ## Using `request_user_input` in Plan Mode -Use `request_user_input` only when you are genuinely blocked on a decision that materially changes the plan AND the decision cannot be resolved via evidence-first workspace exploration. +Use `request_user_input` when either: +1) You are genuinely blocked on a decision that materially changes the plan and cannot be resolved via evidence-first exploration, OR +2) There is a meaningful **preference/tradeoff** the user should choose among. +3) When an answer is skipped, assume the recommended path. Rules: - **Default to options** when there are ≤ 4 common outcomes; include a **recommended** option. @@ -125,42 +165,28 @@ Do **not** use `request_user_input` to ask: "header": "Migrate", "question": "Enable the database migration in this release?", "options": [ - { - "label": "Yes (Recommended)", - "description": "Ship the migration with this rollout." - }, - { - "label": "No", - "description": "Defer the migration to a later release." - } + { "label": "Yes (Recommended)", "description": "Ship the migration with this rollout." }, + { "label": "No", "description": "Defer the migration to a later release." } ] } ] } ```` -**2) Choice with free-form** +**2) Preference/tradeoff question (recommended + options)** ```json { "questions": [ { - "id": "cache_strategy", - "header": "Cache", - "question": "Which cache strategy should we implement?", + "id": "tradeoff_priority", + "header": "Tradeoff", + "question": "Which priority should guide the implementation?", "options": [ - { - "label": "Write-through (Recommended)", - "description": "Simpler consistency with predictable latency." - }, - { - "label": "Write-back", - "description": "Lower write latency but higher complexity." - }, - { - "label": "Other", - "description": "Provide a custom strategy or constraints." - } + { "label": "Ship fast (Recommended)", "description": "Minimal changes, pragmatic shortcuts, faster delivery." }, + { "label": "Robust & maintainable", "description": "Cleaner abstractions, more refactor, better long-term stability." }, + { "label": "Performance-first", "description": "Optimize latency/throughput even if complexity rises." }, + { "label": "Other", "description": "Specify a different priority or constraint." } ] } ] @@ -173,9 +199,9 @@ Do **not** use `request_user_input` to ask: { "questions": [ { - "id": "rollout_constraints", - "header": "Rollout", - "question": "Any rollout constraints or compliance requirements we must follow?" + "id": "acceptance_criteria", + "header": "Success", + "question": "What are the acceptance criteria or success metrics we should optimize for?" } ] } From ae7d3e1b492c682c76fb96d8ebdf710348db1948 Mon Sep 17 00:00:00 2001 From: zerone0x Date: Sat, 24 Jan 2026 04:05:20 +0800 Subject: [PATCH 06/36] fix(exec): skip git repo check when --yolo flag is used (#9590) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Fixes #7522 The `--yolo` (`--dangerously-bypass-approvals-and-sandbox`) flag is documented to skip all confirmation prompts and execute commands without sandboxing, intended solely for running in environments that are externally sandboxed. However, it was not bypassing the trusted directory (git repo) check, requiring users to also specify `--skip-git-repo-check`. This change makes `--yolo` also skip the git repo check, matching the documented behavior and user expectations. ## Changes - Modified `codex-rs/exec/src/lib.rs` to check for `dangerously_bypass_approvals_and_sandbox` flag in addition to `skip_git_repo_check` when determining whether to skip the git repo check ## Testing - Verified the code compiles with `cargo check -p codex-exec` - Ran existing tests with `cargo test -p codex-exec` (34 passed, 8 integration tests failed due to unrelated API connectivity issues) --- 🤖 Generated with [Claude Code](https://claude.ai/code) Co-authored-by: Claude --- codex-rs/exec/src/lib.rs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/codex-rs/exec/src/lib.rs b/codex-rs/exec/src/lib.rs index d5fb0f6ed2..533b328e22 100644 --- a/codex-rs/exec/src/lib.rs +++ b/codex-rs/exec/src/lib.rs @@ -297,7 +297,12 @@ pub async fn run_main(cli: Cli, codex_linux_sandbox_exe: Option) -> any let default_effort = config.model_reasoning_effort; let default_summary = config.model_reasoning_summary; - if !skip_git_repo_check && get_git_repo_root(&default_cwd).is_none() { + // When --yolo (dangerously_bypass_approvals_and_sandbox) is set, also skip the git repo check + // since the user is explicitly running in an externally sandboxed environment. + if !skip_git_repo_check + && !dangerously_bypass_approvals_and_sandbox + && get_git_repo_root(&default_cwd).is_none() + { eprintln!("Not inside a trusted directory and --skip-git-repo-check was not specified."); std::process::exit(1); } From eca365cf8cfa6a2f68937b5a7ea13832feae6042 Mon Sep 17 00:00:00 2001 From: Salman Chishti <13schishti@gmail.com> Date: Fri, 23 Jan 2026 20:06:04 +0000 Subject: [PATCH 07/36] Upgrade GitHub Actions for Node 24 compatibility (#9722) ## Summary Upgrade GitHub Actions to their latest versions to ensure compatibility with Node 24, as Node 20 will reach end-of-life in April 2026. ## Changes | Action | Old Version(s) | New Version | Release | Files | |--------|---------------|-------------|---------|-------| | `actions/cache` | [`v4`](https://github.com/actions/cache/releases/tag/v4) | [`v5`](https://github.com/actions/cache/releases/tag/v5) | [Release](https://github.com/actions/cache/releases/tag/v5) | bazel.yml | ## Context Per [GitHub's announcement](https://github.blog/changelog/2025-09-19-deprecation-of-node-20-on-github-actions-runners/), Node 20 is being deprecated and runners will begin using Node 24 by default starting March 4th, 2026. ### Why this matters - **Node 20 EOL**: April 2026 - **Node 24 default**: March 4th, 2026 - **Action**: Update to latest action versions that support Node 24 ### Security Note Actions that were previously pinned to commit SHAs remain pinned to SHAs (updated to the latest release SHA) to maintain the security benefits of immutable references. ### Testing These changes only affect CI/CD workflow configurations and should not impact application functionality. The workflows should be tested by running them on a branch before merging. Signed-off-by: Salman Muin Kayser Chishti <13schishti@gmail.com> --- .github/workflows/bazel.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/bazel.yml b/.github/workflows/bazel.yml index 4f97d2de02..55e05251d7 100644 --- a/.github/workflows/bazel.yml +++ b/.github/workflows/bazel.yml @@ -81,7 +81,7 @@ jobs: # previously built artifacts to minimize build time. The more precise you are with # hashFiles sources the less work bazel will have to do. # - name: Mount bazel caches - # uses: actions/cache@v4 + # uses: actions/cache@v5 # with: # path: | # ~/.cache/bazel-repo-cache From 7938c170d90389864802f52cf526fad994bd1019 Mon Sep 17 00:00:00 2001 From: gt-oai Date: Fri, 23 Jan 2026 20:06:37 +0000 Subject: [PATCH 08/36] Print warning if we skip config loading (#9611) https://github.com/openai/codex/pull/9533 silently ignored config if untrusted. Instead, we still load it but disable it. Maybe we shouldn't try to parse it either... Screenshot 2026-01-21 at 14 56 38 --- .../app-server-protocol/src/protocol/v2.rs | 2 + codex-rs/app-server/src/lib.rs | 48 ++++ codex-rs/core/src/config/service.rs | 3 +- codex-rs/core/src/config_loader/README.md | 6 +- codex-rs/core/src/config_loader/mod.rs | 218 +++++++++++++----- codex-rs/core/src/config_loader/state.rs | 44 +++- codex-rs/core/src/config_loader/tests.rs | 129 ++++++++++- codex-rs/core/src/exec_policy.rs | 2 +- codex-rs/core/src/skills/loader.rs | 8 +- codex-rs/tui/src/app.rs | 42 ++++ 10 files changed, 424 insertions(+), 78 deletions(-) diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index 14a3fe532f..ee65a9ac77 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -393,6 +393,8 @@ pub struct ConfigLayer { pub name: ConfigLayerSource, pub version: String, pub config: JsonValue, + #[serde(skip_serializing_if = "Option::is_none")] + pub disabled_reason: Option, } #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] diff --git a/codex-rs/app-server/src/lib.rs b/codex-rs/app-server/src/lib.rs index 417ed46f93..022ae9a9c0 100644 --- a/codex-rs/app-server/src/lib.rs +++ b/codex-rs/app-server/src/lib.rs @@ -3,6 +3,7 @@ use codex_common::CliConfigOverrides; use codex_core::config::Config; use codex_core::config::ConfigBuilder; +use codex_core::config_loader::ConfigLayerStackOrdering; use codex_core::config_loader::LoaderOverrides; use std::io::ErrorKind; use std::io::Result as IoResult; @@ -11,6 +12,7 @@ use std::path::PathBuf; use crate::message_processor::MessageProcessor; use crate::outgoing_message::OutgoingMessage; use crate::outgoing_message::OutgoingMessageSender; +use codex_app_server_protocol::ConfigLayerSource; use codex_app_server_protocol::ConfigWarningNotification; use codex_app_server_protocol::JSONRPCMessage; use codex_core::check_execpolicy_for_warnings; @@ -44,6 +46,48 @@ mod outgoing_message; /// plenty for an interactive CLI. const CHANNEL_CAPACITY: usize = 128; +fn project_config_warning(config: &Config) -> Option { + let mut disabled_folders = Vec::new(); + + for layer in config + .config_layer_stack + .get_layers(ConfigLayerStackOrdering::LowestPrecedenceFirst, true) + { + if !matches!(layer.name, ConfigLayerSource::Project { .. }) { + continue; + } + if layer.disabled_reason.is_none() { + continue; + }; + if let ConfigLayerSource::Project { dot_codex_folder } = &layer.name { + disabled_folders.push(( + dot_codex_folder.as_path().display().to_string(), + layer + .disabled_reason + .as_ref() + .map(ToString::to_string) + .unwrap_or_else(|| "Config folder disabled.".to_string()), + )); + } + } + + if disabled_folders.is_empty() { + return None; + } + + let mut message = "The following config folders are disabled:\n".to_string(); + for (index, (folder, reason)) in disabled_folders.iter().enumerate() { + let display_index = index + 1; + message.push_str(&format!(" {display_index}. {folder}\n")); + message.push_str(&format!(" {reason}\n")); + } + + Some(ConfigWarningNotification { + summary: message, + details: None, + }) +} + pub async fn run_main( codex_linux_sandbox_exe: Option, cli_config_overrides: CliConfigOverrides, @@ -119,6 +163,10 @@ pub async fn run_main( config_warnings.push(message); } + if let Some(warning) = project_config_warning(&config) { + config_warnings.push(warning); + } + let feedback = CodexFeedback::new(); let otel = codex_core::otel_init::build_provider( diff --git a/codex-rs/core/src/config/service.rs b/codex-rs/core/src/config/service.rs index d1a5759d8b..961c40e02b 100644 --- a/codex-rs/core/src/config/service.rs +++ b/codex-rs/core/src/config/service.rs @@ -4,6 +4,7 @@ use crate::config::edit::ConfigEdit; use crate::config::edit::ConfigEditsBuilder; use crate::config_loader::ConfigLayerEntry; use crate::config_loader::ConfigLayerStack; +use crate::config_loader::ConfigLayerStackOrdering; use crate::config_loader::ConfigRequirementsToml; use crate::config_loader::LoaderOverrides; use crate::config_loader::load_config_layers_state; @@ -171,7 +172,7 @@ impl ConfigService { origins: layers.origins(), layers: params.include_layers.then(|| { layers - .layers_high_to_low() + .get_layers(ConfigLayerStackOrdering::HighestPrecedenceFirst, true) .iter() .map(|layer| layer.as_layer()) .collect() diff --git a/codex-rs/core/src/config_loader/README.md b/codex-rs/core/src/config_loader/README.md index d0df9a7349..67170644f5 100644 --- a/codex-rs/core/src/config_loader/README.md +++ b/codex-rs/core/src/config_loader/README.md @@ -16,7 +16,7 @@ Exported from `codex_core::config_loader`: - `origins() -> HashMap` - `layers_high_to_low() -> Vec` - `with_user_config(user_config) -> ConfigLayerStack` -- `ConfigLayerEntry` (one layer’s `{name, config, version}`; `name` carries source metadata) +- `ConfigLayerEntry` (one layer’s `{name, config, version, disabled_reason}`; `name` carries source metadata) - `LoaderOverrides` (test/override hooks for managed config sources) - `merge_toml_values(base, overlay)` (public helper used elsewhere) @@ -29,7 +29,9 @@ Precedence is **top overrides bottom**: 3. **Session flags** (CLI overrides, applied as dotted-path TOML writes) 4. **User** config (`config.toml`) -This is what `ConfigLayerStack::effective_config()` implements. +Layers with a `disabled_reason` are still surfaced for UI, but are ignored when +computing the effective config and origins metadata. This is what +`ConfigLayerStack::effective_config()` implements. ## Typical usage diff --git a/codex-rs/core/src/config_loader/mod.rs b/codex-rs/core/src/config_loader/mod.rs index ffde19f1ff..8f2365f673 100644 --- a/codex-rs/core/src/config_loader/mod.rs +++ b/codex-rs/core/src/config_loader/mod.rs @@ -67,9 +67,9 @@ const DEFAULT_PROJECT_ROOT_MARKERS: &[&str] = &[".git"]; /// - admin: managed preferences (*) /// - system `/etc/codex/config.toml` /// - user `${CODEX_HOME}/config.toml` -/// - cwd `${PWD}/config.toml` (only when the directory is trusted) -/// - tree parent directories up to root looking for `./.codex/config.toml` (trusted only) -/// - repo `$(git rev-parse --show-toplevel)/.codex/config.toml` (trusted only) +/// - cwd `${PWD}/config.toml` (loaded but disabled when the directory is untrusted) +/// - tree parent directories up to root looking for `./.codex/config.toml` (loaded but disabled when untrusted) +/// - repo `$(git rev-parse --show-toplevel)/.codex/config.toml` (loaded but disabled when untrusted) /// - runtime e.g., --config flags, model selector in UI /// /// (*) Only available on macOS via managed device profiles. @@ -173,12 +173,21 @@ pub async fn load_config_layers_state( let project_root_markers = project_root_markers_from_config(&merged_so_far)? .unwrap_or_else(default_project_root_markers); - if let Some(project_root) = - trusted_project_root(&merged_so_far, &cwd, &project_root_markers, codex_home).await? - { - let project_layers = load_project_layers(&cwd, &project_root).await?; - layers.extend(project_layers); - } + let project_trust_context = project_trust_context( + &merged_so_far, + &cwd, + &project_root_markers, + codex_home, + &user_file, + ) + .await?; + let project_layers = load_project_layers( + &cwd, + &project_trust_context.project_root, + &project_trust_context, + ) + .await?; + layers.extend(project_layers); } // Add a layer for runtime overrides from the CLI or UI, if any exist. @@ -402,42 +411,132 @@ fn default_project_root_markers() -> Vec { .collect() } -async fn trusted_project_root( +struct ProjectTrustContext { + project_root: AbsolutePathBuf, + project_root_key: String, + repo_root_key: Option, + projects_trust: std::collections::HashMap, + user_config_file: AbsolutePathBuf, +} + +struct ProjectTrustDecision { + trust_level: Option, + trust_key: String, +} + +impl ProjectTrustDecision { + fn is_trusted(&self) -> bool { + matches!(self.trust_level, Some(TrustLevel::Trusted)) + } +} + +impl ProjectTrustContext { + fn decision_for_dir(&self, dir: &AbsolutePathBuf) -> ProjectTrustDecision { + let dir_key = dir.as_path().to_string_lossy().to_string(); + if let Some(trust_level) = self.projects_trust.get(&dir_key).copied() { + return ProjectTrustDecision { + trust_level: Some(trust_level), + trust_key: dir_key, + }; + } + + if let Some(trust_level) = self.projects_trust.get(&self.project_root_key).copied() { + return ProjectTrustDecision { + trust_level: Some(trust_level), + trust_key: self.project_root_key.clone(), + }; + } + + if let Some(repo_root_key) = self.repo_root_key.as_ref() + && let Some(trust_level) = self.projects_trust.get(repo_root_key).copied() + { + return ProjectTrustDecision { + trust_level: Some(trust_level), + trust_key: repo_root_key.clone(), + }; + } + + ProjectTrustDecision { + trust_level: None, + trust_key: self + .repo_root_key + .clone() + .unwrap_or_else(|| self.project_root_key.clone()), + } + } + + fn disabled_reason_for_dir(&self, dir: &AbsolutePathBuf) -> Option { + let decision = self.decision_for_dir(dir); + if decision.is_trusted() { + return None; + } + + let trust_key = decision.trust_key.as_str(); + let user_config_file = self.user_config_file.as_path().display(); + match decision.trust_level { + Some(TrustLevel::Untrusted) => Some(format!( + "{trust_key} is marked as untrusted in {user_config_file}. Mark it trusted to enable project config folders." + )), + _ => Some(format!( + "Add {trust_key} as a trusted project in {user_config_file}." + )), + } + } +} + +fn project_layer_entry( + trust_context: &ProjectTrustContext, + dot_codex_folder: &AbsolutePathBuf, + layer_dir: &AbsolutePathBuf, + config: TomlValue, +) -> ConfigLayerEntry { + match trust_context.disabled_reason_for_dir(layer_dir) { + Some(reason) => ConfigLayerEntry::new_disabled( + ConfigLayerSource::Project { + dot_codex_folder: dot_codex_folder.clone(), + }, + config, + reason, + ), + None => ConfigLayerEntry::new( + ConfigLayerSource::Project { + dot_codex_folder: dot_codex_folder.clone(), + }, + config, + ), + } +} + +async fn project_trust_context( merged_config: &TomlValue, cwd: &AbsolutePathBuf, project_root_markers: &[String], config_base_dir: &Path, -) -> io::Result> { + user_config_file: &AbsolutePathBuf, +) -> io::Result { let config_toml = deserialize_config_toml_with_base(merged_config.clone(), config_base_dir)?; let project_root = find_project_root(cwd, project_root_markers).await?; let projects = config_toml.projects.unwrap_or_default(); - let cwd_key = cwd.as_path().to_string_lossy().to_string(); let project_root_key = project_root.as_path().to_string_lossy().to_string(); - let repo_root_key = resolve_root_git_project_for_trust(cwd.as_path()) + let repo_root = resolve_root_git_project_for_trust(cwd.as_path()); + let repo_root_key = repo_root + .as_ref() .map(|root| root.to_string_lossy().to_string()); - let trust_level = projects - .get(&cwd_key) - .and_then(|project| project.trust_level) - .or_else(|| { - projects - .get(&project_root_key) - .and_then(|project| project.trust_level) - }) - .or_else(|| { - repo_root_key - .as_ref() - .and_then(|root| projects.get(root)) - .and_then(|project| project.trust_level) - }); + let projects_trust = projects + .into_iter() + .filter_map(|(key, project)| project.trust_level.map(|trust_level| (key, trust_level))) + .collect(); - if matches!(trust_level, Some(TrustLevel::Trusted)) { - Ok(Some(project_root)) - } else { - Ok(None) - } + Ok(ProjectTrustContext { + project_root, + project_root_key, + repo_root_key, + projects_trust, + user_config_file: user_config_file.clone(), + }) } /// Takes a `toml::Value` parsed from a config.toml file and walks through it, @@ -527,6 +626,7 @@ async fn find_project_root( async fn load_project_layers( cwd: &AbsolutePathBuf, project_root: &AbsolutePathBuf, + trust_context: &ProjectTrustContext, ) -> io::Result> { let mut dirs = cwd .as_path() @@ -555,46 +655,54 @@ async fn load_project_layers( continue; } + let layer_dir = AbsolutePathBuf::from_absolute_path(dir)?; + let decision = trust_context.decision_for_dir(&layer_dir); let dot_codex_abs = AbsolutePathBuf::from_absolute_path(&dot_codex)?; let config_file = dot_codex_abs.join(CONFIG_TOML_FILE)?; match tokio::fs::read_to_string(&config_file).await { Ok(contents) => { - let config: TomlValue = toml::from_str(&contents).map_err(|e| { - io::Error::new( - io::ErrorKind::InvalidData, - format!( - "Error parsing project config file {}: {e}", - config_file.as_path().display(), - ), - ) - })?; + let config: TomlValue = match toml::from_str(&contents) { + Ok(config) => config, + Err(e) => { + if decision.is_trusted() { + let config_file_display = config_file.as_path().display(); + return Err(io::Error::new( + io::ErrorKind::InvalidData, + format!( + "Error parsing project config file {config_file_display}: {e}" + ), + )); + } + layers.push(project_layer_entry( + trust_context, + &dot_codex_abs, + &layer_dir, + TomlValue::Table(toml::map::Map::new()), + )); + continue; + } + }; let config = resolve_relative_paths_in_config_toml(config, dot_codex_abs.as_path())?; - layers.push(ConfigLayerEntry::new( - ConfigLayerSource::Project { - dot_codex_folder: dot_codex_abs, - }, - config, - )); + let entry = project_layer_entry(trust_context, &dot_codex_abs, &layer_dir, config); + layers.push(entry); } Err(err) => { if err.kind() == io::ErrorKind::NotFound { // If there is no config.toml file, record an empty entry // for this project layer, as this may still have subfolders // that are significant in the overall ConfigLayerStack. - layers.push(ConfigLayerEntry::new( - ConfigLayerSource::Project { - dot_codex_folder: dot_codex_abs, - }, + layers.push(project_layer_entry( + trust_context, + &dot_codex_abs, + &layer_dir, TomlValue::Table(toml::map::Map::new()), )); } else { + let config_file_display = config_file.as_path().display(); return Err(io::Error::new( err.kind(), - format!( - "Failed to read project config file {}: {err}", - config_file.as_path().display(), - ), + format!("Failed to read project config file {config_file_display}: {err}"), )); } } diff --git a/codex-rs/core/src/config_loader/state.rs b/codex-rs/core/src/config_loader/state.rs index 2b01a22644..847b19d7e5 100644 --- a/codex-rs/core/src/config_loader/state.rs +++ b/codex-rs/core/src/config_loader/state.rs @@ -28,6 +28,7 @@ pub struct ConfigLayerEntry { pub name: ConfigLayerSource, pub config: TomlValue, pub version: String, + pub disabled_reason: Option, } impl ConfigLayerEntry { @@ -37,9 +38,28 @@ impl ConfigLayerEntry { name, config, version, + disabled_reason: None, } } + pub fn new_disabled( + name: ConfigLayerSource, + config: TomlValue, + disabled_reason: impl Into, + ) -> Self { + let version = version_for_toml(&config); + Self { + name, + config, + version, + disabled_reason: Some(disabled_reason.into()), + } + } + + pub fn is_disabled(&self) -> bool { + self.disabled_reason.is_some() + } + pub fn metadata(&self) -> ConfigLayerMetadata { ConfigLayerMetadata { name: self.name.clone(), @@ -52,6 +72,7 @@ impl ConfigLayerEntry { name: self.name.clone(), version: self.version.clone(), config: serde_json::to_value(&self.config).unwrap_or(JsonValue::Null), + disabled_reason: self.disabled_reason.clone(), } } @@ -172,7 +193,7 @@ impl ConfigLayerStack { pub fn effective_config(&self) -> TomlValue { let mut merged = TomlValue::Table(toml::map::Map::new()); - for layer in &self.layers { + for layer in self.get_layers(ConfigLayerStackOrdering::LowestPrecedenceFirst, false) { merge_toml_values(&mut merged, &layer.config); } merged @@ -182,7 +203,7 @@ impl ConfigLayerStack { let mut origins = HashMap::new(); let mut path = Vec::new(); - for layer in &self.layers { + for layer in self.get_layers(ConfigLayerStackOrdering::LowestPrecedenceFirst, false) { record_origins(&layer.config, &layer.metadata(), &mut path, &mut origins); } @@ -192,16 +213,25 @@ impl ConfigLayerStack { /// Returns the highest-precedence to lowest-precedence layers, so /// `ConfigLayerSource::SessionFlags` would be first, if present. pub fn layers_high_to_low(&self) -> Vec<&ConfigLayerEntry> { - self.get_layers(ConfigLayerStackOrdering::HighestPrecedenceFirst) + self.get_layers(ConfigLayerStackOrdering::HighestPrecedenceFirst, false) } /// Returns the highest-precedence to lowest-precedence layers, so /// `ConfigLayerSource::SessionFlags` would be first, if present. - pub fn get_layers(&self, ordering: ConfigLayerStackOrdering) -> Vec<&ConfigLayerEntry> { - match ordering { - ConfigLayerStackOrdering::HighestPrecedenceFirst => self.layers.iter().rev().collect(), - ConfigLayerStackOrdering::LowestPrecedenceFirst => self.layers.iter().collect(), + pub fn get_layers( + &self, + ordering: ConfigLayerStackOrdering, + include_disabled: bool, + ) -> Vec<&ConfigLayerEntry> { + let mut layers: Vec<&ConfigLayerEntry> = self + .layers + .iter() + .filter(|layer| include_disabled || !layer.is_disabled()) + .collect(); + if ordering == ConfigLayerStackOrdering::HighestPrecedenceFirst { + layers.reverse(); } + layers } } diff --git a/codex-rs/core/src/config_loader/tests.rs b/codex-rs/core/src/config_loader/tests.rs index 51132e54fe..9167f9f87d 100644 --- a/codex-rs/core/src/config_loader/tests.rs +++ b/codex-rs/core/src/config_loader/tests.rs @@ -132,6 +132,7 @@ async fn returns_empty_when_all_layers_missing() { }, config: TomlValue::Table(toml::map::Map::new()), version: version_for_toml(&TomlValue::Table(toml::map::Map::new())), + disabled_reason: None, }, user_layer, ); @@ -546,6 +547,7 @@ async fn project_layer_is_added_when_dot_codex_exists_without_config_toml() -> s }, config: TomlValue::Table(toml::map::Map::new()), version: version_for_toml(&TomlValue::Table(toml::map::Map::new())), + disabled_reason: None, }], project_layers ); @@ -554,7 +556,7 @@ async fn project_layer_is_added_when_dot_codex_exists_without_config_toml() -> s } #[tokio::test] -async fn project_layers_skipped_when_untrusted_or_unknown() -> std::io::Result<()> { +async fn project_layers_disabled_when_untrusted_or_unknown() -> std::io::Result<()> { let tmp = tempdir()?; let project_root = tmp.path().join("project"); let nested = project_root.join("child"); @@ -576,6 +578,13 @@ async fn project_layers_skipped_when_untrusted_or_unknown() -> std::io::Result<( None, ) .await?; + let untrusted_config_path = codex_home_untrusted.join(CONFIG_TOML_FILE); + let untrusted_config_contents = tokio::fs::read_to_string(&untrusted_config_path).await?; + tokio::fs::write( + &untrusted_config_path, + format!("foo = \"user\"\n{untrusted_config_contents}"), + ) + .await?; let layers_untrusted = load_config_layers_state( &codex_home_untrusted, @@ -584,16 +593,35 @@ async fn project_layers_skipped_when_untrusted_or_unknown() -> std::io::Result<( LoaderOverrides::default(), ) .await?; - let project_layers_untrusted = layers_untrusted - .layers_high_to_low() + let project_layers_untrusted: Vec<_> = layers_untrusted + .get_layers( + super::ConfigLayerStackOrdering::HighestPrecedenceFirst, + true, + ) .into_iter() .filter(|layer| matches!(layer.name, super::ConfigLayerSource::Project { .. })) - .count(); - assert_eq!(project_layers_untrusted, 0); - assert_eq!(layers_untrusted.effective_config().get("foo"), None); + .collect(); + assert_eq!(project_layers_untrusted.len(), 1); + assert!( + project_layers_untrusted[0].disabled_reason.is_some(), + "expected untrusted project layer to be disabled" + ); + assert_eq!( + project_layers_untrusted[0].config.get("foo"), + Some(&TomlValue::String("child".to_string())) + ); + assert_eq!( + layers_untrusted.effective_config().get("foo"), + Some(&TomlValue::String("user".to_string())) + ); let codex_home_unknown = tmp.path().join("home_unknown"); tokio::fs::create_dir_all(&codex_home_unknown).await?; + tokio::fs::write( + codex_home_unknown.join(CONFIG_TOML_FILE), + "foo = \"user\"\n", + ) + .await?; let layers_unknown = load_config_layers_state( &codex_home_unknown, @@ -602,13 +630,92 @@ async fn project_layers_skipped_when_untrusted_or_unknown() -> std::io::Result<( LoaderOverrides::default(), ) .await?; - let project_layers_unknown = layers_unknown - .layers_high_to_low() + let project_layers_unknown: Vec<_> = layers_unknown + .get_layers( + super::ConfigLayerStackOrdering::HighestPrecedenceFirst, + true, + ) .into_iter() .filter(|layer| matches!(layer.name, super::ConfigLayerSource::Project { .. })) - .count(); - assert_eq!(project_layers_unknown, 0); - assert_eq!(layers_unknown.effective_config().get("foo"), None); + .collect(); + assert_eq!(project_layers_unknown.len(), 1); + assert!( + project_layers_unknown[0].disabled_reason.is_some(), + "expected unknown-trust project layer to be disabled" + ); + assert_eq!( + project_layers_unknown[0].config.get("foo"), + Some(&TomlValue::String("child".to_string())) + ); + assert_eq!( + layers_unknown.effective_config().get("foo"), + Some(&TomlValue::String("user".to_string())) + ); + + Ok(()) +} + +#[tokio::test] +async fn invalid_project_config_ignored_when_untrusted_or_unknown() -> std::io::Result<()> { + let tmp = tempdir()?; + let project_root = tmp.path().join("project"); + let nested = project_root.join("child"); + tokio::fs::create_dir_all(nested.join(".codex")).await?; + tokio::fs::write(project_root.join(".git"), "gitdir: here").await?; + tokio::fs::write(nested.join(".codex").join(CONFIG_TOML_FILE), "foo =").await?; + + let cwd = AbsolutePathBuf::from_absolute_path(&nested)?; + let cases = [ + ("untrusted", Some(TrustLevel::Untrusted)), + ("unknown", None), + ]; + + for (name, trust_level) in cases { + let codex_home = tmp.path().join(format!("home_{name}")); + tokio::fs::create_dir_all(&codex_home).await?; + let config_path = codex_home.join(CONFIG_TOML_FILE); + + if let Some(trust_level) = trust_level { + make_config_for_test(&codex_home, &project_root, trust_level, None).await?; + let config_contents = tokio::fs::read_to_string(&config_path).await?; + tokio::fs::write(&config_path, format!("foo = \"user\"\n{config_contents}")).await?; + } else { + tokio::fs::write(&config_path, "foo = \"user\"\n").await?; + } + + let layers = load_config_layers_state( + &codex_home, + Some(cwd.clone()), + &[] as &[(String, TomlValue)], + LoaderOverrides::default(), + ) + .await?; + let project_layers: Vec<_> = layers + .get_layers( + super::ConfigLayerStackOrdering::HighestPrecedenceFirst, + true, + ) + .into_iter() + .filter(|layer| matches!(layer.name, super::ConfigLayerSource::Project { .. })) + .collect(); + assert_eq!( + project_layers.len(), + 1, + "expected one project layer for {name}" + ); + assert!( + project_layers[0].disabled_reason.is_some(), + "expected {name} project layer to be disabled" + ); + assert_eq!( + project_layers[0].config, + TomlValue::Table(toml::map::Map::new()) + ); + assert_eq!( + layers.effective_config().get("foo"), + Some(&TomlValue::String("user".to_string())) + ); + } Ok(()) } diff --git a/codex-rs/core/src/exec_policy.rs b/codex-rs/core/src/exec_policy.rs index e3a6751b55..dea11c8604 100644 --- a/codex-rs/core/src/exec_policy.rs +++ b/codex-rs/core/src/exec_policy.rs @@ -227,7 +227,7 @@ pub async fn load_exec_policy(config_stack: &ConfigLayerStack) -> Result Config { + let trust_root = cwd + .ancestors() + .find(|ancestor| ancestor.join(".git").exists()) + .map(Path::to_path_buf) + .unwrap_or_else(|| cwd.clone()); + fs::write( codex_home.path().join(CONFIG_TOML_FILE), toml::to_string(&ConfigToml { projects: Some(HashMap::from([( - cwd.to_string_lossy().to_string(), + trust_root.to_string_lossy().to_string(), ProjectConfig { trust_level: Some(TrustLevel::Trusted), }, diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 6b6b0188cc..03aac67bbc 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -31,12 +31,14 @@ use crate::tui; use crate::tui::TuiEvent; use crate::update_action::UpdateAction; use codex_ansi_escape::ansi_escape_line; +use codex_app_server_protocol::ConfigLayerSource; use codex_core::AuthManager; use codex_core::CodexAuth; use codex_core::ThreadManager; use codex_core::config::Config; use codex_core::config::edit::ConfigEdit; use codex_core::config::edit::ConfigEditsBuilder; +use codex_core::config_loader::ConfigLayerStackOrdering; #[cfg(target_os = "windows")] use codex_core::features::Feature; use codex_core::models_manager::manager::RefreshStrategy; @@ -172,6 +174,45 @@ fn emit_deprecation_notice(app_event_tx: &AppEventSender, notice: Option Date: Fri, 23 Jan 2026 12:18:36 -0800 Subject: [PATCH 09/36] Select default model from filtered presets (#9782) Pick the first available preset after auth filtering for default selection. --- codex-rs/core/src/models_manager/manager.rs | 36 ++++++++++----------- 1 file changed, 18 insertions(+), 18 deletions(-) diff --git a/codex-rs/core/src/models_manager/manager.rs b/codex-rs/core/src/models_manager/manager.rs index 7210e1c62d..efa60507ea 100644 --- a/codex-rs/core/src/models_manager/manager.rs +++ b/codex-rs/core/src/models_manager/manager.rs @@ -30,9 +30,6 @@ use tracing::error; const MODEL_CACHE_FILE: &str = "models_cache.json"; const DEFAULT_MODEL_CACHE_TTL: Duration = Duration::from_secs(300); const MODELS_REFRESH_TIMEOUT: Duration = Duration::from_secs(5); -const OPENAI_DEFAULT_API_MODEL: &str = "gpt-5.2-codex"; -const OPENAI_DEFAULT_CHATGPT_MODEL: &str = "gpt-5.2-codex"; -const CODEX_AUTO_BALANCED_MODEL: &str = "codex-auto-balanced"; /// Strategy for refreshing available models. #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -110,7 +107,7 @@ impl ModelsManager { /// Get the model identifier to use, refreshing according to the specified strategy. /// /// If `model` is provided, returns it directly. Otherwise selects the default based on - /// auth mode and available models (prefers `codex-auto-balanced` for ChatGPT auth). + /// auth mode and available models. pub async fn get_default_model( &self, model: &Option, @@ -126,20 +123,14 @@ impl ModelsManager { { error!("failed to refresh available models: {err}"); } - // if codex-auto-balanced exists & signed in with chatgpt mode, return it, otherwise return the default model - let auth_mode = self.auth_manager.get_auth_mode(); let remote_models = self.get_remote_models(config).await; - if auth_mode == Some(AuthMode::ChatGPT) { - let has_auto_balanced = self - .build_available_models(remote_models) - .iter() - .any(|model| model.model == CODEX_AUTO_BALANCED_MODEL && model.show_in_picker); - if has_auto_balanced { - return CODEX_AUTO_BALANCED_MODEL.to_string(); - } - return OPENAI_DEFAULT_CHATGPT_MODEL.to_string(); - } - OPENAI_DEFAULT_API_MODEL.to_string() + let available = self.build_available_models(remote_models); + available + .iter() + .find(|model| model.is_default) + .or_else(|| available.first()) + .map(|model| model.model.clone()) + .unwrap_or_default() } // todo(aibrahim): look if we can tighten it to pub(crate) @@ -336,7 +327,16 @@ impl ModelsManager { #[cfg(any(test, feature = "test-support"))] /// Get model identifier without consulting remote state or cache. pub fn get_model_offline(model: Option<&str>) -> String { - model.unwrap_or(OPENAI_DEFAULT_CHATGPT_MODEL).to_string() + if let Some(model) = model { + return model.to_string(); + } + let presets = builtin_model_presets(None); + presets + .iter() + .find(|preset| preset.show_in_picker) + .or_else(|| presets.first()) + .map(|preset| preset.model.clone()) + .unwrap_or_default() } #[cfg(any(test, feature = "test-support"))] From e0ae219f36b6850438dbf2a78cb6774a2b53d9dc Mon Sep 17 00:00:00 2001 From: JUAN DAVID SALAS CAMARGO <31742974+jdsalasca@users.noreply.github.com> Date: Fri, 23 Jan 2026 15:21:27 -0500 Subject: [PATCH 10/36] Fix resume picker when user event appears after head (#9512) Fixes #9501 Contributing guide: https://github.com/openai/codex/blob/main/docs/contributing.md ## Summary The resume picker requires a session_meta line and at least one user_message event within the initial head scan. Some rollout files contain multiple session_meta entries before the first user_message, so the user event can fall outside the default head window and the session is omitted from the picker even though it is resumable by ID. This PR keeps the head summary bounded but extends scanning for a user_message once a session_meta has been observed. The summary still caps stored head entries, but we allow a small, bounded extra scan to find the first user event so valid sessions are not filtered out. ## Changes - Continue scanning past the head limit (bounded) when session_meta is present but no user_message has been seen yet. - Mark session_meta as seen even if the head summary buffer is already full. - Add a regression test with multiple session_meta lines before the first user_message. ## Why This Is Safe - The head summary remains bounded to avoid unbounded memory usage. - The extra scan is capped (USER_EVENT_SCAN_LIMIT) and only triggers after a session_meta is seen. - Behavior is unchanged for typical files where the user_message appears early. ## Testing - cargo test -p codex-core --lib test_list_threads_scans_past_head_for_user_event --- codex-rs/core/src/rollout/list.rs | 19 +++++-- codex-rs/core/src/rollout/tests.rs | 82 ++++++++++++++++++++++++++++++ 2 files changed, 97 insertions(+), 4 deletions(-) diff --git a/codex-rs/core/src/rollout/list.rs b/codex-rs/core/src/rollout/list.rs index e571dccc68..14b2addc55 100644 --- a/codex-rs/core/src/rollout/list.rs +++ b/codex-rs/core/src/rollout/list.rs @@ -72,6 +72,7 @@ struct HeadTailSummary { /// Hard cap to bound worst‑case work per request. const MAX_SCAN_FILES: usize = 10000; const HEAD_RECORD_LIMIT: usize = 10; +const USER_EVENT_SCAN_LIMIT: usize = 200; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ThreadSortKey { @@ -943,14 +944,20 @@ async fn read_head_summary(path: &Path, head_limit: usize) -> io::Result = serde_json::from_str(trimmed); let Ok(rollout_line) = parsed else { continue }; @@ -963,9 +970,11 @@ async fn read_head_summary(path: &Path, head_limit: usize) -> io::Result { @@ -973,7 +982,9 @@ async fn read_head_summary(path: &Path, head_limit: usize) -> io::Result std::io::Result<()> { + let format: &[FormatItem] = + format_description!("[year]-[month]-[day]T[hour]-[minute]-[second]"); + let dt = PrimitiveDateTime::parse(ts_str, format) + .unwrap() + .assume_utc(); + let dir = root + .join("sessions") + .join(format!("{:04}", dt.year())) + .join(format!("{:02}", u8::from(dt.month()))) + .join(format!("{:02}", dt.day())); + fs::create_dir_all(&dir)?; + + let filename = format!("rollout-{ts_str}-{uuid}.jsonl"); + let file_path = dir.join(filename); + let mut file = File::create(file_path)?; + + for i in 0..meta_lines_before_user { + let id = if i == 0 { + uuid + } else { + Uuid::from_u128(100 + i as u128) + }; + let payload = serde_json::json!({ + "id": id, + "timestamp": ts_str, + "cwd": ".", + "originator": "test_originator", + "cli_version": "test_version", + "source": "vscode", + "model_provider": "test-provider", + }); + let meta = serde_json::json!({ + "timestamp": ts_str, + "type": "session_meta", + "payload": payload, + }); + writeln!(file, "{meta}")?; + } + + let user_event = serde_json::json!({ + "timestamp": ts_str, + "type": "event_msg", + "payload": {"type": "user_message", "message": "Hello from user", "kind": "plain"} + }); + writeln!(file, "{user_event}")?; + + let times = FileTimes::new().set_modified(dt.into()); + file.set_times(times)?; + Ok(()) +} + fn write_session_file_with_meta_payload( root: &Path, ts_str: &str, @@ -539,6 +596,31 @@ async fn test_pagination_cursor() { assert_eq!(page3, expected_page3); } +#[tokio::test] +async fn test_list_threads_scans_past_head_for_user_event() { + let temp = TempDir::new().unwrap(); + let home = temp.path(); + + let uuid = Uuid::from_u128(99); + let ts = "2025-05-01T10-30-00"; + write_session_file_with_delayed_user_event(home, ts, uuid, 12).unwrap(); + + let provider_filter = provider_vec(&[TEST_PROVIDER]); + let page = get_threads( + home, + 10, + None, + ThreadSortKey::CreatedAt, + INTERACTIVE_SESSION_SOURCES, + Some(provider_filter.as_slice()), + TEST_PROVIDER, + ) + .await + .unwrap(); + + assert_eq!(page.items.len(), 1); +} + #[tokio::test] async fn test_get_thread_contents() { let temp = TempDir::new().unwrap(); From e04851816df57e6994550356d0a582f944c548bb Mon Sep 17 00:00:00 2001 From: jcoens-openai <153659877+jcoens-openai@users.noreply.github.com> Date: Fri, 23 Jan 2026 12:30:01 -0800 Subject: [PATCH 11/36] Remove stale TODO comment from defs.bzl (#9787) ### Motivation - Remove an outdated comment in `defs.bzl` referencing `cargo_build_script` that is no longer relevant. ### Description - Delete the stale `# TODO(zbarsky): cargo_build_script support?` line so the logic flows directly from `binaries` to `lib_srcs` in `defs.bzl`. ### Testing - Ran `git diff --check` which produced no errors. ------ [Codex Task](https://chatgpt.com/codex/tasks/task_i_6973d9ac757c8331be475a8fb0f90a88) --- defs.bzl | 2 -- 1 file changed, 2 deletions(-) diff --git a/defs.bzl b/defs.bzl index 5fab68f436..249c3a4583 100644 --- a/defs.bzl +++ b/defs.bzl @@ -89,8 +89,6 @@ def codex_rust_crate( binaries = DEP_DATA.get(native.package_name())["binaries"] - # TODO(zbarsky): cargo_build_script support? - lib_srcs = crate_srcs or native.glob(["src/**/*.rs"], exclude = binaries.values(), allow_empty = True) if native.glob(["build.rs"], allow_empty = True): From c6ded0afd84ab30300aa85d4fffd41079cf16656 Mon Sep 17 00:00:00 2001 From: gt-oai Date: Fri, 23 Jan 2026 20:35:50 +0000 Subject: [PATCH 12/36] still load skills (#9700) --- codex-rs/core/src/skills/loader.rs | 58 +++++++++++++++++++++++++++++- 1 file changed, 57 insertions(+), 1 deletion(-) diff --git a/codex-rs/core/src/skills/loader.rs b/codex-rs/core/src/skills/loader.rs index 2b822aa9a6..09c988a15b 100644 --- a/codex-rs/core/src/skills/loader.rs +++ b/codex-rs/core/src/skills/loader.rs @@ -1,5 +1,6 @@ use crate::config::Config; use crate::config_loader::ConfigLayerStack; +use crate::config_loader::ConfigLayerStackOrdering; use crate::skills::model::SkillError; use crate::skills::model::SkillInterface; use crate::skills::model::SkillLoadOutcome; @@ -133,7 +134,9 @@ where fn skill_roots_from_layer_stack_inner(config_layer_stack: &ConfigLayerStack) -> Vec { let mut roots = Vec::new(); - for layer in config_layer_stack.layers_high_to_low() { + for layer in + config_layer_stack.get_layers(ConfigLayerStackOrdering::HighestPrecedenceFirst, true) + { let Some(config_folder) = layer.config_folder() else { continue; }; @@ -669,6 +672,59 @@ mod tests { Ok(()) } + #[test] + fn skill_roots_from_layer_stack_includes_disabled_project_layers() -> anyhow::Result<()> { + let tmp = tempfile::tempdir()?; + + let user_folder = tmp.path().join("home/codex"); + fs::create_dir_all(&user_folder)?; + + let project_root = tmp.path().join("repo"); + let dot_codex = project_root.join(".codex"); + fs::create_dir_all(&dot_codex)?; + + let user_file = AbsolutePathBuf::from_absolute_path(user_folder.join("config.toml"))?; + let project_dot_codex = AbsolutePathBuf::from_absolute_path(&dot_codex)?; + + let layers = vec![ + ConfigLayerEntry::new( + ConfigLayerSource::User { file: user_file }, + TomlValue::Table(toml::map::Map::new()), + ), + ConfigLayerEntry::new_disabled( + ConfigLayerSource::Project { + dot_codex_folder: project_dot_codex, + }, + TomlValue::Table(toml::map::Map::new()), + "marked untrusted", + ), + ]; + let stack = ConfigLayerStack::new( + layers, + ConfigRequirements::default(), + ConfigRequirementsToml::default(), + )?; + + let got = skill_roots_from_layer_stack(&stack) + .into_iter() + .map(|root| (root.scope, root.path)) + .collect::>(); + + assert_eq!( + got, + vec![ + (SkillScope::Repo, dot_codex.join("skills")), + (SkillScope::User, user_folder.join("skills")), + ( + SkillScope::System, + user_folder.join("skills").join(".system") + ), + ] + ); + + Ok(()) + } + fn write_skill(codex_home: &TempDir, dir: &str, name: &str, description: &str) -> PathBuf { write_skill_at(&codex_home.path().join("skills"), dir, name, description) } From b9deb57689bef15a9bf77b4aec419e6f7c9a3824 Mon Sep 17 00:00:00 2001 From: gt-oai Date: Fri, 23 Jan 2026 21:52:27 +0000 Subject: [PATCH 13/36] Load untrusted rules (#9791) --- codex-rs/core/src/exec_policy.rs | 45 +++++++++++++++++++++++++++++++- 1 file changed, 44 insertions(+), 1 deletion(-) diff --git a/codex-rs/core/src/exec_policy.rs b/codex-rs/core/src/exec_policy.rs index dea11c8604..61c0070239 100644 --- a/codex-rs/core/src/exec_policy.rs +++ b/codex-rs/core/src/exec_policy.rs @@ -227,7 +227,9 @@ pub async fn load_exec_policy(config_stack: &ConfigLayerStack) -> Result anyhow::Result<()> { + let project_dir = tempdir()?; + let policy_dir = project_dir.path().join(RULES_DIR_NAME); + fs::create_dir_all(&policy_dir)?; + fs::write( + policy_dir.join("disabled.rules"), + r#"prefix_rule(pattern=["ls"], decision="forbidden")"#, + )?; + + let project_dot_codex_folder = AbsolutePathBuf::from_absolute_path(project_dir.path())?; + let layers = vec![ConfigLayerEntry::new_disabled( + ConfigLayerSource::Project { + dot_codex_folder: project_dot_codex_folder, + }, + TomlValue::Table(Default::default()), + "trust disabled", + )]; + let config_stack = ConfigLayerStack::new( + layers, + ConfigRequirements::default(), + ConfigRequirementsToml::default(), + )?; + + let policy = load_exec_policy(&config_stack).await?; + + assert_eq!( + Evaluation { + decision: Decision::Forbidden, + matched_rules: vec![RuleMatch::PrefixRuleMatch { + matched_prefix: vec!["ls".to_string()], + decision: Decision::Forbidden, + justification: None, + }], + }, + policy.check_multiple([vec!["ls".to_string()]].iter(), &|_| Decision::Allow) + ); + + Ok(()) + } + #[tokio::test] async fn loads_policies_from_multiple_config_layers() -> anyhow::Result<()> { let user_dir = tempdir()?; From d9232403aa7dfe92784b00de19af923046924be4 Mon Sep 17 00:00:00 2001 From: iceweasel-oai Date: Fri, 23 Jan 2026 14:36:42 -0800 Subject: [PATCH 14/36] bundle sandbox helper binaries in main zip, for winget. (#9707) Winget uses the main codex.exe value as its target. The elevated sandbox requires these two binaries to live next to codex.exe --- .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 f058f8dbef..1ab7f51755 100644 --- a/.github/workflows/rust-release.yml +++ b/.github/workflows/rust-release.yml @@ -285,7 +285,28 @@ jobs: # Must run from inside the dest dir so 7z won't # embed the directory path inside the zip. if [[ "${{ matrix.runner }}" == windows* ]]; then - (cd "$dest" && 7z a "${base}.zip" "$base") + if [[ "$base" == "codex-${{ matrix.target }}.exe" ]]; then + # Bundle the sandbox helper binaries into the main codex zip so + # WinGet installs include the required helpers next to codex.exe. + # Fall back to the single-binary zip if the helpers are missing + # to avoid breaking releases. + bundle_dir="$(mktemp -d)" + runner_src="$dest/codex-command-runner-${{ matrix.target }}.exe" + setup_src="$dest/codex-windows-sandbox-setup-${{ matrix.target }}.exe" + if [[ -f "$runner_src" && -f "$setup_src" ]]; then + cp "$dest/$base" "$bundle_dir/$base" + cp "$runner_src" "$bundle_dir/codex-command-runner.exe" + cp "$setup_src" "$bundle_dir/codex-windows-sandbox-setup.exe" + (cd "$bundle_dir" && 7z a "$dest/${base}.zip" .) + else + echo "warning: missing sandbox binaries; falling back to single-binary zip" + echo "warning: expected $runner_src and $setup_src" + (cd "$dest" && 7z a "${base}.zip" "$base") + fi + rm -rf "$bundle_dir" + else + (cd "$dest" && 7z a "${base}.zip" "$base") + fi fi # Also create .zst (existing behaviour) *and* remove the original From 1167465bf65d613a8f317586db4083b8ca1fc5e4 Mon Sep 17 00:00:00 2001 From: Ahmed Ibrahim Date: Fri, 23 Jan 2026 14:38:17 -0800 Subject: [PATCH 15/36] Chore: remove mode from header (#9792) --- codex-rs/tui/src/app.rs | 5 --- codex-rs/tui/src/chatwidget.rs | 22 ++-------- codex-rs/tui/src/history_cell.rs | 73 +++----------------------------- 3 files changed, 10 insertions(+), 90 deletions(-) diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 03aac67bbc..7063f8f5ba 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -2583,11 +2583,6 @@ mod tests { app.chat_widget.current_model(), event, is_first, - app.chat_widget - .config_ref() - .features - .enabled(codex_core::features::Feature::CollaborationModes), - app.chat_widget.stored_collaboration_mode().clone(), )) as Arc }; diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 5a96e4e381..137e0f3195 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -746,8 +746,6 @@ impl ChatWidget { &model_for_header, event, self.show_welcome_banner, - self.collaboration_modes_enabled(), - self.stored_collaboration_mode.clone(), ); self.apply_session_info_cell(session_info_cell); @@ -1948,11 +1946,7 @@ impl ChatWidget { CollaborationMode::Custom(fallback_custom) }; - let active_cell = Some(Self::placeholder_session_header_cell( - &config, - config.features.enabled(Feature::CollaborationModes), - stored_collaboration_mode.clone(), - )); + let active_cell = Some(Self::placeholder_session_header_cell(&config)); let mut widget = Self { app_event_tx: app_event_tx.clone(), @@ -2070,11 +2064,7 @@ impl ChatWidget { CollaborationMode::Custom(fallback_custom) }; - let active_cell = Some(Self::placeholder_session_header_cell( - &config, - config.features.enabled(Feature::CollaborationModes), - stored_collaboration_mode.clone(), - )); + let active_cell = Some(Self::placeholder_session_header_cell(&config)); let mut widget = Self { app_event_tx: app_event_tx.clone(), @@ -4698,11 +4688,7 @@ impl ChatWidget { } /// Build a placeholder header cell while the session is configuring. - fn placeholder_session_header_cell( - config: &Config, - is_collaboration: bool, - collaboration_mode: CollaborationMode, - ) -> Box { + fn placeholder_session_header_cell(config: &Config) -> Box { let placeholder_style = Style::default().add_modifier(Modifier::DIM | Modifier::ITALIC); Box::new(history_cell::SessionHeaderHistoryCell::new_with_style( DEFAULT_MODEL_DISPLAY_NAME.to_string(), @@ -4710,8 +4696,6 @@ impl ChatWidget { None, config.cwd.clone(), CODEX_CLI_VERSION, - is_collaboration, - collaboration_mode, )) } diff --git a/codex-rs/tui/src/history_cell.rs b/codex-rs/tui/src/history_cell.rs index 8bd4eda851..174f2a7f6a 100644 --- a/codex-rs/tui/src/history_cell.rs +++ b/codex-rs/tui/src/history_cell.rs @@ -19,7 +19,6 @@ use crate::exec_cell::output_lines; use crate::exec_cell::spinner; use crate::exec_command::relativize_to_home; use crate::exec_command::strip_bash_lc_and_escape; -use crate::key_hint; use crate::live_wrap::take_prefix_by_width; use crate::markdown::append_markdown; use crate::render::line_utils::line_to_static; @@ -44,13 +43,11 @@ use codex_core::protocol::FileChange; use codex_core::protocol::McpAuthStatus; use codex_core::protocol::McpInvocation; use codex_core::protocol::SessionConfiguredEvent; -use codex_protocol::config_types::CollaborationMode; use codex_protocol::openai_models::ReasoningEffort as ReasoningEffortConfig; use codex_protocol::plan_tool::PlanItemArg; use codex_protocol::plan_tool::StepStatus; use codex_protocol::plan_tool::UpdatePlanArgs; use codex_protocol::user_input::TextElement; -use crossterm::event::KeyCode; use image::DynamicImage; use image::ImageReader; use mcp_types::EmbeddedResourceResource; @@ -906,8 +903,6 @@ pub(crate) fn new_session_info( requested_model: &str, event: SessionConfiguredEvent, is_first_event: bool, - is_collaboration: bool, - collaboration_mode: CollaborationMode, ) -> SessionInfoCell { let SessionConfiguredEvent { model, @@ -920,8 +915,6 @@ pub(crate) fn new_session_info( reasoning_effort, config.cwd.clone(), CODEX_CLI_VERSION, - is_collaboration, - collaboration_mode, ); let mut parts: Vec> = vec![Box::new(header)]; @@ -1003,8 +996,6 @@ pub(crate) struct SessionHeaderHistoryCell { model_style: Style, reasoning_effort: Option, directory: PathBuf, - is_collaboration: bool, - collaboration_mode: CollaborationMode, } impl SessionHeaderHistoryCell { @@ -1013,8 +1004,6 @@ impl SessionHeaderHistoryCell { reasoning_effort: Option, directory: PathBuf, version: &'static str, - is_collaboration: bool, - collaboration_mode: CollaborationMode, ) -> Self { Self::new_with_style( model, @@ -1022,8 +1011,6 @@ impl SessionHeaderHistoryCell { reasoning_effort, directory, version, - is_collaboration, - collaboration_mode, ) } @@ -1033,8 +1020,6 @@ impl SessionHeaderHistoryCell { reasoning_effort: Option, directory: PathBuf, version: &'static str, - is_collaboration: bool, - collaboration_mode: CollaborationMode, ) -> Self { Self { version, @@ -1042,20 +1027,6 @@ impl SessionHeaderHistoryCell { model_style, reasoning_effort, directory, - is_collaboration, - collaboration_mode, - } - } - - fn collaboration_mode_label(&self) -> Option<&'static str> { - if !self.is_collaboration { - return None; - } - match &self.collaboration_mode { - CollaborationMode::Plan(_) => Some("Plan"), - CollaborationMode::PairProgramming(_) => Some("Pair Programming"), - CollaborationMode::Execute(_) => Some("Execute"), - CollaborationMode::Custom(_) => None, } } @@ -1116,38 +1087,16 @@ impl HistoryCell for SessionHeaderHistoryCell { const CHANGE_MODEL_HINT_COMMAND: &str = "/model"; const CHANGE_MODEL_HINT_EXPLANATION: &str = " to change"; - const CHANGE_MODE_HINT_EXPLANATION: &str = " to change mode"; const DIR_LABEL: &str = "directory:"; let label_width = DIR_LABEL.len(); - let model_spans: Vec> = if self.is_collaboration { - // Render collaboration mode instead of model - let collab_label = format!( - "{collab_label: = key_hint::shift(KeyCode::Tab).into(); - spans.push(shift_tab_span.cyan()); - spans.push(CHANGE_MODE_HINT_EXPLANATION.dim()); - spans - } else { - // Render model as before - let model_label = format!( - "{model_label:> = { let mut spans = vec![ Span::from(format!("{model_label} ")).dim(), Span::styled(self.model.clone(), self.model_style), @@ -1888,8 +1837,6 @@ mod tests { use codex_core::config::types::McpServerConfig; use codex_core::config::types::McpServerTransportConfig; use codex_core::protocol::McpAuthStatus; - use codex_protocol::config_types::CollaborationMode; - use codex_protocol::config_types::Settings; use codex_protocol::parse_command::ParsedCommand; use dirs::home_dir; use pretty_assertions::assert_eq; @@ -2346,12 +2293,6 @@ mod tests { Some(ReasoningEffortConfig::High), std::env::temp_dir(), "test", - false, - CollaborationMode::Custom(Settings { - model: "gpt-4o".to_string(), - reasoning_effort: Some(ReasoningEffortConfig::High), - developer_instructions: None, - }), ); let lines = render_lines(&cell.display_lines(80)); From 69cfc73dc6d9cfd7cf0ad4c47942dfcaeebee2ac Mon Sep 17 00:00:00 2001 From: Ahmed Ibrahim Date: Fri, 23 Jan 2026 17:00:23 -0800 Subject: [PATCH 16/36] change collaboration mode to struct (#9793) Shouldn't cause behavioral change --- .../tests/suite/v2/collaboration_mode_list.rs | 7 +- .../tests/suite/v2/request_user_input.rs | 14 +- .../app-server/tests/suite/v2/turn_start.rs | 14 +- codex-rs/core/src/codex.rs | 80 +++++++----- .../collaboration_mode_presets.rs | 40 +++--- .../src/tools/handlers/request_user_input.rs | 8 +- codex-rs/core/tests/suite/client.rs | 14 +- .../tests/suite/collaboration_instructions.rs | 14 +- codex-rs/core/tests/suite/override_updates.rs | 14 +- codex-rs/core/tests/suite/prompt_caching.rs | 14 +- .../core/tests/suite/request_user_input.rs | 28 ++-- codex-rs/protocol/src/config_types.rs | 39 ++---- codex-rs/protocol/src/models.rs | 9 +- codex-rs/tui/src/chatwidget.rs | 75 ++++++----- codex-rs/tui/src/chatwidget/tests.rs | 122 +++++++++++------- codex-rs/tui/src/collaboration_modes.rs | 9 +- 16 files changed, 291 insertions(+), 210 deletions(-) diff --git a/codex-rs/app-server/tests/suite/v2/collaboration_mode_list.rs b/codex-rs/app-server/tests/suite/v2/collaboration_mode_list.rs index d75a589d00..f3a931f003 100644 --- a/codex-rs/app-server/tests/suite/v2/collaboration_mode_list.rs +++ b/codex-rs/app-server/tests/suite/v2/collaboration_mode_list.rs @@ -17,6 +17,7 @@ use codex_app_server_protocol::JSONRPCResponse; use codex_app_server_protocol::RequestId; use codex_core::models_manager::test_builtin_collaboration_mode_presets; use codex_protocol::config_types::CollaborationMode; +use codex_protocol::config_types::ModeKind; use pretty_assertions::assert_eq; use tempfile::TempDir; use tokio::time::timeout; @@ -57,7 +58,7 @@ fn plan_preset() -> CollaborationMode { let presets = test_builtin_collaboration_mode_presets(); presets .into_iter() - .find(|p| matches!(p, CollaborationMode::Plan(_))) + .find(|p| p.mode == ModeKind::Plan) .unwrap() } @@ -69,7 +70,7 @@ fn pair_programming_preset() -> CollaborationMode { let presets = test_builtin_collaboration_mode_presets(); presets .into_iter() - .find(|p| matches!(p, CollaborationMode::PairProgramming(_))) + .find(|p| p.mode == ModeKind::PairProgramming) .unwrap() } @@ -81,6 +82,6 @@ fn execute_preset() -> CollaborationMode { let presets = test_builtin_collaboration_mode_presets(); presets .into_iter() - .find(|p| matches!(p, CollaborationMode::Execute(_))) + .find(|p| p.mode == ModeKind::Execute) .unwrap() } diff --git a/codex-rs/app-server/tests/suite/v2/request_user_input.rs b/codex-rs/app-server/tests/suite/v2/request_user_input.rs index c66dd05bec..4ee76bdca2 100644 --- a/codex-rs/app-server/tests/suite/v2/request_user_input.rs +++ b/codex-rs/app-server/tests/suite/v2/request_user_input.rs @@ -13,6 +13,7 @@ use codex_app_server_protocol::TurnStartParams; use codex_app_server_protocol::TurnStartResponse; use codex_app_server_protocol::UserInput as V2UserInput; use codex_protocol::config_types::CollaborationMode; +use codex_protocol::config_types::ModeKind; use codex_protocol::config_types::Settings; use codex_protocol::openai_models::ReasoningEffort; use tokio::time::timeout; @@ -54,11 +55,14 @@ async fn request_user_input_round_trip() -> Result<()> { }], model: Some("mock-model".to_string()), effort: Some(ReasoningEffort::Medium), - collaboration_mode: Some(CollaborationMode::Plan(Settings { - model: "mock-model".to_string(), - reasoning_effort: Some(ReasoningEffort::Medium), - developer_instructions: None, - })), + collaboration_mode: Some(CollaborationMode { + mode: ModeKind::Plan, + settings: Settings { + model: "mock-model".to_string(), + reasoning_effort: Some(ReasoningEffort::Medium), + developer_instructions: None, + }, + }), ..Default::default() }) .await?; diff --git a/codex-rs/app-server/tests/suite/v2/turn_start.rs b/codex-rs/app-server/tests/suite/v2/turn_start.rs index bf04eaf252..d375a2db2f 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start.rs @@ -38,6 +38,7 @@ use codex_core::features::FEATURES; use codex_core::features::Feature; use codex_core::protocol_config_types::ReasoningSummary; use codex_protocol::config_types::CollaborationMode; +use codex_protocol::config_types::ModeKind; use codex_protocol::config_types::Personality; use codex_protocol::config_types::Settings; use codex_protocol::openai_models::ReasoningEffort; @@ -363,11 +364,14 @@ async fn turn_start_accepts_collaboration_mode_override_v2() -> Result<()> { .await??; let ThreadStartResponse { thread, .. } = to_response::(thread_resp)?; - let collaboration_mode = CollaborationMode::Custom(Settings { - model: "mock-model-collab".to_string(), - reasoning_effort: Some(ReasoningEffort::High), - developer_instructions: None, - }); + let collaboration_mode = CollaborationMode { + mode: ModeKind::Custom, + settings: Settings { + model: "mock-model-collab".to_string(), + reasoning_effort: Some(ReasoningEffort::High), + developer_instructions: None, + }, + }; let turn_req = mcp .send_turn_start_request(TurnStartParams { diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index ea52aad32f..25c2b225a3 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -35,6 +35,7 @@ use async_channel::Receiver; use async_channel::Sender; use codex_protocol::ThreadId; use codex_protocol::approvals::ExecPolicyAmendment; +use codex_protocol::config_types::ModeKind; use codex_protocol::config_types::Settings; use codex_protocol::config_types::WebSearchMode; use codex_protocol::items::TurnItem; @@ -295,11 +296,14 @@ impl Codex { // TODO (aibrahim): Consolidate config.model and config.model_reasoning_effort into config.collaboration_mode // to avoid extracting these fields separately and constructing CollaborationMode here. - let collaboration_mode = CollaborationMode::Custom(Settings { - model: model.clone(), - reasoning_effort: config.model_reasoning_effort, - developer_instructions: None, - }); + let collaboration_mode = CollaborationMode { + mode: ModeKind::Custom, + settings: Settings { + model: model.clone(), + reasoning_effort: config.model_reasoning_effort, + developer_instructions: None, + }, + }; let session_configuration = SessionConfiguration { provider: config.model_provider.clone(), collaboration_mode, @@ -2215,6 +2219,7 @@ mod handlers { use crate::context_manager::is_user_turn_boundary; use codex_protocol::config_types::CollaborationMode; + use codex_protocol::config_types::ModeKind; use codex_protocol::config_types::Settings; use codex_protocol::user_input::UserInput; use codex_rmcp_client::ElicitationAction; @@ -2291,11 +2296,14 @@ mod handlers { personality, } => { let collaboration_mode = collaboration_mode.or_else(|| { - Some(CollaborationMode::Custom(Settings { - model: model.clone(), - reasoning_effort: effort, - developer_instructions: None, - })) + Some(CollaborationMode { + mode: ModeKind::Custom, + settings: Settings { + model: model.clone(), + reasoning_effort: effort, + developer_instructions: None, + }, + }) }); ( items, @@ -3878,11 +3886,14 @@ mod tests { let model = ModelsManager::get_model_offline(config.model.as_deref()); let model_info = ModelsManager::construct_model_info_offline(model.as_str(), &config); let reasoning_effort = config.model_reasoning_effort; - let collaboration_mode = CollaborationMode::Custom(Settings { - model, - reasoning_effort, - developer_instructions: None, - }); + let collaboration_mode = CollaborationMode { + mode: ModeKind::Custom, + settings: Settings { + model, + reasoning_effort, + developer_instructions: None, + }, + }; let session_configuration = SessionConfiguration { provider: config.model_provider.clone(), collaboration_mode, @@ -3954,11 +3965,14 @@ mod tests { let model = ModelsManager::get_model_offline(config.model.as_deref()); let model_info = ModelsManager::construct_model_info_offline(model.as_str(), &config); let reasoning_effort = config.model_reasoning_effort; - let collaboration_mode = CollaborationMode::Custom(Settings { - model, - reasoning_effort, - developer_instructions: None, - }); + let collaboration_mode = CollaborationMode { + mode: ModeKind::Custom, + settings: Settings { + model, + reasoning_effort, + developer_instructions: None, + }, + }; let session_configuration = SessionConfiguration { provider: config.model_provider.clone(), collaboration_mode, @@ -4214,11 +4228,14 @@ mod tests { let model = ModelsManager::get_model_offline(config.model.as_deref()); let model_info = ModelsManager::construct_model_info_offline(model.as_str(), &config); let reasoning_effort = config.model_reasoning_effort; - let collaboration_mode = CollaborationMode::Custom(Settings { - model, - reasoning_effort, - developer_instructions: None, - }); + let collaboration_mode = CollaborationMode { + mode: ModeKind::Custom, + settings: Settings { + model, + reasoning_effort, + developer_instructions: None, + }, + }; let session_configuration = SessionConfiguration { provider: config.model_provider.clone(), collaboration_mode, @@ -4319,11 +4336,14 @@ mod tests { let model = ModelsManager::get_model_offline(config.model.as_deref()); let model_info = ModelsManager::construct_model_info_offline(model.as_str(), &config); let reasoning_effort = config.model_reasoning_effort; - let collaboration_mode = CollaborationMode::Custom(Settings { - model, - reasoning_effort, - developer_instructions: None, - }); + let collaboration_mode = CollaborationMode { + mode: ModeKind::Custom, + settings: Settings { + model, + reasoning_effort, + developer_instructions: None, + }, + }; let session_configuration = SessionConfiguration { provider: config.model_provider.clone(), collaboration_mode, diff --git a/codex-rs/core/src/models_manager/collaboration_mode_presets.rs b/codex-rs/core/src/models_manager/collaboration_mode_presets.rs index 39d725bcd6..70989cb7cd 100644 --- a/codex-rs/core/src/models_manager/collaboration_mode_presets.rs +++ b/codex-rs/core/src/models_manager/collaboration_mode_presets.rs @@ -1,4 +1,5 @@ use codex_protocol::config_types::CollaborationMode; +use codex_protocol::config_types::ModeKind; use codex_protocol::config_types::Settings; use codex_protocol::openai_models::ReasoningEffort; @@ -18,25 +19,34 @@ pub fn test_builtin_collaboration_mode_presets() -> Vec { } fn plan_preset() -> CollaborationMode { - CollaborationMode::Plan(Settings { - model: "gpt-5.2-codex".to_string(), - reasoning_effort: Some(ReasoningEffort::High), - developer_instructions: Some(COLLABORATION_MODE_PLAN.to_string()), - }) + CollaborationMode { + mode: ModeKind::Plan, + settings: Settings { + model: "gpt-5.2-codex".to_string(), + reasoning_effort: Some(ReasoningEffort::High), + developer_instructions: Some(COLLABORATION_MODE_PLAN.to_string()), + }, + } } fn pair_programming_preset() -> CollaborationMode { - CollaborationMode::PairProgramming(Settings { - model: "gpt-5.2-codex".to_string(), - reasoning_effort: Some(ReasoningEffort::Medium), - developer_instructions: Some(COLLABORATION_MODE_PAIR_PROGRAMMING.to_string()), - }) + CollaborationMode { + mode: ModeKind::PairProgramming, + settings: Settings { + model: "gpt-5.2-codex".to_string(), + reasoning_effort: Some(ReasoningEffort::Medium), + developer_instructions: Some(COLLABORATION_MODE_PAIR_PROGRAMMING.to_string()), + }, + } } fn execute_preset() -> CollaborationMode { - CollaborationMode::Execute(Settings { - model: "gpt-5.2-codex".to_string(), - reasoning_effort: Some(ReasoningEffort::High), - developer_instructions: Some(COLLABORATION_MODE_EXECUTE.to_string()), - }) + CollaborationMode { + mode: ModeKind::Execute, + settings: Settings { + model: "gpt-5.2-codex".to_string(), + reasoning_effort: Some(ReasoningEffort::High), + developer_instructions: Some(COLLABORATION_MODE_EXECUTE.to_string()), + }, + } } diff --git a/codex-rs/core/src/tools/handlers/request_user_input.rs b/codex-rs/core/src/tools/handlers/request_user_input.rs index aca43a1d12..d2c55b08df 100644 --- a/codex-rs/core/src/tools/handlers/request_user_input.rs +++ b/codex-rs/core/src/tools/handlers/request_user_input.rs @@ -7,7 +7,7 @@ use crate::tools::context::ToolPayload; use crate::tools::handlers::parse_arguments; use crate::tools::registry::ToolHandler; use crate::tools::registry::ToolKind; -use codex_protocol::config_types::CollaborationMode; +use codex_protocol::config_types::ModeKind; use codex_protocol::request_user_input::RequestUserInputArgs; pub struct RequestUserInputHandler; @@ -36,9 +36,9 @@ impl ToolHandler for RequestUserInputHandler { } }; - let disallowed_mode = match session.collaboration_mode().await { - CollaborationMode::Execute(_) => Some("Execute"), - CollaborationMode::Custom(_) => Some("Custom"), + let disallowed_mode = match session.collaboration_mode().await.mode { + ModeKind::Execute => Some("Execute"), + ModeKind::Custom => Some("Custom"), _ => None, }; if let Some(mode_name) = disallowed_mode { diff --git a/codex-rs/core/tests/suite/client.rs b/codex-rs/core/tests/suite/client.rs index eb08de075c..902609afc5 100644 --- a/codex-rs/core/tests/suite/client.rs +++ b/codex-rs/core/tests/suite/client.rs @@ -23,6 +23,7 @@ use codex_core::protocol::SessionSource; use codex_otel::OtelManager; use codex_protocol::ThreadId; use codex_protocol::config_types::CollaborationMode; +use codex_protocol::config_types::ModeKind; use codex_protocol::config_types::ReasoningSummary; use codex_protocol::config_types::Settings; use codex_protocol::config_types::Verbosity; @@ -887,11 +888,14 @@ async fn user_turn_collaboration_mode_overrides_model_and_effort() -> anyhow::Re .build(&server) .await?; - let collaboration_mode = CollaborationMode::Custom(Settings { - model: "gpt-5.1".to_string(), - reasoning_effort: Some(ReasoningEffort::High), - developer_instructions: None, - }); + let collaboration_mode = CollaborationMode { + mode: ModeKind::Custom, + settings: Settings { + model: "gpt-5.1".to_string(), + reasoning_effort: Some(ReasoningEffort::High), + developer_instructions: None, + }, + }; codex .submit(Op::UserTurn { diff --git a/codex-rs/core/tests/suite/collaboration_instructions.rs b/codex-rs/core/tests/suite/collaboration_instructions.rs index 3304111698..3c6619fcea 100644 --- a/codex-rs/core/tests/suite/collaboration_instructions.rs +++ b/codex-rs/core/tests/suite/collaboration_instructions.rs @@ -4,6 +4,7 @@ use codex_core::protocol::COLLABORATION_MODE_OPEN_TAG; use codex_core::protocol::EventMsg; use codex_core::protocol::Op; use codex_protocol::config_types::CollaborationMode; +use codex_protocol::config_types::ModeKind; use codex_protocol::config_types::Settings; use codex_protocol::user_input::UserInput; use core_test_support::responses::ev_completed; @@ -22,11 +23,14 @@ fn sse_completed(id: &str) -> String { } fn collab_mode_with_instructions(instructions: Option<&str>) -> CollaborationMode { - CollaborationMode::Custom(Settings { - model: "gpt-5.1".to_string(), - reasoning_effort: None, - developer_instructions: instructions.map(str::to_string), - }) + CollaborationMode { + mode: ModeKind::Custom, + settings: Settings { + model: "gpt-5.1".to_string(), + reasoning_effort: None, + developer_instructions: instructions.map(str::to_string), + }, + } } fn developer_texts(input: &[Value]) -> Vec { diff --git a/codex-rs/core/tests/suite/override_updates.rs b/codex-rs/core/tests/suite/override_updates.rs index 924fde733b..3ceb847daa 100644 --- a/codex-rs/core/tests/suite/override_updates.rs +++ b/codex-rs/core/tests/suite/override_updates.rs @@ -9,6 +9,7 @@ use codex_core::protocol::RolloutItem; use codex_core::protocol::RolloutLine; use codex_core::protocol::ENVIRONMENT_CONTEXT_OPEN_TAG; use codex_protocol::config_types::CollaborationMode; +use codex_protocol::config_types::ModeKind; use codex_protocol::config_types::Settings; use codex_protocol::models::ContentItem; use codex_protocol::models::ResponseItem; @@ -23,11 +24,14 @@ use std::time::Duration; use tempfile::TempDir; fn collab_mode_with_instructions(instructions: Option<&str>) -> CollaborationMode { - CollaborationMode::Custom(Settings { - model: "gpt-5.1".to_string(), - reasoning_effort: None, - developer_instructions: instructions.map(str::to_string), - }) + CollaborationMode { + mode: ModeKind::Custom, + settings: Settings { + model: "gpt-5.1".to_string(), + reasoning_effort: None, + developer_instructions: instructions.map(str::to_string), + }, + } } fn collab_xml(text: &str) -> String { diff --git a/codex-rs/core/tests/suite/prompt_caching.rs b/codex-rs/core/tests/suite/prompt_caching.rs index c7a3eb78a8..686428b21a 100644 --- a/codex-rs/core/tests/suite/prompt_caching.rs +++ b/codex-rs/core/tests/suite/prompt_caching.rs @@ -12,6 +12,7 @@ use codex_core::protocol_config_types::ReasoningSummary; use codex_core::shell::Shell; use codex_core::shell::default_user_shell; use codex_protocol::config_types::CollaborationMode; +use codex_protocol::config_types::ModeKind; use codex_protocol::config_types::Settings; use codex_protocol::config_types::WebSearchMode; use codex_protocol::openai_models::ReasoningEffort; @@ -412,11 +413,14 @@ async fn override_before_first_turn_emits_environment_context() -> anyhow::Resul let TestCodex { codex, .. } = test_codex().build(&server).await?; - let collaboration_mode = CollaborationMode::Custom(Settings { - model: "gpt-5.1".to_string(), - reasoning_effort: Some(ReasoningEffort::High), - developer_instructions: None, - }); + let collaboration_mode = CollaborationMode { + mode: ModeKind::Custom, + settings: Settings { + model: "gpt-5.1".to_string(), + reasoning_effort: Some(ReasoningEffort::High), + developer_instructions: None, + }, + }; codex .submit(Op::OverrideTurnContext { diff --git a/codex-rs/core/tests/suite/request_user_input.rs b/codex-rs/core/tests/suite/request_user_input.rs index 59f6944577..74d3fc98f2 100644 --- a/codex-rs/core/tests/suite/request_user_input.rs +++ b/codex-rs/core/tests/suite/request_user_input.rs @@ -8,6 +8,7 @@ use codex_core::protocol::EventMsg; use codex_core::protocol::Op; use codex_core::protocol::SandboxPolicy; use codex_protocol::config_types::CollaborationMode; +use codex_protocol::config_types::ModeKind; use codex_protocol::config_types::ReasoningSummary; use codex_protocol::config_types::Settings; use codex_protocol::request_user_input::RequestUserInputAnswer; @@ -132,11 +133,14 @@ async fn request_user_input_round_trip_resolves_pending() -> anyhow::Result<()> model: session_model, effort: None, summary: ReasoningSummary::Auto, - collaboration_mode: Some(CollaborationMode::Plan(Settings { - model: session_configured.model.clone(), - reasoning_effort: None, - developer_instructions: None, - })), + collaboration_mode: Some(CollaborationMode { + mode: ModeKind::Plan, + settings: Settings { + model: session_configured.model.clone(), + reasoning_effort: None, + developer_instructions: None, + }, + }), personality: None, }) .await?; @@ -269,24 +273,26 @@ where #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn request_user_input_rejected_in_execute_mode() -> anyhow::Result<()> { - assert_request_user_input_rejected("Execute", |model| { - CollaborationMode::Execute(Settings { + assert_request_user_input_rejected("Execute", |model| CollaborationMode { + mode: ModeKind::Execute, + settings: Settings { model, reasoning_effort: None, developer_instructions: None, - }) + }, }) .await } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn request_user_input_rejected_in_custom_mode() -> anyhow::Result<()> { - assert_request_user_input_rejected("Custom", |model| { - CollaborationMode::Custom(Settings { + assert_request_user_input_rejected("Custom", |model| CollaborationMode { + mode: ModeKind::Custom, + settings: Settings { model, reasoning_effort: None, developer_instructions: None, - }) + }, }) .await } diff --git a/codex-rs/protocol/src/config_types.rs b/codex-rs/protocol/src/config_types.rs index 374a9d1b11..c242d07de4 100644 --- a/codex-rs/protocol/src/config_types.rs +++ b/codex-rs/protocol/src/config_types.rs @@ -163,31 +163,24 @@ pub enum ModeKind { /// Collaboration mode for a Codex session. #[derive(Clone, PartialEq, Eq, Hash, Debug, Serialize, Deserialize, JsonSchema, TS)] -#[serde(tag = "mode", rename_all = "lowercase")] -pub enum CollaborationMode { - Plan(Settings), - PairProgramming(Settings), - Execute(Settings), - Custom(Settings), +#[serde(rename_all = "lowercase")] +pub struct CollaborationMode { + pub mode: ModeKind, + pub settings: Settings, } impl CollaborationMode { - /// Returns a reference to the settings, regardless of variant. - fn settings(&self) -> &Settings { - match self { - CollaborationMode::Plan(settings) - | CollaborationMode::PairProgramming(settings) - | CollaborationMode::Execute(settings) - | CollaborationMode::Custom(settings) => settings, - } + /// Returns a reference to the settings. + fn settings_ref(&self) -> &Settings { + &self.settings } pub fn model(&self) -> &str { - self.settings().model.as_str() + self.settings_ref().model.as_str() } pub fn reasoning_effort(&self) -> Option { - self.settings().reasoning_effort + self.settings_ref().reasoning_effort } /// Updates the collaboration mode with new model and/or effort values. @@ -196,14 +189,14 @@ impl CollaborationMode { /// - `effort`: `Some(Some(e))` to set effort to `e`, `Some(None)` to clear effort, `None` to keep current effort /// - `developer_instructions`: `Some(s)` to update developer instructions, `None` to keep current /// - /// Returns a new `CollaborationMode` with updated values, preserving the variant. + /// Returns a new `CollaborationMode` with updated values, preserving the mode. pub fn with_updates( &self, model: Option, effort: Option>, developer_instructions: Option, ) -> Self { - let settings = self.settings(); + let settings = self.settings_ref(); let updated_settings = Settings { model: model.unwrap_or_else(|| settings.model.clone()), reasoning_effort: effort.unwrap_or(settings.reasoning_effort), @@ -211,13 +204,9 @@ impl CollaborationMode { .or_else(|| settings.developer_instructions.clone()), }; - match self { - CollaborationMode::Plan(_) => CollaborationMode::Plan(updated_settings), - CollaborationMode::PairProgramming(_) => { - CollaborationMode::PairProgramming(updated_settings) - } - CollaborationMode::Execute(_) => CollaborationMode::Execute(updated_settings), - CollaborationMode::Custom(_) => CollaborationMode::Custom(updated_settings), + CollaborationMode { + mode: self.mode, + settings: updated_settings, } } } diff --git a/codex-rs/protocol/src/models.rs b/codex-rs/protocol/src/models.rs index f173d39645..57241bb8b6 100644 --- a/codex-rs/protocol/src/models.rs +++ b/codex-rs/protocol/src/models.rs @@ -263,13 +263,8 @@ impl DeveloperInstructions { /// Returns developer instructions from a collaboration mode if they exist and are non-empty. pub fn from_collaboration_mode(collaboration_mode: &CollaborationMode) -> Option { - let settings = match collaboration_mode { - CollaborationMode::Plan(settings) - | CollaborationMode::PairProgramming(settings) - | CollaborationMode::Execute(settings) - | CollaborationMode::Custom(settings) => settings, - }; - settings + collaboration_mode + .settings .developer_instructions .as_ref() .filter(|instructions| !instructions.is_empty()) diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 137e0f3195..89f6a96c5c 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -910,7 +910,7 @@ impl ChatWidget { if !self.queued_user_messages.is_empty() { return; } - if !matches!(self.stored_collaboration_mode, CollaborationMode::Plan(_)) { + if self.stored_collaboration_mode.mode != ModeKind::Plan { return; } let has_message = last_agent_message.is_some_and(|message| !message.trim().is_empty()); @@ -1943,7 +1943,10 @@ impl ChatWidget { config.experimental_mode, ) } else { - CollaborationMode::Custom(fallback_custom) + CollaborationMode { + mode: ModeKind::Custom, + settings: fallback_custom, + } }; let active_cell = Some(Self::placeholder_session_header_cell(&config)); @@ -2061,7 +2064,10 @@ impl ChatWidget { config.experimental_mode, ) } else { - CollaborationMode::Custom(fallback_custom) + CollaborationMode { + mode: ModeKind::Custom, + settings: fallback_custom, + } }; let active_cell = Some(Self::placeholder_session_header_cell(&config)); @@ -2182,7 +2188,10 @@ impl ChatWidget { config.experimental_mode, ) } else { - CollaborationMode::Custom(fallback_custom) + CollaborationMode { + mode: ModeKind::Custom, + settings: fallback_custom, + } }; let mut widget = Self { @@ -3552,11 +3561,11 @@ impl ChatWidget { let items: Vec = presets .into_iter() .map(|preset| { - let name = match preset { - CollaborationMode::Plan(_) => "Plan", - CollaborationMode::PairProgramming(_) => "Pair Programming", - CollaborationMode::Execute(_) => "Execute", - CollaborationMode::Custom(_) => "Custom", + let name = match preset.mode { + ModeKind::Plan => "Plan", + ModeKind::PairProgramming => "Pair Programming", + ModeKind::Execute => "Execute", + ModeKind::Custom => "Custom", }; let is_current = collaboration_modes::same_variant(&self.stored_collaboration_mode, &preset); @@ -4538,12 +4547,7 @@ impl ChatWidget { } if feature == Feature::CollaborationModes { self.bottom_pane.set_collaboration_modes_enabled(enabled); - let settings = match &self.stored_collaboration_mode { - CollaborationMode::Plan(settings) - | CollaborationMode::PairProgramming(settings) - | CollaborationMode::Execute(settings) - | CollaborationMode::Custom(settings) => settings.clone(), - }; + let settings = self.stored_collaboration_mode.settings.clone(); let fallback_custom = settings.clone(); self.stored_collaboration_mode = if enabled { initial_collaboration_mode( @@ -4552,7 +4556,10 @@ impl ChatWidget { self.config.experimental_mode, ) } else { - CollaborationMode::Custom(settings) + CollaborationMode { + mode: ModeKind::Custom, + settings, + } }; self.update_collaboration_mode_indicator(); } @@ -4632,11 +4639,11 @@ impl ChatWidget { if !self.collaboration_modes_enabled() { return None; } - match &self.stored_collaboration_mode { - CollaborationMode::Plan(_) => Some("Plan"), - CollaborationMode::PairProgramming(_) => Some("Pair Programming"), - CollaborationMode::Execute(_) => Some("Execute"), - CollaborationMode::Custom(_) => None, + match self.stored_collaboration_mode.mode { + ModeKind::Plan => Some("Plan"), + ModeKind::PairProgramming => Some("Pair Programming"), + ModeKind::Execute => Some("Execute"), + ModeKind::Custom => None, } } @@ -4644,13 +4651,11 @@ impl ChatWidget { if !self.collaboration_modes_enabled() { return None; } - match &self.stored_collaboration_mode { - CollaborationMode::Plan(_) => Some(CollaborationModeIndicator::Plan), - CollaborationMode::PairProgramming(_) => { - Some(CollaborationModeIndicator::PairProgramming) - } - CollaborationMode::Execute(_) => Some(CollaborationModeIndicator::Execute), - CollaborationMode::Custom(_) => None, + match self.stored_collaboration_mode.mode { + ModeKind::Plan => Some(CollaborationModeIndicator::Plan), + ModeKind::PairProgramming => Some(CollaborationModeIndicator::PairProgramming), + ModeKind::Execute => Some(CollaborationModeIndicator::Execute), + ModeKind::Custom => None, } } @@ -4681,7 +4686,8 @@ impl ChatWidget { if !self.collaboration_modes_enabled() { return; } - + let old_model = self.stored_collaboration_mode.model().to_string(); + let mode = mode.with_updates(Some(old_model), None, None); self.stored_collaboration_mode = mode; self.update_collaboration_mode_indicator(); self.request_redraw(); @@ -5304,15 +5310,20 @@ fn initial_collaboration_mode( ) -> CollaborationMode { if let Some(kind) = desired_mode { if kind == ModeKind::Custom { - return CollaborationMode::Custom(fallback_custom); + return CollaborationMode { + mode: ModeKind::Custom, + settings: fallback_custom, + }; } if let Some(mode) = collaboration_modes::mode_for_kind(models_manager, kind) { return mode; } } - collaboration_modes::default_mode(models_manager) - .unwrap_or(CollaborationMode::Custom(fallback_custom)) + collaboration_modes::default_mode(models_manager).unwrap_or(CollaborationMode { + mode: ModeKind::Custom, + settings: fallback_custom, + }) } async fn fetch_rate_limits(base_url: String, auth: CodexAuth) -> Option { diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index 1fb199b0d8..9fe7d3a1c9 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -64,6 +64,8 @@ use codex_otel::OtelManager; use codex_protocol::ThreadId; use codex_protocol::account::PlanType; use codex_protocol::config_types::CollaborationMode; +use codex_protocol::config_types::ModeKind; +use codex_protocol::config_types::Settings; use codex_protocol::openai_models::ModelPreset; use codex_protocol::openai_models::ReasoningEffortPreset; use codex_protocol::parse_command::ParsedCommand; @@ -779,18 +781,24 @@ async fn make_chatwidget_manual( let reasoning_effort = None; let stored_collaboration_mode = if collaboration_modes_enabled { collaboration_modes::default_mode(models_manager.as_ref()).unwrap_or_else(|| { - CollaborationMode::Custom(Settings { + CollaborationMode { + mode: ModeKind::Custom, + settings: Settings { + model: resolved_model.clone(), + reasoning_effort, + developer_instructions: None, + }, + } + }) + } else { + CollaborationMode { + mode: ModeKind::Custom, + settings: Settings { model: resolved_model.clone(), reasoning_effort, developer_instructions: None, - }) - }) - } else { - CollaborationMode::Custom(Settings { - model: resolved_model.clone(), - reasoning_effort, - developer_instructions: None, - }) + }, + } }; let widget = ChatWidget { app_event_tx, @@ -1206,7 +1214,7 @@ async fn plan_implementation_popup_yes_emits_submit_message_event() { panic!("expected SubmitUserMessageWithMode, got {event:?}"); }; assert_eq!(text, PLAN_IMPLEMENTATION_EXECUTE_MESSAGE); - assert!(matches!(collaboration_mode, CollaborationMode::Execute(_))); + assert_eq!(collaboration_mode.mode, ModeKind::Execute); } #[tokio::test] @@ -1221,7 +1229,11 @@ async fn submit_user_message_with_mode_sets_execute_collaboration_mode() { match next_submit_op(&mut op_rx) { Op::UserTurn { - collaboration_mode: Some(CollaborationMode::Execute(_)), + collaboration_mode: + Some(CollaborationMode { + mode: ModeKind::Execute, + .. + }), personality: None, .. } => {} @@ -1235,11 +1247,14 @@ async fn submit_user_message_with_mode_sets_execute_collaboration_mode() { async fn plan_implementation_popup_skips_replayed_turn_complete() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5")).await; chat.set_feature_enabled(Feature::CollaborationModes, true); - chat.stored_collaboration_mode = CollaborationMode::Plan(Settings { - model: chat.current_model().to_string(), - reasoning_effort: None, - developer_instructions: None, - }); + chat.stored_collaboration_mode = CollaborationMode { + mode: ModeKind::Plan, + settings: Settings { + model: chat.current_model().to_string(), + reasoning_effort: None, + developer_instructions: None, + }, + }; chat.replay_initial_messages(vec![EventMsg::TurnComplete(TurnCompleteEvent { last_agent_message: Some("Plan details".to_string()), @@ -1256,11 +1271,14 @@ async fn plan_implementation_popup_skips_replayed_turn_complete() { async fn plan_implementation_popup_skips_when_messages_queued() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5")).await; chat.set_feature_enabled(Feature::CollaborationModes, true); - chat.stored_collaboration_mode = CollaborationMode::Plan(Settings { - model: chat.current_model().to_string(), - reasoning_effort: None, - developer_instructions: None, - }); + chat.stored_collaboration_mode = CollaborationMode { + mode: ModeKind::Plan, + settings: Settings { + model: chat.current_model().to_string(), + reasoning_effort: None, + developer_instructions: None, + }, + }; chat.bottom_pane.set_task_running(true); chat.queue_user_message("Queued message".into()); @@ -1277,11 +1295,14 @@ async fn plan_implementation_popup_skips_when_messages_queued() { async fn plan_implementation_popup_shows_on_plan_update_without_message() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5")).await; chat.set_feature_enabled(Feature::CollaborationModes, true); - chat.stored_collaboration_mode = CollaborationMode::Plan(Settings { - model: chat.current_model().to_string(), - reasoning_effort: None, - developer_instructions: None, - }); + chat.stored_collaboration_mode = CollaborationMode { + mode: ModeKind::Plan, + settings: Settings { + model: chat.current_model().to_string(), + reasoning_effort: None, + developer_instructions: None, + }, + }; chat.on_task_started(); chat.on_plan_update(UpdatePlanArgs { @@ -1306,11 +1327,14 @@ async fn plan_implementation_popup_skips_when_rate_limit_prompt_pending() { chat.auth_manager = AuthManager::from_auth_for_testing(CodexAuth::create_dummy_chatgpt_auth_for_testing()); chat.set_feature_enabled(Feature::CollaborationModes, true); - chat.stored_collaboration_mode = CollaborationMode::Plan(Settings { - model: chat.current_model().to_string(), - reasoning_effort: None, - developer_instructions: None, - }); + chat.stored_collaboration_mode = CollaborationMode { + mode: ModeKind::Plan, + settings: Settings { + model: chat.current_model().to_string(), + reasoning_effort: None, + developer_instructions: None, + }, + }; chat.on_task_started(); chat.on_plan_update(UpdatePlanArgs { @@ -2205,16 +2229,10 @@ async fn collab_mode_shift_tab_cycles_only_when_enabled_and_idle() { chat.set_feature_enabled(Feature::CollaborationModes, true); chat.handle_key_event(KeyEvent::from(KeyCode::BackTab)); - assert!(matches!( - chat.stored_collaboration_mode, - CollaborationMode::Execute(_) - )); + assert_eq!(chat.stored_collaboration_mode.mode, ModeKind::Execute); chat.handle_key_event(KeyEvent::from(KeyCode::BackTab)); - assert!(matches!( - chat.stored_collaboration_mode, - CollaborationMode::Plan(_) - )); + assert_eq!(chat.stored_collaboration_mode.mode, ModeKind::Plan); chat.on_task_started(); let before = chat.stored_collaboration_mode.clone(); @@ -2247,7 +2265,11 @@ async fn collab_slash_command_opens_picker_and_updates_mode() { chat.handle_key_event(KeyEvent::from(KeyCode::Enter)); match next_submit_op(&mut op_rx) { Op::UserTurn { - collaboration_mode: Some(CollaborationMode::PairProgramming(_)), + collaboration_mode: + Some(CollaborationMode { + mode: ModeKind::PairProgramming, + .. + }), personality: None, .. } => {} @@ -2261,7 +2283,11 @@ async fn collab_slash_command_opens_picker_and_updates_mode() { chat.handle_key_event(KeyEvent::from(KeyCode::Enter)); match next_submit_op(&mut op_rx) { Op::UserTurn { - collaboration_mode: Some(CollaborationMode::PairProgramming(_)), + collaboration_mode: + Some(CollaborationMode { + mode: ModeKind::PairProgramming, + .. + }), personality: None, .. } => {} @@ -2282,7 +2308,11 @@ async fn collab_mode_defaults_to_pair_programming_when_enabled() { chat.handle_key_event(KeyEvent::from(KeyCode::Enter)); match next_submit_op(&mut op_rx) { Op::UserTurn { - collaboration_mode: Some(CollaborationMode::PairProgramming(_)), + collaboration_mode: + Some(CollaborationMode { + mode: ModeKind::PairProgramming, + .. + }), personality: None, .. } => {} @@ -2296,10 +2326,10 @@ async fn collab_mode_defaults_to_pair_programming_when_enabled() { async fn collab_mode_enabling_sets_pair_programming_default() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; chat.set_feature_enabled(Feature::CollaborationModes, true); - assert!(matches!( - chat.stored_collaboration_mode, - CollaborationMode::PairProgramming(_) - )); + assert_eq!( + chat.stored_collaboration_mode.mode, + ModeKind::PairProgramming + ); } #[tokio::test] diff --git a/codex-rs/tui/src/collaboration_modes.rs b/codex-rs/tui/src/collaboration_modes.rs index 7059165552..df034053d4 100644 --- a/codex-rs/tui/src/collaboration_modes.rs +++ b/codex-rs/tui/src/collaboration_modes.rs @@ -3,19 +3,14 @@ use codex_protocol::config_types::CollaborationMode; use codex_protocol::config_types::ModeKind; fn mode_kind(mode: &CollaborationMode) -> ModeKind { - match mode { - CollaborationMode::Plan(_) => ModeKind::Plan, - CollaborationMode::PairProgramming(_) => ModeKind::PairProgramming, - CollaborationMode::Execute(_) => ModeKind::Execute, - CollaborationMode::Custom(_) => ModeKind::Custom, - } + mode.mode } pub(crate) fn default_mode(models_manager: &ModelsManager) -> Option { let presets = models_manager.list_collaboration_modes(); presets .iter() - .find(|preset| matches!(preset, CollaborationMode::PairProgramming(_))) + .find(|preset| preset.mode == ModeKind::PairProgramming) .cloned() .or_else(|| presets.into_iter().next()) } From 77222492f9a648106495c7741716c93b2174855b Mon Sep 17 00:00:00 2001 From: viyatb-oai Date: Fri, 23 Jan 2026 20:47:09 -0500 Subject: [PATCH 17/36] feat: introducing a network sandbox proxy (#8442) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This add a new crate, `codex-network-proxy`, a local network proxy service used by Codex to enforce fine-grained network policy (domain allow/deny) and to surface blocked network events for interactive approvals. - New crate: `codex-rs/network-proxy/` (`codex-network-proxy` binary + library) - Core capabilities: - HTTP proxy support (including CONNECT tunneling) - SOCKS5 proxy support (in the later PR) - policy evaluation (allowed/denied domain lists; denylist wins; wildcard support) - small admin API for polling/reload/mode changes - optional MITM support for HTTPS CONNECT to enforce “limited mode” method restrictions (later PR) Will follow up integration with codex in subsequent PRs. ## Testing - `cd codex-rs && cargo build -p codex-network-proxy` - `cd codex-rs && cargo run -p codex-network-proxy -- proxy` --- .github/scripts/install-musl-build-tools.sh | 61 ++ .github/workflows/rust-ci.yml | 8 +- .github/workflows/rust-release.yml | 6 +- .github/workflows/shell-tool-mcp.yml | 6 +- codex-rs/Cargo.lock | 846 +++++++++++++++- codex-rs/Cargo.toml | 2 + codex-rs/deny.toml | 1 + codex-rs/network-proxy/Cargo.toml | 45 + codex-rs/network-proxy/README.md | 169 ++++ codex-rs/network-proxy/src/admin.rs | 160 +++ codex-rs/network-proxy/src/config.rs | 433 ++++++++ codex-rs/network-proxy/src/http_proxy.rs | 636 ++++++++++++ codex-rs/network-proxy/src/lib.rs | 29 + codex-rs/network-proxy/src/main.rs | 14 + codex-rs/network-proxy/src/network_policy.rs | 234 +++++ codex-rs/network-proxy/src/policy.rs | 435 ++++++++ codex-rs/network-proxy/src/proxy.rs | 176 ++++ codex-rs/network-proxy/src/reasons.rs | 6 + codex-rs/network-proxy/src/responses.rs | 67 ++ codex-rs/network-proxy/src/runtime.rs | 984 +++++++++++++++++++ codex-rs/network-proxy/src/state.rs | 419 ++++++++ codex-rs/network-proxy/src/upstream.rs | 188 ++++ 22 files changed, 4904 insertions(+), 21 deletions(-) create mode 100644 .github/scripts/install-musl-build-tools.sh create mode 100644 codex-rs/network-proxy/Cargo.toml create mode 100644 codex-rs/network-proxy/README.md create mode 100644 codex-rs/network-proxy/src/admin.rs create mode 100644 codex-rs/network-proxy/src/config.rs create mode 100644 codex-rs/network-proxy/src/http_proxy.rs create mode 100644 codex-rs/network-proxy/src/lib.rs create mode 100644 codex-rs/network-proxy/src/main.rs create mode 100644 codex-rs/network-proxy/src/network_policy.rs create mode 100644 codex-rs/network-proxy/src/policy.rs create mode 100644 codex-rs/network-proxy/src/proxy.rs create mode 100644 codex-rs/network-proxy/src/reasons.rs create mode 100644 codex-rs/network-proxy/src/responses.rs create mode 100644 codex-rs/network-proxy/src/runtime.rs create mode 100644 codex-rs/network-proxy/src/state.rs create mode 100644 codex-rs/network-proxy/src/upstream.rs diff --git a/.github/scripts/install-musl-build-tools.sh b/.github/scripts/install-musl-build-tools.sh new file mode 100644 index 0000000000..e2af7528d5 --- /dev/null +++ b/.github/scripts/install-musl-build-tools.sh @@ -0,0 +1,61 @@ +#!/usr/bin/env bash +set -euo pipefail + +: "${TARGET:?TARGET environment variable is required}" +: "${GITHUB_ENV:?GITHUB_ENV environment variable is required}" + +apt_update_args=() +if [[ -n "${APT_UPDATE_ARGS:-}" ]]; then + # shellcheck disable=SC2206 + apt_update_args=(${APT_UPDATE_ARGS}) +fi + +apt_install_args=() +if [[ -n "${APT_INSTALL_ARGS:-}" ]]; then + # shellcheck disable=SC2206 + apt_install_args=(${APT_INSTALL_ARGS}) +fi + +sudo apt-get update "${apt_update_args[@]}" +sudo apt-get install -y "${apt_install_args[@]}" musl-tools pkg-config g++ clang libc++-dev libc++abi-dev lld + +case "${TARGET}" in + x86_64-unknown-linux-musl) + arch="x86_64" + ;; + aarch64-unknown-linux-musl) + arch="aarch64" + ;; + *) + echo "Unexpected musl target: ${TARGET}" >&2 + exit 1 + ;; +esac + +if command -v clang++ >/dev/null; then + cxx="$(command -v clang++)" + echo "CXXFLAGS=--target=${TARGET} -stdlib=libc++ -pthread" >> "$GITHUB_ENV" + echo "CFLAGS=--target=${TARGET} -pthread" >> "$GITHUB_ENV" + if command -v clang >/dev/null; then + cc="$(command -v clang)" + echo "CC=${cc}" >> "$GITHUB_ENV" + echo "TARGET_CC=${cc}" >> "$GITHUB_ENV" + target_cc_var="CC_${TARGET}" + target_cc_var="${target_cc_var//-/_}" + echo "${target_cc_var}=${cc}" >> "$GITHUB_ENV" + fi +elif command -v "${arch}-linux-musl-g++" >/dev/null; then + cxx="$(command -v "${arch}-linux-musl-g++")" +elif command -v musl-g++ >/dev/null; then + cxx="$(command -v musl-g++)" +elif command -v musl-gcc >/dev/null; then + cxx="$(command -v musl-gcc)" + echo "CFLAGS=-pthread" >> "$GITHUB_ENV" +else + echo "musl g++ not found after install; arch=${arch}" >&2 + exit 1 +fi + +echo "CXX=${cxx}" >> "$GITHUB_ENV" +echo "CMAKE_CXX_COMPILER=${cxx}" >> "$GITHUB_ENV" +echo "CMAKE_ARGS=-DCMAKE_HAVE_THREADS_LIBRARY=1 -DCMAKE_USE_PTHREADS_INIT=1 -DCMAKE_THREAD_LIBS_INIT=-pthread -DTHREADS_PREFER_PTHREAD_FLAG=ON" >> "$GITHUB_ENV" diff --git a/.github/workflows/rust-ci.yml b/.github/workflows/rust-ci.yml index 22a1a8320e..a6bf51369b 100644 --- a/.github/workflows/rust-ci.yml +++ b/.github/workflows/rust-ci.yml @@ -265,11 +265,11 @@ jobs: name: Install musl build tools env: DEBIAN_FRONTEND: noninteractive + TARGET: ${{ matrix.target }} + APT_UPDATE_ARGS: -o Acquire::Retries=3 + APT_INSTALL_ARGS: --no-install-recommends shell: bash - run: | - set -euo pipefail - sudo apt-get -y update -o Acquire::Retries=3 - sudo apt-get -y install --no-install-recommends musl-tools pkg-config + run: bash "${GITHUB_WORKSPACE}/.github/scripts/install-musl-build-tools.sh" - name: Install cargo-chef if: ${{ matrix.profile == 'release' }} diff --git a/.github/workflows/rust-release.yml b/.github/workflows/rust-release.yml index 1ab7f51755..9033d058d7 100644 --- a/.github/workflows/rust-release.yml +++ b/.github/workflows/rust-release.yml @@ -106,9 +106,9 @@ jobs: - if: ${{ matrix.target == 'x86_64-unknown-linux-musl' || matrix.target == 'aarch64-unknown-linux-musl'}} name: Install musl build tools - run: | - sudo apt-get update - sudo apt-get install -y musl-tools pkg-config + env: + TARGET: ${{ matrix.target }} + run: bash "${GITHUB_WORKSPACE}/.github/scripts/install-musl-build-tools.sh" - name: Cargo build shell: bash diff --git a/.github/workflows/shell-tool-mcp.yml b/.github/workflows/shell-tool-mcp.yml index 62a4989b77..0e03fea43c 100644 --- a/.github/workflows/shell-tool-mcp.yml +++ b/.github/workflows/shell-tool-mcp.yml @@ -99,9 +99,9 @@ jobs: - if: ${{ matrix.install_musl }} name: Install musl build dependencies - run: | - sudo apt-get update - sudo apt-get install -y musl-tools pkg-config + env: + TARGET: ${{ matrix.target }} + run: bash "${GITHUB_WORKSPACE}/.github/scripts/install-musl-build-tools.sh" - name: Build exec server binaries run: cargo build --release --target ${{ matrix.target }} --bin codex-exec-mcp-server --bin codex-execve-wrapper diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index c8fc0223f6..2f2da13454 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -193,6 +193,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" dependencies = [ "cfg-if", + "getrandom 0.3.3", "once_cell", "version_check", "zerocopy", @@ -261,7 +262,7 @@ version = "7.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "67555e1f1ece39d737e28c8a017721287753af3f93225e4a445b29ccb0f5912c" dependencies = [ - "nom", + "nom 7.1.3", "ratatui", "simdutf8", "smallvec", @@ -591,6 +592,16 @@ dependencies = [ "syn 2.0.104", ] +[[package]] +name = "asynk-strim" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52697735bdaac441a29391a9e97102c74c6ef0f9b60a40cf109b1b404e29d2f6" +dependencies = [ + "futures-core", + "pin-project-lite", +] + [[package]] name = "atomic-waker" version = "1.1.2" @@ -618,7 +629,7 @@ dependencies = [ "hyper", "hyper-util", "itoa", - "matchit", + "matchit 0.8.4", "memchr", "mime", "percent-encoding", @@ -686,6 +697,24 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3a8241f3ebb85c056b509d4327ad0358fbbba6ffb340bf388f26350aeda225b1" +[[package]] +name = "bindgen" +version = "0.72.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "993776b509cfb49c750f11b8f07a46fa23e0a1386ffc01fb1e7d343efc387895" +dependencies = [ + "bitflags 2.10.0", + "cexpr", + "clang-sys", + "itertools 0.13.0", + "proc-macro2", + "quote", + "regex", + "rustc-hash", + "shlex", + "syn 2.0.104", +] + [[package]] name = "bit-set" version = "0.5.3" @@ -744,6 +773,15 @@ dependencies = [ "piper", ] +[[package]] +name = "borsh" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1da5ab77c1437701eeff7c88d968729e7766172279eab0676857b3d63af7a6f" +dependencies = [ + "cfg_aliases 0.2.1", +] + [[package]] name = "bstr" version = "1.12.0" @@ -820,10 +858,11 @@ dependencies = [ [[package]] name = "cc" -version = "1.2.30" +version = "1.2.52" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "deec109607ca693028562ed836a5f1c4b8bd77755c4e132fc5ce11b0b6211ae7" +checksum = "cd4932aefd12402b36c60956a4fe0035421f544799057659ff86f923657aada3" dependencies = [ + "find-msvc-tools", "jobserver", "libc", "shlex", @@ -835,6 +874,15 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" +[[package]] +name = "cexpr" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766" +dependencies = [ + "nom 7.1.3", +] + [[package]] name = "cfg-if" version = "1.0.1" @@ -894,6 +942,17 @@ dependencies = [ "inout", ] +[[package]] +name = "clang-sys" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b023947811758c97c59bf9d1c188fd619ad4718dcaa767947df1cadb14f39f4" +dependencies = [ + "glob", + "libc", + "libloading", +] + [[package]] name = "clap" version = "4.5.54" @@ -953,6 +1012,15 @@ dependencies = [ "error-code", ] +[[package]] +name = "cmake" +version = "0.1.57" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75443c44cd6b379beb8c5b45d85d0773baf31cce901fe7bb252f4eff3008ef7d" +dependencies = [ + "cc", +] + [[package]] name = "cmp_any" version = "0.8.1" @@ -1599,6 +1667,35 @@ dependencies = [ "wiremock", ] +[[package]] +name = "codex-network-proxy" +version = "0.0.0" +dependencies = [ + "anyhow", + "async-trait", + "clap", + "codex-app-server-protocol", + "codex-core", + "codex-utils-absolute-path", + "globset", + "pretty_assertions", + "rama-core", + "rama-http", + "rama-http-backend", + "rama-net", + "rama-tcp", + "rama-tls-boring", + "rama-unix", + "serde", + "serde_json", + "tempfile", + "time", + "tokio", + "tracing", + "tracing-subscriber", + "url", +] + [[package]] name = "codex-ollama" version = "0.0.0" @@ -2009,6 +2106,26 @@ dependencies = [ "serde_core", ] +[[package]] +name = "const_format" +version = "0.2.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7faa7469a93a566e9ccc1c73fe783b4a65c274c5ace346038dca9c39fe0030ad" +dependencies = [ + "const_format_proc_macros", +] + +[[package]] +name = "const_format_proc_macros" +version = "0.2.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d57c2eccfb16dbac1f4e61e206105db5820c9d26c3c472bc17c774259ef7744" +dependencies = [ + "proc-macro2", + "quote", + "unicode-xid", +] + [[package]] name = "convert_case" version = "0.6.0" @@ -2096,6 +2213,12 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "critical-section" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" + [[package]] name = "crossbeam-channel" version = "0.5.15" @@ -2171,6 +2294,27 @@ dependencies = [ "typenum", ] +[[package]] +name = "csv" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52cd9d68cf7efc6ddfaaee42e7288d3a99d613d4b50f76ce9827ae0c6e14f938" +dependencies = [ + "csv-core", + "itoa", + "ryu", + "serde_core", +] + +[[package]] +name = "csv-core" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "704a3c26996a80471189265814dbc2c257598b96b8a7feae2d31ace646bb9782" +dependencies = [ + "memchr", +] + [[package]] name = "ctor" version = "0.1.26" @@ -2663,6 +2807,24 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c34f04666d835ff5d62e058c3995147c06f42fe86ff053337632bca83e42702d" +[[package]] +name = "endian-type" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "869b0adbda23651a9c5c0c3d270aac9fcb52e8622a8f2b17e57802d7791962f2" + +[[package]] +name = "enum-as-inner" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1e6a265c649f3f5979b601d26f1d05ada116434c87741c9493cb56218f76cbc" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.104", +] + [[package]] name = "enumflags2" version = "0.7.12" @@ -2778,7 +2940,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "74fef4569247a5f429d9156b9d0a2599914385dd189c539334c625d8099d90ab" dependencies = [ "futures-core", - "nom", + "nom 7.1.3", "pin-project-lite", ] @@ -2809,6 +2971,9 @@ name = "fastrand" version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" +dependencies = [ + "getrandom 0.2.16", +] [[package]] name = "fax" @@ -2861,6 +3026,12 @@ dependencies = [ "winapi", ] +[[package]] +name = "find-msvc-tools" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f449e6c6c08c865631d4890cfacf252b3d396c9bcc83adb6623cdb02a8336c41" + [[package]] name = "findshlibs" version = "0.10.2" @@ -2909,6 +3080,18 @@ dependencies = [ "num-traits", ] +[[package]] +name = "flume" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e139bc46ca777eb5efaf62df0ab8cc5fd400866427e56c68b22e414e53bd3be" +dependencies = [ + "fastrand", + "futures-core", + "futures-sink", + "spin", +] + [[package]] name = "fnv" version = "1.0.7" @@ -2933,7 +3116,28 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" dependencies = [ - "foreign-types-shared", + "foreign-types-shared 0.1.1", +] + +[[package]] +name = "foreign-types" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965" +dependencies = [ + "foreign-types-macros", + "foreign-types-shared 0.3.1", +] + +[[package]] +name = "foreign-types-macros" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a5c6c585bc94aaf2c7b51dd4c2ba22680844aba4c687be581871a6f518c5742" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.104", ] [[package]] @@ -2942,6 +3146,12 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" +[[package]] +name = "foreign-types-shared" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b" + [[package]] name = "form_urlencoded" version = "1.2.1" @@ -2951,6 +3161,12 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "fs_extra" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" + [[package]] name = "fsevent-sys" version = "4.1.0" @@ -2960,6 +3176,16 @@ dependencies = [ "libc", ] +[[package]] +name = "fslock" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04412b8935272e3a9bae6f48c7bfff74c2911f60525404edfdd28e49884c3bfb" +dependencies = [ + "libc", + "winapi", +] + [[package]] name = "futures" version = "0.3.31" @@ -3071,6 +3297,21 @@ dependencies = [ "byteorder", ] +[[package]] +name = "generator" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52f04ae4152da20c76fe800fa48659201d5cf627c5149ca0b707b69d7eef6cf9" +dependencies = [ + "cc", + "cfg-if", + "libc", + "log", + "rustversion", + "windows-link 0.2.0", + "windows-result 0.3.4", +] + [[package]] name = "generic-array" version = "0.14.7" @@ -3133,6 +3374,12 @@ version = "0.31.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "07e28edb80900c19c28f1072f2e8aeca7fa06b23cd4169cefe1af5aa3260783f" +[[package]] +name = "glob" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" + [[package]] name = "globset" version = "0.4.16" @@ -3231,6 +3478,52 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +[[package]] +name = "hickory-proto" +version = "0.25.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8a6fe56c0038198998a6f217ca4e7ef3a5e51f46163bd6dd60b5c71ca6c6502" +dependencies = [ + "async-trait", + "cfg-if", + "data-encoding", + "enum-as-inner", + "futures-channel", + "futures-io", + "futures-util", + "idna", + "ipnet", + "once_cell", + "rand 0.9.2", + "ring", + "thiserror 2.0.17", + "tinyvec", + "tokio", + "tracing", + "url", +] + +[[package]] +name = "hickory-resolver" +version = "0.25.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc62a9a99b0bfb44d2ab95a7208ac952d31060efc16241c87eaf36406fecf87a" +dependencies = [ + "cfg-if", + "futures-util", + "hickory-proto", + "ipconfig", + "moka", + "once_cell", + "parking_lot", + "rand 0.9.2", + "resolv-conf", + "smallvec", + "thiserror 2.0.17", + "tokio", + "tracing", +] + [[package]] name = "hkdf" version = "0.12.4" @@ -3314,6 +3607,12 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "http-range-header" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9171a2ea8a68358193d15dd5d70c1c10a2afc3e7e4c5bc92bc9f025cebd7359c" + [[package]] name = "httparse" version = "1.10.1" @@ -3755,6 +4054,18 @@ dependencies = [ "rustversion", ] +[[package]] +name = "ipconfig" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b58db92f96b720de98181bbbe63c831e87005ab460c1bf306eb2622b4707997f" +dependencies = [ + "socket2 0.5.10", + "widestring", + "windows-sys 0.48.0", + "winreg 0.50.0", +] + [[package]] name = "ipnet" version = "2.11.0" @@ -4000,6 +4311,16 @@ dependencies = [ "pkg-config", ] +[[package]] +name = "libloading" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" +dependencies = [ + "cfg-if", + "windows-link 0.2.0", +] + [[package]] name = "libredox" version = "0.1.6" @@ -4083,6 +4404,22 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "loom" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "419e0dc8046cb947daa77eb95ae174acfbddb7673b4151f56d1eed8e93fbfaca" +dependencies = [ + "cfg-if", + "generator", + "pin-utils", + "scoped-tls", + "serde", + "serde_json", + "tracing", + "tracing-subscriber", +] + [[package]] name = "lru" version = "0.12.5" @@ -4141,6 +4478,12 @@ version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" +[[package]] +name = "matchit" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3eede3bdf92f3b4f9dc04072a9ce5ab557d5ec9038773bf9ffcd5588b3cc05b" + [[package]] name = "mcp-types" version = "0.0.0" @@ -4170,6 +4513,12 @@ dependencies = [ "wiremock", ] +[[package]] +name = "md5" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae960838283323069879657ca3de837e9f7bbb4c7bf6ea7f1b290d5e9476d2e0" + [[package]] name = "memchr" version = "2.7.5" @@ -4238,6 +4587,23 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "moka" +version = "0.12.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3dec6bd31b08944e08b58fd99373893a6c17054d6f3ea5006cc894f4f4eee2a" +dependencies = [ + "crossbeam-channel", + "crossbeam-epoch", + "crossbeam-utils", + "equivalent", + "parking_lot", + "portable-atomic", + "smallvec", + "tagptr", + "uuid", +] + [[package]] name = "moxcms" version = "0.7.5" @@ -4342,6 +4708,15 @@ dependencies = [ "minimal-lexical", ] +[[package]] +name = "nom" +version = "8.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" +dependencies = [ + "memchr", +] + [[package]] name = "normalize-line-endings" version = "0.3.0" @@ -4596,6 +4971,10 @@ name = "once_cell" version = "1.21.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" +dependencies = [ + "critical-section", + "portable-atomic", +] [[package]] name = "once_cell_polyfill" @@ -4611,7 +4990,7 @@ checksum = "8505734d46c8ab1e19a1dce3aef597ad87dcb4c37e7188231769bd6bd51cebf8" dependencies = [ "bitflags 2.10.0", "cfg-if", - "foreign-types", + "foreign-types 0.3.2", "libc", "once_cell", "openssl-macros", @@ -5025,7 +5404,7 @@ dependencies = [ "shared_library", "shell-words", "winapi", - "winreg", + "winreg 0.10.1", ] [[package]] @@ -5170,6 +5549,21 @@ dependencies = [ "syn 2.0.104", ] +[[package]] +name = "psl" +version = "2.1.178" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a54161bdf033552d905f5e653fb6263690e63df54745ad5199a5f8fa802a0d6" +dependencies = [ + "psl-types", +] + +[[package]] +name = "psl-types" +version = "2.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33cb294fe86a74cbcf50d4445b37da762029549ebeea341421c7c70370f86cac" + [[package]] name = "pulldown-cmark" version = "0.10.3" @@ -5298,10 +5692,340 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c069c179fcdc6a2fe24d8d18305cf085fdbd4f922c041943e203685d6a1c58fd" dependencies = [ - "endian-type", + "endian-type 0.1.2", "nibble_vec", ] +[[package]] +name = "radix_trie" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b4431027dcd37fc2a73ef740b5f233aa805897935b8bce0195e41bbf9a3289a" +dependencies = [ + "endian-type 0.2.0", + "nibble_vec", +] + +[[package]] +name = "rama-boring" +version = "0.5.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "288926585d0b8ed1b1dd278a31ea1367007ad0bd4263ca84810e10939c2398a3" +dependencies = [ + "bitflags 2.10.0", + "foreign-types 0.5.0", + "libc", + "openssl-macros", + "rama-boring-sys", +] + +[[package]] +name = "rama-boring-sys" +version = "0.5.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "421ebb40444a6d740f867a5055a710a73e7cd74b9b389583b45e9b29d1330465" +dependencies = [ + "bindgen", + "cmake", + "fs_extra", + "fslock", +] + +[[package]] +name = "rama-boring-tokio" +version = "0.5.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "913cf3d377b37ff903cd57c2f6133ba7da5b7e0fe94821dec83daa8a024bb6ce" +dependencies = [ + "rama-boring", + "rama-boring-sys", + "tokio", +] + +[[package]] +name = "rama-core" +version = "0.3.0-alpha.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b93751ab27c9d151e84c1100057eab3f2a6a1378bc31b62abd416ecb1847658" +dependencies = [ + "ahash", + "asynk-strim", + "bytes", + "futures", + "parking_lot", + "pin-project-lite", + "rama-error", + "rama-macros", + "rama-utils", + "serde", + "serde_json", + "tokio", + "tokio-graceful", + "tokio-util", + "tracing", +] + +[[package]] +name = "rama-dns" +version = "0.3.0-alpha.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e340fef2799277e204260b17af01bc23604712092eacd6defe40167f304baed8" +dependencies = [ + "ahash", + "hickory-resolver", + "rama-core", + "rama-net", + "rama-utils", + "serde", + "tokio", +] + +[[package]] +name = "rama-error" +version = "0.3.0-alpha.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c452aba1beb7e29b873ff32f304536164cffcc596e786921aea64e858ff8f40" + +[[package]] +name = "rama-http" +version = "0.3.0-alpha.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "453d60af031e23af2d48995e41b17023f6150044738680508b63671f8d7417dd" +dependencies = [ + "ahash", + "base64", + "bitflags 2.10.0", + "chrono", + "const_format", + "csv", + "http 1.3.1", + "http-range-header", + "httpdate", + "iri-string", + "matchit 0.9.1", + "parking_lot", + "percent-encoding", + "pin-project-lite", + "radix_trie 0.3.0", + "rama-core", + "rama-error", + "rama-http-headers", + "rama-http-types", + "rama-net", + "rama-utils", + "rand 0.9.2", + "serde", + "serde_html_form", + "serde_json", + "tokio", + "uuid", +] + +[[package]] +name = "rama-http-backend" +version = "0.3.0-alpha.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3ff6a3c8ae690be8167e43777ba0bf6b0c8c2f6de165c538666affe2a32fd81" +dependencies = [ + "h2", + "pin-project-lite", + "rama-core", + "rama-http", + "rama-http-core", + "rama-http-headers", + "rama-http-types", + "rama-net", + "rama-tcp", + "rama-unix", + "rama-utils", + "tokio", +] + +[[package]] +name = "rama-http-core" +version = "0.3.0-alpha.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3822be6703e010afec0bcfeb5dbb6e5a3b23ca5689d9b1215b66ce6446653b77" +dependencies = [ + "ahash", + "atomic-waker", + "futures-channel", + "httparse", + "httpdate", + "indexmap 2.12.0", + "itoa", + "parking_lot", + "pin-project-lite", + "rama-core", + "rama-http", + "rama-http-types", + "rama-utils", + "slab", + "tokio", + "tokio-test", + "want", +] + +[[package]] +name = "rama-http-headers" +version = "0.3.0-alpha.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d74fe0cd9bd4440827dc6dc0f504cf66065396532e798891dee2c1b740b2285" +dependencies = [ + "ahash", + "base64", + "chrono", + "const_format", + "httpdate", + "rama-core", + "rama-error", + "rama-http-types", + "rama-macros", + "rama-net", + "rama-utils", + "rand 0.9.2", + "serde", + "sha1", +] + +[[package]] +name = "rama-http-types" +version = "0.3.0-alpha.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6dae655a72da5f2b97cfacb67960d8b28c5025e62707b4c8c5f0c5c9843a444" +dependencies = [ + "ahash", + "bytes", + "const_format", + "fnv", + "http 1.3.1", + "http-body", + "http-body-util", + "itoa", + "memchr", + "mime", + "mime_guess", + "nom 8.0.0", + "pin-project-lite", + "rama-core", + "rama-error", + "rama-macros", + "rama-utils", + "rand 0.9.2", + "serde", + "serde_json", + "sync_wrapper", + "tokio", +] + +[[package]] +name = "rama-macros" +version = "0.3.0-alpha.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea18a110bcf21e35c5f194168e6914ccea45ffdd0fea51bc4b169fbeafef6428" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.104", +] + +[[package]] +name = "rama-net" +version = "0.3.0-alpha.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b28ee9e1e5d39264414b71f5c33e7fbb66b382c3fac456fe0daad39cf5509933" +dependencies = [ + "ahash", + "const_format", + "flume", + "hex", + "ipnet", + "itertools 0.14.0", + "md5", + "nom 8.0.0", + "parking_lot", + "pin-project-lite", + "psl", + "radix_trie 0.3.0", + "rama-core", + "rama-http-types", + "rama-macros", + "rama-utils", + "serde", + "sha2", + "socket2 0.6.1", + "tokio", +] + +[[package]] +name = "rama-tcp" +version = "0.3.0-alpha.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe60cd604f91196b3659a1b28945add2e8b10bd0b4e6373c93d024fb3197704b" +dependencies = [ + "pin-project-lite", + "rama-core", + "rama-dns", + "rama-http-types", + "rama-net", + "rama-utils", + "rand 0.9.2", + "tokio", +] + +[[package]] +name = "rama-tls-boring" +version = "0.3.0-alpha.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "def3d5d06d3ca3a2d2e4376cf93de0555cd9c7960f085bf77be9562f5c9ace8f" +dependencies = [ + "ahash", + "flume", + "itertools 0.14.0", + "moka", + "parking_lot", + "pin-project-lite", + "rama-boring", + "rama-boring-tokio", + "rama-core", + "rama-http-types", + "rama-net", + "rama-utils", + "schannel", + "tokio", +] + +[[package]] +name = "rama-unix" +version = "0.3.0-alpha.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91acb16d571428ba4cece072dfab90d2667cdfa910a7b3cb4530c3f31542d708" +dependencies = [ + "pin-project-lite", + "rama-core", + "rama-net", + "tokio", +] + +[[package]] +name = "rama-utils" +version = "0.3.0-alpha.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf28b18ba4a57f8334d7992d3f8020194ea359b246ae6f8f98b8df524c7a14ef" +dependencies = [ + "const_format", + "parking_lot", + "pin-project-lite", + "rama-macros", + "regex", + "serde", + "smallvec", + "smol_str", + "tokio", + "wildcard", +] + [[package]] name = "rand" version = "0.8.5" @@ -5540,6 +6264,12 @@ dependencies = [ "webpki-roots", ] +[[package]] +name = "resolv-conf" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e061d1b48cb8d38042de4ae0a7a6401009d6143dc80d2e2d6f31f0bdd6470c7" + [[package]] name = "ring" version = "0.17.14" @@ -5718,7 +6448,7 @@ dependencies = [ "log", "memchr", "nix 0.28.0", - "radix_trie", + "radix_trie 0.2.1", "unicode-segmentation", "unicode-width 0.1.14", "utf8parse", @@ -5862,6 +6592,12 @@ dependencies = [ "syn 2.0.104", ] +[[package]] +name = "scoped-tls" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294" + [[package]] name = "scopeguard" version = "1.2.0" @@ -6106,6 +6842,19 @@ dependencies = [ "syn 2.0.104", ] +[[package]] +name = "serde_html_form" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2acf96b1d9364968fce46ebb548f1c0e1d7eceae27bdff73865d42e6c7369d94" +dependencies = [ + "form_urlencoded", + "indexmap 2.12.0", + "itoa", + "ryu", + "serde_core", +] + [[package]] name = "serde_json" version = "1.0.145" @@ -6367,6 +7116,9 @@ name = "smallvec" version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +dependencies = [ + "serde", +] [[package]] name = "smawk" @@ -6374,6 +7126,16 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b7c388c1b5e93756d0c740965c41e8822f866621d41acbdf6336a6a168f8840c" +[[package]] +name = "smol_str" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f7a918bd2a9951d18ee6e48f076843e8e73a9a5d22cf05bcd4b7a81bdd04e17" +dependencies = [ + "borsh", + "serde_core", +] + [[package]] name = "socket2" version = "0.5.10" @@ -6394,6 +7156,15 @@ dependencies = [ "windows-sys 0.60.2", ] +[[package]] +name = "spin" +version = "0.9.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" +dependencies = [ + "lock_api", +] + [[package]] name = "sse-stream" version = "0.2.1" @@ -6676,6 +7447,12 @@ dependencies = [ "libc", ] +[[package]] +name = "tagptr" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b2093cf4c8eb1e67749a6762251bc9cd836b6fc171623bd0a9d324d37af2417" + [[package]] name = "tempfile" version = "3.23.0" @@ -6959,6 +7736,19 @@ dependencies = [ "windows-sys 0.61.1", ] +[[package]] +name = "tokio-graceful" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45740b38b48641855471cd402922e89156bdfbd97b69b45eeff170369cc18c7d" +dependencies = [ + "loom", + "pin-project-lite", + "slab", + "tokio", + "tracing", +] + [[package]] name = "tokio-macros" version = "2.6.0" @@ -7379,7 +8169,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f943391d896cdfe8eec03a04d7110332d445be7df856db382dd96a730667562c" dependencies = [ "memchr", - "nom", + "nom 7.1.3", "once_cell", "petgraph", ] @@ -7914,6 +8704,21 @@ dependencies = [ "winsafe", ] +[[package]] +name = "widestring" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72069c3113ab32ab29e5584db3c6ec55d416895e60715417b5b883a357c3e471" + +[[package]] +name = "wildcard" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9b0540e91e49de3817c314da0dd3bc518093ceacc6ea5327cb0e1eb073e5189" +dependencies = [ + "thiserror 2.0.17", +] + [[package]] name = "wildmatch" version = "2.6.1" @@ -8143,6 +8948,15 @@ dependencies = [ "windows-targets 0.42.2", ] +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + [[package]] name = "windows-sys" version = "0.52.0" @@ -8448,6 +9262,16 @@ dependencies = [ "winapi", ] +[[package]] +name = "winreg" +version = "0.50.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "524e57b2c537c0f9b1e69f1965311ec12182b4122e45035b1508cd24d2adadb1" +dependencies = [ + "cfg-if", + "windows-sys 0.48.0", +] + [[package]] name = "winres" version = "0.1.12" diff --git a/codex-rs/Cargo.toml b/codex-rs/Cargo.toml index caadeecd64..9a58bcbfac 100644 --- a/codex-rs/Cargo.toml +++ b/codex-rs/Cargo.toml @@ -27,6 +27,7 @@ members = [ "login", "mcp-server", "mcp-types", + "network-proxy", "ollama", "process-hardening", "protocol", @@ -136,6 +137,7 @@ env-flags = "0.1.1" env_logger = "0.11.5" eventsource-stream = "0.2.3" futures = { version = "0.3", default-features = false } +globset = "0.4" http = "1.3.1" icu_decimal = "2.1" icu_locale_core = "2.1" diff --git a/codex-rs/deny.toml b/codex-rs/deny.toml index 0a4a08bd89..3e260ac24e 100644 --- a/codex-rs/deny.toml +++ b/codex-rs/deny.toml @@ -73,6 +73,7 @@ ignore = [ { id = "RUSTSEC-2024-0388", reason = "derivative is unmaintained; pulled in via starlark v0.13.0 used by execpolicy/cli/core; no fixed release yet" }, { id = "RUSTSEC-2025-0057", reason = "fxhash is unmaintained; pulled in via starlark_map/starlark v0.13.0 used by execpolicy/cli/core; no fixed release yet" }, { id = "RUSTSEC-2024-0436", reason = "paste is unmaintained; pulled in via ratatui/rmcp/starlark used by tui/execpolicy; no fixed release yet" }, + { id = "RUSTSEC-2025-0134", reason = "rustls-pemfile is unmaintained; pulled in via rama-tls-rustls used by codex-network-proxy; no safe upgrade until rama removes the dependency" }, # TODO(joshka, nornagon): remove this exception when once we update the ratatui fork to a version that uses lru 0.13+. { id = "RUSTSEC-2026-0002", reason = "lru 0.12.5 is pulled in via ratatui fork; cannot upgrade until the fork is updated" }, ] diff --git a/codex-rs/network-proxy/Cargo.toml b/codex-rs/network-proxy/Cargo.toml new file mode 100644 index 0000000000..2d303b1c95 --- /dev/null +++ b/codex-rs/network-proxy/Cargo.toml @@ -0,0 +1,45 @@ +[package] +name = "codex-network-proxy" +edition = "2024" +version = { workspace = true } +license.workspace = true + +[[bin]] +name = "codex-network-proxy" +path = "src/main.rs" + +[lib] +name = "codex_network_proxy" +path = "src/lib.rs" + +[lints] +workspace = true + +[dependencies] +anyhow = { workspace = true } +async-trait = { workspace = true } +clap = { workspace = true, features = ["derive"] } +codex-app-server-protocol = { workspace = true } +codex-core = { workspace = true } +codex-utils-absolute-path = { workspace = true } +globset = { workspace = true } +serde = { workspace = true, features = ["derive"] } +serde_json = { workspace = true } +time = { workspace = true } +tokio = { workspace = true, features = ["full"] } +tracing = { workspace = true } +tracing-subscriber = { workspace = true, features = ["fmt"] } +url = { workspace = true } +rama-core = { version = "=0.3.0-alpha.4" } +rama-http = { version = "=0.3.0-alpha.4" } +rama-http-backend = { version = "=0.3.0-alpha.4", features = ["tls"] } +rama-net = { version = "=0.3.0-alpha.4", features = ["http", "tls"] } +rama-tcp = { version = "=0.3.0-alpha.4", features = ["http"] } +rama-tls-boring = { version = "=0.3.0-alpha.4", features = ["http"] } + +[dev-dependencies] +pretty_assertions = { workspace = true } +tempfile = { workspace = true } + +[target.'cfg(target_family = "unix")'.dependencies] +rama-unix = { version = "=0.3.0-alpha.4" } diff --git a/codex-rs/network-proxy/README.md b/codex-rs/network-proxy/README.md new file mode 100644 index 0000000000..1d19a92a68 --- /dev/null +++ b/codex-rs/network-proxy/README.md @@ -0,0 +1,169 @@ +# codex-network-proxy + +`codex-network-proxy` is Codex's local network policy enforcement proxy. It runs: + +- an HTTP proxy (default `127.0.0.1:3128`) +- an admin HTTP API (default `127.0.0.1:8080`) + +It enforces an allow/deny policy and a "limited" mode intended for read-only network access. + +## Quickstart + +### 1) Configure + +`codex-network-proxy` reads from Codex's merged `config.toml` (via `codex-core` config loading). + +Example config: + +```toml +[network_proxy] +enabled = true +proxy_url = "http://127.0.0.1:3128" +admin_url = "http://127.0.0.1:8080" +# When `enabled` is false, the proxy no-ops and does not bind listeners. +# When true, respect HTTP(S)_PROXY/ALL_PROXY for upstream requests (HTTP(S) proxies only), +# including CONNECT tunnels in full mode. +allow_upstream_proxy = false +# By default, non-loopback binds are clamped to loopback for safety. +# If you want to expose these listeners beyond localhost, you must opt in explicitly. +dangerously_allow_non_loopback_proxy = false +dangerously_allow_non_loopback_admin = false +mode = "limited" # or "full" + +[network_proxy.policy] +# Hosts must match the allowlist (unless denied). +# If `allowed_domains` is empty, the proxy blocks requests until an allowlist is configured. +allowed_domains = ["*.openai.com"] +denied_domains = ["evil.example"] + +# If false, local/private networking is rejected. Explicit allowlisting of local IP literals +# (or `localhost`) is required to permit them. +# Hostnames that resolve to local/private IPs are still blocked even if allowlisted. +allow_local_binding = false + +# macOS-only: allows proxying to a unix socket when request includes `x-unix-socket: /path`. +allow_unix_sockets = ["/tmp/example.sock"] +``` + +### 2) Run the proxy + +```bash +cargo run -p codex-network-proxy -- +``` + +### 3) Point a client at it + +For HTTP(S) traffic: + +```bash +export HTTP_PROXY="http://127.0.0.1:3128" +export HTTPS_PROXY="http://127.0.0.1:3128" +``` + +### 4) Understand blocks / debugging + +When a request is blocked, the proxy responds with `403` and includes: + +- `x-proxy-error`: one of: + - `blocked-by-allowlist` + - `blocked-by-denylist` + - `blocked-by-method-policy` + - `blocked-by-policy` + +In "limited" mode, only `GET`, `HEAD`, and `OPTIONS` are allowed for plain HTTP. HTTPS `CONNECT` +remains a transparent tunnel, so limited-mode method enforcement does not apply to HTTPS. + +## Library API + +`codex-network-proxy` can be embedded as a library with a thin API: + +```rust +use codex_network_proxy::{NetworkProxy, NetworkDecision, NetworkPolicyRequest}; + +let proxy = NetworkProxy::builder() + .http_addr("127.0.0.1:8080".parse()?) + .admin_addr("127.0.0.1:9000".parse()?) + .policy_decider(|request: NetworkPolicyRequest| async move { + // Example: auto-allow when exec policy already approved a command prefix. + if let Some(command) = request.command.as_deref() { + if command.starts_with("curl ") { + return NetworkDecision::Allow; + } + } + NetworkDecision::Deny { + reason: "policy_denied".to_string(), + } + }) + .build() + .await?; + +let handle = proxy.run().await?; +handle.shutdown().await?; +``` + +When unix socket proxying is enabled, HTTP/admin bind overrides are still clamped to loopback +to avoid turning the proxy into a remote bridge to local daemons. + +### Policy hook (exec-policy mapping) + +The proxy exposes a policy hook (`NetworkPolicyDecider`) that can override allowlist-only blocks. +It receives `command` and `exec_policy_hint` fields when supplied by the embedding app. This lets +core map exec approvals to network access, e.g. if a user already approved `curl *` for a session, +the decider can auto-allow network requests originating from that command. + +**Important:** Explicit deny rules still win. The decider only gets a chance to override +`not_allowed` (allowlist misses), not `denied` or `not_allowed_local`. + +## Admin API + +The admin API is a small HTTP server intended for debugging and runtime adjustments. + +Endpoints: + +```bash +curl -sS http://127.0.0.1:8080/health +curl -sS http://127.0.0.1:8080/config +curl -sS http://127.0.0.1:8080/patterns +curl -sS http://127.0.0.1:8080/blocked + +# Switch modes without restarting: +curl -sS -X POST http://127.0.0.1:8080/mode -d '{"mode":"full"}' + +# Force a config reload: +curl -sS -X POST http://127.0.0.1:8080/reload +``` + +## Platform notes + +- Unix socket proxying via the `x-unix-socket` header is **macOS-only**; other platforms will + reject unix socket requests. +- HTTPS tunneling uses BoringSSL via Rama's `rama-tls-boring`; building the proxy requires a + native toolchain and CMake on macOS/Linux/Windows. + +## Security notes (important) + +This section documents the protections implemented by `codex-network-proxy`, and the boundaries of +what it can reasonably guarantee. + +- Allowlist-first policy: if `allowed_domains` is empty, requests are blocked until an allowlist is configured. +- Deny wins: entries in `denied_domains` always override the allowlist. +- Local/private network protection: when `allow_local_binding = false`, the proxy blocks loopback + and common private/link-local ranges. Explicit allowlisting of local IP literals (or `localhost`) + is required to permit them; hostnames that resolve to local/private IPs are still blocked even if + allowlisted (best-effort DNS lookup). +- Limited mode enforcement: + - only `GET`, `HEAD`, and `OPTIONS` are allowed + - HTTPS `CONNECT` remains a tunnel; limited-mode method enforcement does not apply to HTTPS +- Listener safety defaults: + - the admin API is unauthenticated; non-loopback binds are clamped unless explicitly enabled via + `dangerously_allow_non_loopback_admin` +- the HTTP proxy listener similarly clamps non-loopback binds unless explicitly enabled via + `dangerously_allow_non_loopback_proxy` +- when unix socket proxying is enabled, both listeners are forced to loopback to avoid turning the + proxy into a remote bridge into local daemons. +- `enabled` is enforced at runtime; when false the proxy no-ops and does not bind listeners. +Limitations: + +- DNS rebinding is hard to fully prevent without pinning the resolved IP(s) all the way down to the + transport layer. If your threat model includes hostile DNS, enforce network egress at a lower + layer too (e.g., firewall / VPC / corporate proxy policies). diff --git a/codex-rs/network-proxy/src/admin.rs b/codex-rs/network-proxy/src/admin.rs new file mode 100644 index 0000000000..3c883603a9 --- /dev/null +++ b/codex-rs/network-proxy/src/admin.rs @@ -0,0 +1,160 @@ +use crate::config::NetworkMode; +use crate::responses::json_response; +use crate::responses::text_response; +use crate::state::NetworkProxyState; +use anyhow::Context; +use anyhow::Result; +use rama_core::rt::Executor; +use rama_core::service::service_fn; +use rama_http::Body; +use rama_http::Request; +use rama_http::Response; +use rama_http::StatusCode; +use rama_http_backend::server::HttpServer; +use rama_tcp::server::TcpListener; +use serde::Deserialize; +use serde::Serialize; +use std::convert::Infallible; +use std::net::SocketAddr; +use std::sync::Arc; +use tracing::error; +use tracing::info; + +pub async fn run_admin_api(state: Arc, addr: SocketAddr) -> Result<()> { + // Debug-only admin API (health/config/patterns/blocked + mode/reload). Policy is config-driven + // and constraint-enforced; this endpoint should not become a second policy/approval plane. + let listener = TcpListener::build() + .bind(addr) + .await + // See `http_proxy.rs` for details on why we wrap `BoxError` before converting to anyhow. + .map_err(rama_core::error::OpaqueError::from) + .map_err(anyhow::Error::from) + .with_context(|| format!("bind admin API: {addr}"))?; + + let server_state = state.clone(); + let server = HttpServer::auto(Executor::new()).service(service_fn(move |req| { + let state = server_state.clone(); + async move { handle_admin_request(state, req).await } + })); + info!("admin API listening on {addr}"); + listener.serve(server).await; + Ok(()) +} + +async fn handle_admin_request( + state: Arc, + req: Request, +) -> Result { + const MODE_BODY_LIMIT: usize = 8 * 1024; + + let method = req.method().clone(); + let path = req.uri().path().to_string(); + let response = match (method.as_str(), path.as_str()) { + ("GET", "/health") => Response::new(Body::from("ok")), + ("GET", "/config") => match state.current_cfg().await { + Ok(cfg) => json_response(&cfg), + Err(err) => { + error!("failed to load config: {err}"); + text_response(StatusCode::INTERNAL_SERVER_ERROR, "error") + } + }, + ("GET", "/patterns") => match state.current_patterns().await { + Ok((allow, deny)) => json_response(&PatternsResponse { + allowed: allow, + denied: deny, + }), + Err(err) => { + error!("failed to load patterns: {err}"); + text_response(StatusCode::INTERNAL_SERVER_ERROR, "error") + } + }, + ("GET", "/blocked") => match state.drain_blocked().await { + Ok(blocked) => json_response(&BlockedResponse { blocked }), + Err(err) => { + error!("failed to read blocked queue: {err}"); + text_response(StatusCode::INTERNAL_SERVER_ERROR, "error") + } + }, + ("POST", "/mode") => { + let mut body = req.into_body(); + let mut buf: Vec = Vec::new(); + loop { + let chunk = match body.chunk().await { + Ok(chunk) => chunk, + Err(err) => { + error!("failed to read mode body: {err}"); + return Ok(text_response(StatusCode::BAD_REQUEST, "invalid body")); + } + }; + let Some(chunk) = chunk else { + break; + }; + + if buf.len().saturating_add(chunk.len()) > MODE_BODY_LIMIT { + return Ok(text_response( + StatusCode::PAYLOAD_TOO_LARGE, + "body too large", + )); + } + buf.extend_from_slice(&chunk); + } + + if buf.is_empty() { + return Ok(text_response(StatusCode::BAD_REQUEST, "missing body")); + } + let update: ModeUpdate = match serde_json::from_slice(&buf) { + Ok(update) => update, + Err(err) => { + error!("failed to parse mode update: {err}"); + return Ok(text_response(StatusCode::BAD_REQUEST, "invalid json")); + } + }; + match state.set_network_mode(update.mode).await { + Ok(()) => json_response(&ModeUpdateResponse { + status: "ok", + mode: update.mode, + }), + Err(err) => { + error!("mode update failed: {err}"); + text_response(StatusCode::INTERNAL_SERVER_ERROR, "mode update failed") + } + } + } + ("POST", "/reload") => match state.force_reload().await { + Ok(()) => json_response(&ReloadResponse { status: "reloaded" }), + Err(err) => { + error!("reload failed: {err}"); + text_response(StatusCode::INTERNAL_SERVER_ERROR, "reload failed") + } + }, + _ => text_response(StatusCode::NOT_FOUND, "not found"), + }; + Ok(response) +} + +#[derive(Deserialize)] +struct ModeUpdate { + mode: NetworkMode, +} + +#[derive(Debug, Serialize)] +struct PatternsResponse { + allowed: Vec, + denied: Vec, +} + +#[derive(Debug, Serialize)] +struct BlockedResponse { + blocked: T, +} + +#[derive(Debug, Serialize)] +struct ModeUpdateResponse { + status: &'static str, + mode: NetworkMode, +} + +#[derive(Debug, Serialize)] +struct ReloadResponse { + status: &'static str, +} diff --git a/codex-rs/network-proxy/src/config.rs b/codex-rs/network-proxy/src/config.rs new file mode 100644 index 0000000000..7bd6957ac7 --- /dev/null +++ b/codex-rs/network-proxy/src/config.rs @@ -0,0 +1,433 @@ +use anyhow::Context; +use anyhow::Result; +use anyhow::bail; +use serde::Deserialize; +use serde::Serialize; +use std::net::IpAddr; +use std::net::SocketAddr; +use tracing::warn; +use url::Url; + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct NetworkProxyConfig { + #[serde(default)] + pub network_proxy: NetworkProxySettings, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct NetworkProxySettings { + #[serde(default)] + pub enabled: bool, + #[serde(default = "default_proxy_url")] + pub proxy_url: String, + #[serde(default = "default_admin_url")] + pub admin_url: String, + #[serde(default)] + pub allow_upstream_proxy: bool, + #[serde(default)] + pub dangerously_allow_non_loopback_proxy: bool, + #[serde(default)] + pub dangerously_allow_non_loopback_admin: bool, + #[serde(default)] + pub mode: NetworkMode, + #[serde(default)] + pub policy: NetworkPolicy, +} + +impl Default for NetworkProxySettings { + fn default() -> Self { + Self { + enabled: false, + proxy_url: default_proxy_url(), + admin_url: default_admin_url(), + allow_upstream_proxy: false, + dangerously_allow_non_loopback_proxy: false, + dangerously_allow_non_loopback_admin: false, + mode: NetworkMode::default(), + policy: NetworkPolicy::default(), + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct NetworkPolicy { + #[serde(default)] + pub allowed_domains: Vec, + #[serde(default)] + pub denied_domains: Vec, + #[serde(default)] + pub allow_unix_sockets: Vec, + #[serde(default)] + pub allow_local_binding: bool, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)] +#[serde(rename_all = "lowercase")] +pub enum NetworkMode { + /// Limited (read-only) access: only GET/HEAD/OPTIONS are allowed for HTTP. HTTPS CONNECT is + /// blocked unless MITM is enabled so the proxy can enforce method policy on inner requests. + Limited, + /// Full network access: all HTTP methods are allowed, and HTTPS CONNECTs are tunneled without + /// MITM interception. + #[default] + Full, +} + +impl NetworkMode { + pub fn allows_method(self, method: &str) -> bool { + match self { + Self::Full => true, + Self::Limited => matches!(method, "GET" | "HEAD" | "OPTIONS"), + } + } +} + +fn default_proxy_url() -> String { + "http://127.0.0.1:3128".to_string() +} + +fn default_admin_url() -> String { + "http://127.0.0.1:8080".to_string() +} + +/// Clamp non-loopback bind addresses to loopback unless explicitly allowed. +fn clamp_non_loopback(addr: SocketAddr, allow_non_loopback: bool, name: &str) -> SocketAddr { + if addr.ip().is_loopback() { + return addr; + } + + if allow_non_loopback { + warn!("DANGEROUS: {name} listening on non-loopback address {addr}"); + return addr; + } + + warn!( + "{name} requested non-loopback bind ({addr}); clamping to 127.0.0.1:{port} (set dangerously_allow_non_loopback_proxy or dangerously_allow_non_loopback_admin to override)", + port = addr.port() + ); + SocketAddr::from(([127, 0, 0, 1], addr.port())) +} + +pub(crate) fn clamp_bind_addrs( + http_addr: SocketAddr, + admin_addr: SocketAddr, + cfg: &NetworkProxySettings, +) -> (SocketAddr, SocketAddr) { + let http_addr = clamp_non_loopback( + http_addr, + cfg.dangerously_allow_non_loopback_proxy, + "HTTP proxy", + ); + let admin_addr = clamp_non_loopback( + admin_addr, + cfg.dangerously_allow_non_loopback_admin, + "admin API", + ); + if cfg.policy.allow_unix_sockets.is_empty() { + return (http_addr, admin_addr); + } + + // `x-unix-socket` is intentionally a local escape hatch. If the proxy (or admin API) is + // reachable from outside the machine, it can become a remote bridge into local daemons + // (e.g. docker.sock). To avoid footguns, enforce loopback binding whenever unix sockets + // are enabled. + if cfg.dangerously_allow_non_loopback_proxy && !http_addr.ip().is_loopback() { + warn!( + "unix socket proxying is enabled; ignoring dangerously_allow_non_loopback_proxy and clamping HTTP proxy to loopback" + ); + } + if cfg.dangerously_allow_non_loopback_admin && !admin_addr.ip().is_loopback() { + warn!( + "unix socket proxying is enabled; ignoring dangerously_allow_non_loopback_admin and clamping admin API to loopback" + ); + } + ( + SocketAddr::from(([127, 0, 0, 1], http_addr.port())), + SocketAddr::from(([127, 0, 0, 1], admin_addr.port())), + ) +} + +pub struct RuntimeConfig { + pub http_addr: SocketAddr, + pub admin_addr: SocketAddr, +} + +pub fn resolve_runtime(cfg: &NetworkProxyConfig) -> Result { + let http_addr = resolve_addr(&cfg.network_proxy.proxy_url, 3128).with_context(|| { + format!( + "invalid network_proxy.proxy_url: {}", + cfg.network_proxy.proxy_url + ) + })?; + let admin_addr = resolve_addr(&cfg.network_proxy.admin_url, 8080).with_context(|| { + format!( + "invalid network_proxy.admin_url: {}", + cfg.network_proxy.admin_url + ) + })?; + let (http_addr, admin_addr) = clamp_bind_addrs(http_addr, admin_addr, &cfg.network_proxy); + + Ok(RuntimeConfig { + http_addr, + admin_addr, + }) +} + +fn resolve_addr(url: &str, default_port: u16) -> Result { + let addr_parts = parse_host_port(url, default_port)?; + let host = if addr_parts.host.eq_ignore_ascii_case("localhost") { + "127.0.0.1".to_string() + } else { + addr_parts.host + }; + match host.parse::() { + Ok(ip) => Ok(SocketAddr::new(ip, addr_parts.port)), + Err(_) => Ok(SocketAddr::from(([127, 0, 0, 1], addr_parts.port))), + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct SocketAddressParts { + host: String, + port: u16, +} + +fn parse_host_port(url: &str, default_port: u16) -> Result { + let trimmed = url.trim(); + if trimmed.is_empty() { + bail!("missing host in network proxy address: {url}"); + } + + // Avoid treating unbracketed IPv6 literals like "2001:db8::1" as scheme-prefixed URLs. + if matches!(trimmed.parse::(), Ok(IpAddr::V6(_))) && !trimmed.starts_with('[') { + return Ok(SocketAddressParts { + host: trimmed.to_string(), + port: default_port, + }); + } + + // Prefer the standard URL parser when the input is URL-like. Prefix a scheme when absent so + // we still accept loose host:port inputs. + let candidate = if trimmed.contains("://") { + trimmed.to_string() + } else { + format!("http://{trimmed}") + }; + if let Ok(parsed) = Url::parse(&candidate) + && let Some(host) = parsed.host_str() + { + let host = host.trim_matches(|c| c == '[' || c == ']'); + if host.is_empty() { + bail!("missing host in network proxy address: {url}"); + } + return Ok(SocketAddressParts { + host: host.to_string(), + port: parsed.port().unwrap_or(default_port), + }); + } + + parse_host_port_fallback(trimmed, default_port) +} + +fn parse_host_port_fallback(input: &str, default_port: u16) -> Result { + let without_scheme = input + .split_once("://") + .map(|(_, rest)| rest) + .unwrap_or(input); + let host_port = without_scheme.split('/').next().unwrap_or(without_scheme); + let host_port = host_port + .rsplit_once('@') + .map(|(_, rest)| rest) + .unwrap_or(host_port); + + if host_port.starts_with('[') + && let Some(end) = host_port.find(']') + { + let host = &host_port[1..end]; + let port = host_port[end + 1..] + .strip_prefix(':') + .and_then(|port| port.parse::().ok()) + .unwrap_or(default_port); + if host.is_empty() { + bail!("missing host in network proxy address: {input}"); + } + return Ok(SocketAddressParts { + host: host.to_string(), + port, + }); + } + + // Only treat `host:port` as such when there's a single `:`. This avoids + // accidentally interpreting unbracketed IPv6 addresses as `host:port`. + if host_port.bytes().filter(|b| *b == b':').count() == 1 + && let Some((host, port)) = host_port.rsplit_once(':') + && let Ok(port) = port.parse::() + { + if host.is_empty() { + bail!("missing host in network proxy address: {input}"); + } + return Ok(SocketAddressParts { + host: host.to_string(), + port, + }); + } + + if host_port.is_empty() { + bail!("missing host in network proxy address: {input}"); + } + Ok(SocketAddressParts { + host: host_port.to_string(), + port: default_port, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + use pretty_assertions::assert_eq; + + #[test] + fn parse_host_port_defaults_for_empty_string() { + assert!(parse_host_port("", 1234).is_err()); + } + + #[test] + fn parse_host_port_defaults_for_whitespace() { + assert!(parse_host_port(" ", 5555).is_err()); + } + + #[test] + fn parse_host_port_parses_host_port_without_scheme() { + assert_eq!( + parse_host_port("127.0.0.1:8080", 3128).unwrap(), + SocketAddressParts { + host: "127.0.0.1".to_string(), + port: 8080, + } + ); + } + + #[test] + fn parse_host_port_parses_host_port_with_scheme_and_path() { + assert_eq!( + parse_host_port("http://example.com:8080/some/path", 3128).unwrap(), + SocketAddressParts { + host: "example.com".to_string(), + port: 8080, + } + ); + } + + #[test] + fn parse_host_port_strips_userinfo() { + assert_eq!( + parse_host_port("http://user:pass@host.example:5555", 3128).unwrap(), + SocketAddressParts { + host: "host.example".to_string(), + port: 5555, + } + ); + } + + #[test] + fn parse_host_port_parses_ipv6_with_brackets() { + assert_eq!( + parse_host_port("http://[::1]:9999", 3128).unwrap(), + SocketAddressParts { + host: "::1".to_string(), + port: 9999, + } + ); + } + + #[test] + fn parse_host_port_does_not_treat_unbracketed_ipv6_as_host_port() { + assert_eq!( + parse_host_port("2001:db8::1", 3128).unwrap(), + SocketAddressParts { + host: "2001:db8::1".to_string(), + port: 3128, + } + ); + } + + #[test] + fn parse_host_port_falls_back_to_default_port_when_port_is_invalid() { + assert_eq!( + parse_host_port("example.com:notaport", 3128).unwrap(), + SocketAddressParts { + host: "example.com:notaport".to_string(), + port: 3128, + } + ); + } + + #[test] + fn resolve_addr_maps_localhost_to_loopback() { + assert_eq!( + resolve_addr("localhost", 3128).unwrap(), + "127.0.0.1:3128".parse::().unwrap() + ); + } + + #[test] + fn resolve_addr_parses_ip_literals() { + assert_eq!( + resolve_addr("1.2.3.4", 80).unwrap(), + "1.2.3.4:80".parse::().unwrap() + ); + } + + #[test] + fn resolve_addr_parses_ipv6_literals() { + assert_eq!( + resolve_addr("http://[::1]:8080", 3128).unwrap(), + "[::1]:8080".parse::().unwrap() + ); + } + + #[test] + fn resolve_addr_falls_back_to_loopback_for_hostnames() { + assert_eq!( + resolve_addr("http://example.com:5555", 3128).unwrap(), + "127.0.0.1:5555".parse::().unwrap() + ); + } + + #[test] + fn clamp_bind_addrs_allows_non_loopback_when_enabled() { + let cfg = NetworkProxySettings { + dangerously_allow_non_loopback_proxy: true, + dangerously_allow_non_loopback_admin: true, + ..Default::default() + }; + let http_addr = "0.0.0.0:3128".parse::().unwrap(); + let admin_addr = "0.0.0.0:8080".parse::().unwrap(); + + let (http_addr, admin_addr) = clamp_bind_addrs(http_addr, admin_addr, &cfg); + + assert_eq!(http_addr, "0.0.0.0:3128".parse::().unwrap()); + assert_eq!(admin_addr, "0.0.0.0:8080".parse::().unwrap()); + } + + #[test] + fn clamp_bind_addrs_forces_loopback_when_unix_sockets_enabled() { + let cfg = NetworkProxySettings { + dangerously_allow_non_loopback_proxy: true, + dangerously_allow_non_loopback_admin: true, + policy: NetworkPolicy { + allow_unix_sockets: vec!["/tmp/docker.sock".to_string()], + ..Default::default() + }, + ..Default::default() + }; + let http_addr = "0.0.0.0:3128".parse::().unwrap(); + let admin_addr = "0.0.0.0:8080".parse::().unwrap(); + + let (http_addr, admin_addr) = clamp_bind_addrs(http_addr, admin_addr, &cfg); + + assert_eq!(http_addr, "127.0.0.1:3128".parse::().unwrap()); + assert_eq!(admin_addr, "127.0.0.1:8080".parse::().unwrap()); + } +} diff --git a/codex-rs/network-proxy/src/http_proxy.rs b/codex-rs/network-proxy/src/http_proxy.rs new file mode 100644 index 0000000000..abe6b30144 --- /dev/null +++ b/codex-rs/network-proxy/src/http_proxy.rs @@ -0,0 +1,636 @@ +use crate::config::NetworkMode; +use crate::network_policy::NetworkDecision; +use crate::network_policy::NetworkPolicyDecider; +use crate::network_policy::NetworkPolicyRequest; +use crate::network_policy::NetworkProtocol; +use crate::network_policy::evaluate_host_policy; +use crate::policy::normalize_host; +use crate::reasons::REASON_METHOD_NOT_ALLOWED; +use crate::reasons::REASON_NOT_ALLOWED; +use crate::reasons::REASON_PROXY_DISABLED; +use crate::responses::blocked_header_value; +use crate::responses::json_response; +use crate::runtime::unix_socket_permissions_supported; +use crate::state::BlockedRequest; +use crate::state::NetworkProxyState; +use crate::upstream::UpstreamClient; +use crate::upstream::proxy_for_connect; +use anyhow::Context as _; +use anyhow::Result; +use rama_core::Layer; +use rama_core::Service; +use rama_core::error::BoxError; +use rama_core::error::ErrorExt as _; +use rama_core::error::OpaqueError; +use rama_core::extensions::ExtensionsMut; +use rama_core::extensions::ExtensionsRef; +use rama_core::layer::AddInputExtensionLayer; +use rama_core::rt::Executor; +use rama_core::service::service_fn; +use rama_http::Body; +use rama_http::HeaderValue; +use rama_http::Request; +use rama_http::Response; +use rama_http::StatusCode; +use rama_http::layer::remove_header::RemoveRequestHeaderLayer; +use rama_http::layer::remove_header::RemoveResponseHeaderLayer; +use rama_http::matcher::MethodMatcher; +use rama_http_backend::client::proxy::layer::HttpProxyConnector; +use rama_http_backend::server::HttpServer; +use rama_http_backend::server::layer::upgrade::UpgradeLayer; +use rama_http_backend::server::layer::upgrade::Upgraded; +use rama_net::Protocol; +use rama_net::address::ProxyAddress; +use rama_net::client::ConnectorService; +use rama_net::client::EstablishedClientConnection; +use rama_net::http::RequestContext; +use rama_net::proxy::ProxyRequest; +use rama_net::proxy::ProxyTarget; +use rama_net::proxy::StreamForwardService; +use rama_net::stream::SocketInfo; +use rama_tcp::client::Request as TcpRequest; +use rama_tcp::client::service::TcpConnector; +use rama_tcp::server::TcpListener; +use rama_tls_boring::client::TlsConnectorDataBuilder; +use rama_tls_boring::client::TlsConnectorLayer; +use serde::Serialize; +use std::convert::Infallible; +use std::net::SocketAddr; +use std::sync::Arc; +use tracing::error; +use tracing::info; +use tracing::warn; + +pub async fn run_http_proxy( + state: Arc, + addr: SocketAddr, + policy_decider: Option>, +) -> Result<()> { + let listener = TcpListener::build() + .bind(addr) + .await + // Rama's `BoxError` is a `Box` without an explicit `'static` + // lifetime bound, which means it doesn't satisfy `anyhow::Context`'s `StdError` constraint. + // Wrap it in Rama's `OpaqueError` so we can preserve the original error as a source and + // still use `anyhow` for chaining. + .map_err(rama_core::error::OpaqueError::from) + .map_err(anyhow::Error::from) + .with_context(|| format!("bind HTTP proxy: {addr}"))?; + + let http_service = HttpServer::auto(Executor::new()).service( + ( + UpgradeLayer::new( + MethodMatcher::CONNECT, + service_fn({ + let policy_decider = policy_decider.clone(); + move |req| http_connect_accept(policy_decider.clone(), req) + }), + service_fn(http_connect_proxy), + ), + RemoveResponseHeaderLayer::hop_by_hop(), + RemoveRequestHeaderLayer::hop_by_hop(), + ) + .into_layer(service_fn({ + let policy_decider = policy_decider.clone(); + move |req| http_plain_proxy(policy_decider.clone(), req) + })), + ); + + info!("HTTP proxy listening on {addr}"); + + listener + .serve(AddInputExtensionLayer::new(state).into_layer(http_service)) + .await; + Ok(()) +} + +async fn http_connect_accept( + policy_decider: Option>, + mut req: Request, +) -> Result<(Response, Request), Response> { + let app_state = req + .extensions() + .get::>() + .cloned() + .ok_or_else(|| text_response(StatusCode::INTERNAL_SERVER_ERROR, "missing state"))?; + + let authority = match RequestContext::try_from(&req).map(|ctx| ctx.host_with_port()) { + Ok(authority) => authority, + Err(err) => { + warn!("CONNECT missing authority: {err}"); + return Err(text_response(StatusCode::BAD_REQUEST, "missing authority")); + } + }; + + let host = normalize_host(&authority.host.to_string()); + if host.is_empty() { + return Err(text_response(StatusCode::BAD_REQUEST, "invalid host")); + } + + let client = client_addr(&req); + + let enabled = app_state + .enabled() + .await + .map_err(|err| internal_error("failed to read enabled state", err))?; + if !enabled { + let client = client.as_deref().unwrap_or_default(); + warn!("CONNECT blocked; proxy disabled (client={client}, host={host})"); + return Err(proxy_disabled_response( + &app_state, + host, + client_addr(&req), + Some("CONNECT".to_string()), + "http-connect", + ) + .await); + } + + let request = NetworkPolicyRequest::new( + NetworkProtocol::HttpsConnect, + host.clone(), + authority.port, + client.clone(), + Some("CONNECT".to_string()), + None, + None, + ); + + match evaluate_host_policy(&app_state, policy_decider.as_ref(), &request).await { + Ok(NetworkDecision::Deny { reason }) => { + let _ = app_state + .record_blocked(BlockedRequest::new( + host.clone(), + reason.clone(), + client.clone(), + Some("CONNECT".to_string()), + None, + "http-connect".to_string(), + )) + .await; + let client = client.as_deref().unwrap_or_default(); + warn!("CONNECT blocked (client={client}, host={host}, reason={reason})"); + return Err(blocked_text(&reason)); + } + Ok(NetworkDecision::Allow) => { + let client = client.as_deref().unwrap_or_default(); + info!("CONNECT allowed (client={client}, host={host})"); + } + Err(err) => { + error!("failed to evaluate host for CONNECT {host}: {err}"); + return Err(text_response(StatusCode::INTERNAL_SERVER_ERROR, "error")); + } + } + + let mode = app_state + .network_mode() + .await + .map_err(|err| internal_error("failed to read network mode", err))?; + + if mode == NetworkMode::Limited { + let _ = app_state + .record_blocked(BlockedRequest::new( + host.clone(), + REASON_METHOD_NOT_ALLOWED.to_string(), + client.clone(), + Some("CONNECT".to_string()), + Some(NetworkMode::Limited), + "http-connect".to_string(), + )) + .await; + let client = client.as_deref().unwrap_or_default(); + warn!("CONNECT blocked by method policy (client={client}, host={host}, mode=limited)"); + return Err(blocked_text(REASON_METHOD_NOT_ALLOWED)); + } + + req.extensions_mut().insert(ProxyTarget(authority)); + req.extensions_mut().insert(mode); + + Ok(( + Response::builder() + .status(StatusCode::OK) + .body(Body::empty()) + .unwrap_or_else(|_| Response::new(Body::empty())), + req, + )) +} + +async fn http_connect_proxy(upgraded: Upgraded) -> Result<(), Infallible> { + if upgraded.extensions().get::().is_none() { + warn!("CONNECT missing proxy target"); + return Ok(()); + } + + let allow_upstream_proxy = match upgraded + .extensions() + .get::>() + .cloned() + { + Some(state) => match state.allow_upstream_proxy().await { + Ok(allowed) => allowed, + Err(err) => { + error!("failed to read upstream proxy setting: {err}"); + false + } + }, + None => { + error!("missing app state"); + false + } + }; + + let proxy = if allow_upstream_proxy { + proxy_for_connect() + } else { + None + }; + + if let Err(err) = forward_connect_tunnel(upgraded, proxy).await { + warn!("tunnel error: {err}"); + } + Ok(()) +} + +async fn forward_connect_tunnel( + upgraded: Upgraded, + proxy: Option, +) -> Result<(), BoxError> { + let authority = upgraded + .extensions() + .get::() + .map(|target| target.0.clone()) + .ok_or_else(|| OpaqueError::from_display("missing forward authority").into_boxed())?; + + let mut extensions = upgraded.extensions().clone(); + if let Some(proxy) = proxy { + extensions.insert(proxy); + } + + let req = TcpRequest::new_with_extensions(authority.clone(), extensions) + .with_protocol(Protocol::HTTPS); + let proxy_connector = HttpProxyConnector::optional(TcpConnector::new()); + let tls_config = TlsConnectorDataBuilder::new_http_auto().into_shared_builder(); + let connector = TlsConnectorLayer::tunnel(None) + .with_connector_data(tls_config) + .into_layer(proxy_connector); + let EstablishedClientConnection { conn: target, .. } = + connector.connect(req).await.map_err(|err| { + OpaqueError::from_boxed(err) + .with_context(|| format!("establish CONNECT tunnel to {authority}")) + .into_boxed() + })?; + + let proxy_req = ProxyRequest { + source: upgraded, + target, + }; + StreamForwardService::default() + .serve(proxy_req) + .await + .map_err(|err| { + OpaqueError::from_boxed(err.into()) + .with_context(|| format!("forward CONNECT tunnel to {authority}")) + .into_boxed() + }) +} + +async fn http_plain_proxy( + policy_decider: Option>, + req: Request, +) -> Result { + let app_state = match req.extensions().get::>().cloned() { + Some(state) => state, + None => { + error!("missing app state"); + return Ok(text_response(StatusCode::INTERNAL_SERVER_ERROR, "error")); + } + }; + let client = client_addr(&req); + + let method_allowed = match app_state + .method_allowed(req.method().as_str()) + .await + .map_err(|err| internal_error("failed to evaluate method policy", err)) + { + Ok(allowed) => allowed, + Err(resp) => return Ok(resp), + }; + + // `x-unix-socket` is an escape hatch for talking to local daemons. We keep it tightly scoped: + // macOS-only + explicit allowlist, to avoid turning the proxy into a general local capability + // escalation mechanism. + if let Some(unix_socket_header) = req.headers().get("x-unix-socket") { + let socket_path = match unix_socket_header.to_str() { + Ok(value) => value.to_string(), + Err(_) => { + warn!("invalid x-unix-socket header value (non-UTF8)"); + return Ok(text_response( + StatusCode::BAD_REQUEST, + "invalid x-unix-socket header", + )); + } + }; + let enabled = match app_state + .enabled() + .await + .map_err(|err| internal_error("failed to read enabled state", err)) + { + Ok(enabled) => enabled, + Err(resp) => return Ok(resp), + }; + if !enabled { + let client = client.as_deref().unwrap_or_default(); + warn!("unix socket blocked; proxy disabled (client={client}, path={socket_path})"); + return Ok(proxy_disabled_response( + &app_state, + socket_path, + client_addr(&req), + Some(req.method().as_str().to_string()), + "unix-socket", + ) + .await); + } + if !method_allowed { + let client = client.as_deref().unwrap_or_default(); + let method = req.method(); + warn!( + "unix socket blocked by method policy (client={client}, method={method}, mode=limited, allowed_methods=GET, HEAD, OPTIONS)" + ); + return Ok(json_blocked("unix-socket", REASON_METHOD_NOT_ALLOWED)); + } + + if !unix_socket_permissions_supported() { + warn!("unix socket proxy unsupported on this platform (path={socket_path})"); + return Ok(text_response( + StatusCode::NOT_IMPLEMENTED, + "unix sockets unsupported", + )); + } + + return match app_state.is_unix_socket_allowed(&socket_path).await { + Ok(true) => { + let client = client.as_deref().unwrap_or_default(); + info!("unix socket allowed (client={client}, path={socket_path})"); + match proxy_via_unix_socket(req, &socket_path).await { + Ok(resp) => Ok(resp), + Err(err) => { + warn!("unix socket proxy failed: {err}"); + Ok(text_response( + StatusCode::BAD_GATEWAY, + "unix socket proxy failed", + )) + } + } + } + Ok(false) => { + let client = client.as_deref().unwrap_or_default(); + warn!("unix socket blocked (client={client}, path={socket_path})"); + Ok(json_blocked("unix-socket", REASON_NOT_ALLOWED)) + } + Err(err) => { + warn!("unix socket check failed: {err}"); + Ok(text_response(StatusCode::INTERNAL_SERVER_ERROR, "error")) + } + }; + } + + let authority = match RequestContext::try_from(&req).map(|ctx| ctx.host_with_port()) { + Ok(authority) => authority, + Err(err) => { + warn!("missing host: {err}"); + return Ok(text_response(StatusCode::BAD_REQUEST, "missing host")); + } + }; + let host = normalize_host(&authority.host.to_string()); + let port = authority.port; + let enabled = match app_state + .enabled() + .await + .map_err(|err| internal_error("failed to read enabled state", err)) + { + Ok(enabled) => enabled, + Err(resp) => return Ok(resp), + }; + if !enabled { + let client = client.as_deref().unwrap_or_default(); + let method = req.method(); + warn!("request blocked; proxy disabled (client={client}, host={host}, method={method})"); + return Ok(proxy_disabled_response( + &app_state, + host, + client_addr(&req), + Some(req.method().as_str().to_string()), + "http", + ) + .await); + } + + let request = NetworkPolicyRequest::new( + NetworkProtocol::Http, + host.clone(), + port, + client.clone(), + Some(req.method().as_str().to_string()), + None, + None, + ); + + match evaluate_host_policy(&app_state, policy_decider.as_ref(), &request).await { + Ok(NetworkDecision::Deny { reason }) => { + let _ = app_state + .record_blocked(BlockedRequest::new( + host.clone(), + reason.clone(), + client.clone(), + Some(req.method().as_str().to_string()), + None, + "http".to_string(), + )) + .await; + let client = client.as_deref().unwrap_or_default(); + warn!("request blocked (client={client}, host={host}, reason={reason})"); + return Ok(json_blocked(&host, &reason)); + } + Ok(NetworkDecision::Allow) => {} + Err(err) => { + error!("failed to evaluate host for {host}: {err}"); + return Ok(text_response(StatusCode::INTERNAL_SERVER_ERROR, "error")); + } + } + + if !method_allowed { + let _ = app_state + .record_blocked(BlockedRequest::new( + host.clone(), + REASON_METHOD_NOT_ALLOWED.to_string(), + client.clone(), + Some(req.method().as_str().to_string()), + Some(NetworkMode::Limited), + "http".to_string(), + )) + .await; + let client = client.as_deref().unwrap_or_default(); + let method = req.method(); + warn!( + "request blocked by method policy (client={client}, host={host}, method={method}, mode=limited, allowed_methods=GET, HEAD, OPTIONS)" + ); + return Ok(json_blocked(&host, REASON_METHOD_NOT_ALLOWED)); + } + + let client = client.as_deref().unwrap_or_default(); + let method = req.method(); + info!("request allowed (client={client}, host={host}, method={method})"); + + let allow_upstream_proxy = match app_state + .allow_upstream_proxy() + .await + .map_err(|err| internal_error("failed to read upstream proxy config", err)) + { + Ok(allow) => allow, + Err(resp) => return Ok(resp), + }; + let client = if allow_upstream_proxy { + UpstreamClient::from_env_proxy() + } else { + UpstreamClient::direct() + }; + + match client.serve(req).await { + Ok(resp) => Ok(resp), + Err(err) => { + warn!("upstream request failed: {err}"); + Ok(text_response(StatusCode::BAD_GATEWAY, "upstream failure")) + } + } +} + +async fn proxy_via_unix_socket(req: Request, socket_path: &str) -> Result { + #[cfg(target_os = "macos")] + { + let client = UpstreamClient::unix_socket(socket_path); + + let (mut parts, body) = req.into_parts(); + let path = parts + .uri + .path_and_query() + .map(rama_http::uri::PathAndQuery::as_str) + .unwrap_or("/"); + parts.uri = path + .parse() + .with_context(|| format!("invalid unix socket request path: {path}"))?; + parts.headers.remove("x-unix-socket"); + + let req = Request::from_parts(parts, body); + client.serve(req).await.map_err(anyhow::Error::from) + } + #[cfg(not(target_os = "macos"))] + { + let _ = req; + let _ = socket_path; + Err(anyhow::anyhow!("unix sockets not supported")) + } +} + +fn client_addr(input: &T) -> Option { + input + .extensions() + .get::() + .map(|info| info.peer_addr().to_string()) +} + +fn json_blocked(host: &str, reason: &str) -> Response { + let response = BlockedResponse { + status: "blocked", + host, + reason, + }; + let mut resp = json_response(&response); + *resp.status_mut() = StatusCode::FORBIDDEN; + resp.headers_mut().insert( + "x-proxy-error", + HeaderValue::from_static(blocked_header_value(reason)), + ); + resp +} + +fn blocked_text(reason: &str) -> Response { + crate::responses::blocked_text_response(reason) +} + +async fn proxy_disabled_response( + app_state: &NetworkProxyState, + host: String, + client: Option, + method: Option, + protocol: &str, +) -> Response { + let _ = app_state + .record_blocked(BlockedRequest::new( + host, + REASON_PROXY_DISABLED.to_string(), + client, + method, + None, + protocol.to_string(), + )) + .await; + text_response(StatusCode::SERVICE_UNAVAILABLE, "proxy disabled") +} + +fn internal_error(context: &str, err: impl std::fmt::Display) -> Response { + error!("{context}: {err}"); + text_response(StatusCode::INTERNAL_SERVER_ERROR, "error") +} + +fn text_response(status: StatusCode, body: &str) -> Response { + Response::builder() + .status(status) + .header("content-type", "text/plain") + .body(Body::from(body.to_string())) + .unwrap_or_else(|_| Response::new(Body::from(body.to_string()))) +} + +#[derive(Serialize)] +struct BlockedResponse<'a> { + status: &'static str, + host: &'a str, + reason: &'a str, +} + +#[cfg(test)] +mod tests { + use super::*; + + use crate::config::NetworkMode; + use crate::config::NetworkPolicy; + use crate::runtime::network_proxy_state_for_policy; + use pretty_assertions::assert_eq; + use rama_http::Method; + use rama_http::Request; + use std::sync::Arc; + + #[tokio::test] + async fn http_connect_accept_blocks_in_limited_mode() { + let policy = NetworkPolicy { + allowed_domains: vec!["example.com".to_string()], + ..Default::default() + }; + let state = Arc::new(network_proxy_state_for_policy(policy)); + state.set_network_mode(NetworkMode::Limited).await.unwrap(); + + let mut req = Request::builder() + .method(Method::CONNECT) + .uri("https://example.com:443") + .header("host", "example.com:443") + .body(Body::empty()) + .unwrap(); + req.extensions_mut().insert(state); + + let response = http_connect_accept(None, req).await.unwrap_err(); + assert_eq!(response.status(), StatusCode::FORBIDDEN); + assert_eq!( + response.headers().get("x-proxy-error").unwrap(), + "blocked-by-method-policy" + ); + } +} diff --git a/codex-rs/network-proxy/src/lib.rs b/codex-rs/network-proxy/src/lib.rs new file mode 100644 index 0000000000..71e4ec7356 --- /dev/null +++ b/codex-rs/network-proxy/src/lib.rs @@ -0,0 +1,29 @@ +#![deny(clippy::print_stdout, clippy::print_stderr)] + +mod admin; +mod config; +mod http_proxy; +mod network_policy; +mod policy; +mod proxy; +mod reasons; +mod responses; +mod runtime; +mod state; +mod upstream; + +use anyhow::Result; +pub use network_policy::NetworkDecision; +pub use network_policy::NetworkPolicyDecider; +pub use network_policy::NetworkPolicyRequest; +pub use network_policy::NetworkProtocol; +pub use proxy::Args; +pub use proxy::NetworkProxy; +pub use proxy::NetworkProxyBuilder; +pub use proxy::NetworkProxyHandle; + +pub async fn run_main(args: Args) -> Result<()> { + let _ = args; + let proxy = NetworkProxy::builder().build().await?; + proxy.run().await?.wait().await +} diff --git a/codex-rs/network-proxy/src/main.rs b/codex-rs/network-proxy/src/main.rs new file mode 100644 index 0000000000..7cb28aad57 --- /dev/null +++ b/codex-rs/network-proxy/src/main.rs @@ -0,0 +1,14 @@ +use anyhow::Result; +use clap::Parser; +use codex_network_proxy::Args; +use codex_network_proxy::NetworkProxy; + +#[tokio::main] +async fn main() -> Result<()> { + tracing_subscriber::fmt::init(); + + let args = Args::parse(); + let _ = args; + let proxy = NetworkProxy::builder().build().await?; + proxy.run().await?.wait().await +} diff --git a/codex-rs/network-proxy/src/network_policy.rs b/codex-rs/network-proxy/src/network_policy.rs new file mode 100644 index 0000000000..6f410b3e4b --- /dev/null +++ b/codex-rs/network-proxy/src/network_policy.rs @@ -0,0 +1,234 @@ +use crate::reasons::REASON_POLICY_DENIED; +use crate::runtime::HostBlockDecision; +use crate::runtime::HostBlockReason; +use crate::state::NetworkProxyState; +use anyhow::Result; +use async_trait::async_trait; +use std::future::Future; +use std::sync::Arc; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum NetworkProtocol { + Http, + HttpsConnect, + Socks5Tcp, + Socks5Udp, +} + +#[derive(Clone, Debug)] +pub struct NetworkPolicyRequest { + pub protocol: NetworkProtocol, + pub host: String, + pub port: u16, + pub client_addr: Option, + pub method: Option, + pub command: Option, + pub exec_policy_hint: Option, +} + +impl NetworkPolicyRequest { + pub fn new( + protocol: NetworkProtocol, + host: String, + port: u16, + client_addr: Option, + method: Option, + command: Option, + exec_policy_hint: Option, + ) -> Self { + Self { + protocol, + host, + port, + client_addr, + method, + command, + exec_policy_hint, + } + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum NetworkDecision { + Allow, + Deny { reason: String }, +} + +impl NetworkDecision { + pub fn deny(reason: impl Into) -> Self { + let reason = reason.into(); + let reason = if reason.is_empty() { + REASON_POLICY_DENIED.to_string() + } else { + reason + }; + Self::Deny { reason } + } +} + +/// Decide whether a network request should be allowed. +/// +/// If `command` or `exec_policy_hint` is provided, callers can map exec-policy +/// approvals to network access (e.g., allow all requests for commands matching +/// approved prefixes like `curl *`). +#[async_trait] +pub trait NetworkPolicyDecider: Send + Sync + 'static { + async fn decide(&self, req: NetworkPolicyRequest) -> NetworkDecision; +} + +#[async_trait] +impl NetworkPolicyDecider for Arc { + async fn decide(&self, req: NetworkPolicyRequest) -> NetworkDecision { + (**self).decide(req).await + } +} + +#[async_trait] +impl NetworkPolicyDecider for F +where + F: Fn(NetworkPolicyRequest) -> Fut + Send + Sync + 'static, + Fut: Future + Send, +{ + async fn decide(&self, req: NetworkPolicyRequest) -> NetworkDecision { + (self)(req).await + } +} + +pub(crate) async fn evaluate_host_policy( + state: &NetworkProxyState, + decider: Option<&Arc>, + request: &NetworkPolicyRequest, +) -> Result { + match state.host_blocked(&request.host, request.port).await? { + HostBlockDecision::Allowed => Ok(NetworkDecision::Allow), + HostBlockDecision::Blocked(HostBlockReason::NotAllowed) => { + if let Some(decider) = decider { + Ok(decider.decide(request.clone()).await) + } else { + Ok(NetworkDecision::deny(HostBlockReason::NotAllowed.as_str())) + } + } + HostBlockDecision::Blocked(reason) => Ok(NetworkDecision::deny(reason.as_str())), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + use crate::config::NetworkPolicy; + use crate::reasons::REASON_DENIED; + use crate::reasons::REASON_NOT_ALLOWED_LOCAL; + use crate::state::network_proxy_state_for_policy; + use pretty_assertions::assert_eq; + use std::sync::Arc; + use std::sync::atomic::AtomicUsize; + use std::sync::atomic::Ordering; + + #[tokio::test] + async fn evaluate_host_policy_invokes_decider_for_not_allowed() { + let state = network_proxy_state_for_policy(NetworkPolicy::default()); + let calls = Arc::new(AtomicUsize::new(0)); + let decider: Arc = Arc::new({ + let calls = calls.clone(); + move |_req| { + calls.fetch_add(1, Ordering::SeqCst); + // The default policy denies all; the decider is consulted for not_allowed + // requests and can override that decision. + async { NetworkDecision::Allow } + } + }); + + let request = NetworkPolicyRequest::new( + NetworkProtocol::Http, + "example.com".to_string(), + 80, + None, + Some("GET".to_string()), + None, + None, + ); + + let decision = evaluate_host_policy(&state, Some(&decider), &request) + .await + .unwrap(); + assert_eq!(decision, NetworkDecision::Allow); + assert_eq!(calls.load(Ordering::SeqCst), 1); + } + + #[tokio::test] + async fn evaluate_host_policy_skips_decider_for_denied() { + let state = network_proxy_state_for_policy(NetworkPolicy { + allowed_domains: vec!["example.com".to_string()], + denied_domains: vec!["blocked.com".to_string()], + ..NetworkPolicy::default() + }); + let calls = Arc::new(AtomicUsize::new(0)); + let decider: Arc = Arc::new({ + let calls = calls.clone(); + move |_req| { + calls.fetch_add(1, Ordering::SeqCst); + async { NetworkDecision::Allow } + } + }); + + let request = NetworkPolicyRequest::new( + NetworkProtocol::Http, + "blocked.com".to_string(), + 80, + None, + Some("GET".to_string()), + None, + None, + ); + + let decision = evaluate_host_policy(&state, Some(&decider), &request) + .await + .unwrap(); + assert_eq!( + decision, + NetworkDecision::Deny { + reason: REASON_DENIED.to_string() + } + ); + assert_eq!(calls.load(Ordering::SeqCst), 0); + } + + #[tokio::test] + async fn evaluate_host_policy_skips_decider_for_not_allowed_local() { + let state = network_proxy_state_for_policy(NetworkPolicy { + allowed_domains: vec!["example.com".to_string()], + allow_local_binding: false, + ..NetworkPolicy::default() + }); + let calls = Arc::new(AtomicUsize::new(0)); + let decider: Arc = Arc::new({ + let calls = calls.clone(); + move |_req| { + calls.fetch_add(1, Ordering::SeqCst); + async { NetworkDecision::Allow } + } + }); + + let request = NetworkPolicyRequest::new( + NetworkProtocol::Http, + "127.0.0.1".to_string(), + 80, + None, + Some("GET".to_string()), + None, + None, + ); + + let decision = evaluate_host_policy(&state, Some(&decider), &request) + .await + .unwrap(); + assert_eq!( + decision, + NetworkDecision::Deny { + reason: REASON_NOT_ALLOWED_LOCAL.to_string() + } + ); + assert_eq!(calls.load(Ordering::SeqCst), 0); + } +} diff --git a/codex-rs/network-proxy/src/policy.rs b/codex-rs/network-proxy/src/policy.rs new file mode 100644 index 0000000000..d663aea065 --- /dev/null +++ b/codex-rs/network-proxy/src/policy.rs @@ -0,0 +1,435 @@ +#[cfg(test)] +use crate::config::NetworkMode; +use anyhow::Context; +use anyhow::Result; +use anyhow::ensure; +use globset::GlobBuilder; +use globset::GlobSet; +use globset::GlobSetBuilder; +use std::collections::HashSet; +use std::net::IpAddr; +use std::net::Ipv4Addr; +use std::net::Ipv6Addr; +use url::Host as UrlHost; + +/// A normalized host string for policy evaluation. +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +pub struct Host(String); + +impl Host { + pub fn parse(input: &str) -> Result { + let normalized = normalize_host(input); + ensure!(!normalized.is_empty(), "host is empty"); + Ok(Self(normalized)) + } + + pub fn as_str(&self) -> &str { + &self.0 + } +} + +/// Returns true if the host is a loopback hostname or IP literal. +pub fn is_loopback_host(host: &Host) -> bool { + let host = host.as_str(); + let host = host.split_once('%').map(|(ip, _)| ip).unwrap_or(host); + if host == "localhost" { + return true; + } + if let Ok(ip) = host.parse::() { + return ip.is_loopback(); + } + false +} + +pub fn is_non_public_ip(ip: IpAddr) -> bool { + match ip { + IpAddr::V4(ip) => is_non_public_ipv4(ip), + IpAddr::V6(ip) => is_non_public_ipv6(ip), + } +} + +fn is_non_public_ipv4(ip: Ipv4Addr) -> bool { + // Use the standard library classification helpers where possible; they encode the intent more + // clearly than hand-rolled range checks. Some non-public ranges (e.g., CGNAT and TEST-NET + // blocks) are not covered by stable stdlib helpers yet, so we fall back to CIDR checks. + ip.is_loopback() + || ip.is_private() + || ip.is_link_local() + || ip.is_unspecified() + || ip.is_multicast() + || ip.is_broadcast() + || ipv4_in_cidr(ip, [0, 0, 0, 0], 8) // "this network" (RFC 1122) + || ipv4_in_cidr(ip, [100, 64, 0, 0], 10) // CGNAT (RFC 6598) + || ipv4_in_cidr(ip, [192, 0, 0, 0], 24) // IETF Protocol Assignments (RFC 6890) + || ipv4_in_cidr(ip, [192, 0, 2, 0], 24) // TEST-NET-1 (RFC 5737) + || ipv4_in_cidr(ip, [198, 18, 0, 0], 15) // Benchmarking (RFC 2544) + || ipv4_in_cidr(ip, [198, 51, 100, 0], 24) // TEST-NET-2 (RFC 5737) + || ipv4_in_cidr(ip, [203, 0, 113, 0], 24) // TEST-NET-3 (RFC 5737) + || ipv4_in_cidr(ip, [240, 0, 0, 0], 4) // Reserved (RFC 6890) +} + +fn ipv4_in_cidr(ip: Ipv4Addr, base: [u8; 4], prefix: u8) -> bool { + let ip = u32::from(ip); + let base = u32::from(Ipv4Addr::from(base)); + let mask = if prefix == 0 { + 0 + } else { + u32::MAX << (32 - prefix) + }; + (ip & mask) == (base & mask) +} + +fn is_non_public_ipv6(ip: Ipv6Addr) -> bool { + if let Some(v4) = ip.to_ipv4() { + return is_non_public_ipv4(v4) || ip.is_loopback(); + } + // Treat anything that isn't globally routable as "local" for SSRF prevention. In particular: + // - `::1` loopback + // - `fc00::/7` unique-local (RFC 4193) + // - `fe80::/10` link-local + // - `::` unspecified + // - multicast ranges + ip.is_loopback() + || ip.is_unspecified() + || ip.is_multicast() + || ip.is_unique_local() + || ip.is_unicast_link_local() +} + +/// Normalize host fragments for policy matching (trim whitespace, strip ports/brackets, lowercase). +pub fn normalize_host(host: &str) -> String { + let host = host.trim(); + if host.starts_with('[') + && let Some(end) = host.find(']') + { + return normalize_dns_host(&host[1..end]); + } + + // The proxy stack should typically hand us a host without a port, but be + // defensive and strip `:port` when there is exactly one `:`. + if host.bytes().filter(|b| *b == b':').count() == 1 { + let host = host.split(':').next().unwrap_or_default(); + return normalize_dns_host(host); + } + + // Avoid mangling unbracketed IPv6 literals, but strip trailing dots so fully qualified domain + // names are treated the same as their dotless variants. + normalize_dns_host(host) +} + +fn normalize_dns_host(host: &str) -> String { + let host = host.to_ascii_lowercase(); + host.trim_end_matches('.').to_string() +} + +fn normalize_pattern(pattern: &str) -> String { + let pattern = pattern.trim(); + if pattern == "*" { + return "*".to_string(); + } + + let (prefix, remainder) = if let Some(domain) = pattern.strip_prefix("**.") { + ("**.", domain) + } else if let Some(domain) = pattern.strip_prefix("*.") { + ("*.", domain) + } else { + ("", pattern) + }; + + let remainder = normalize_host(remainder); + if prefix.is_empty() { + remainder + } else { + format!("{prefix}{remainder}") + } +} + +pub(crate) fn compile_globset(patterns: &[String]) -> Result { + let mut builder = GlobSetBuilder::new(); + let mut seen = HashSet::new(); + for pattern in patterns { + let pattern = normalize_pattern(pattern); + // Supported domain patterns: + // - "example.com": match the exact host + // - "*.example.com": match any subdomain (not the apex) + // - "**.example.com": match the apex and any subdomain + // - "*": match any host + for candidate in expand_domain_pattern(&pattern) { + if !seen.insert(candidate.clone()) { + continue; + } + let glob = GlobBuilder::new(&candidate) + .case_insensitive(true) + .build() + .with_context(|| format!("invalid glob pattern: {candidate}"))?; + builder.add(glob); + } + } + Ok(builder.build()?) +} + +#[derive(Debug, Clone)] +pub(crate) enum DomainPattern { + Any, + ApexAndSubdomains(String), + SubdomainsOnly(String), + Exact(String), +} + +impl DomainPattern { + /// Parse a policy pattern for constraint comparisons. + /// + /// Validation of glob syntax happens when building the globset; here we only + /// decode the wildcard prefixes to keep constraint checks lightweight. + pub(crate) fn parse(input: &str) -> Self { + let input = input.trim(); + if input.is_empty() { + return Self::Exact(String::new()); + } + if input == "*" { + Self::Any + } else if let Some(domain) = input.strip_prefix("**.") { + Self::parse_domain(domain, Self::ApexAndSubdomains) + } else if let Some(domain) = input.strip_prefix("*.") { + Self::parse_domain(domain, Self::SubdomainsOnly) + } else { + Self::Exact(input.to_string()) + } + } + + /// Parse a policy pattern for constraint comparisons, validating domain parts with `url`. + pub(crate) fn parse_for_constraints(input: &str) -> Self { + let input = input.trim(); + if input.is_empty() { + return Self::Exact(String::new()); + } + if input == "*" { + return Self::Any; + } + if let Some(domain) = input.strip_prefix("**.") { + return Self::ApexAndSubdomains(parse_domain_for_constraints(domain)); + } + if let Some(domain) = input.strip_prefix("*.") { + return Self::SubdomainsOnly(parse_domain_for_constraints(domain)); + } + Self::Exact(parse_domain_for_constraints(input)) + } + + fn parse_domain(domain: &str, build: impl FnOnce(String) -> Self) -> Self { + let domain = domain.trim(); + if domain.is_empty() { + return Self::Exact(String::new()); + } + build(domain.to_string()) + } + + pub(crate) fn allows(&self, candidate: &DomainPattern) -> bool { + match self { + DomainPattern::Any => true, + DomainPattern::Exact(domain) => match candidate { + DomainPattern::Exact(candidate) => domain_eq(candidate, domain), + _ => false, + }, + DomainPattern::SubdomainsOnly(domain) => match candidate { + DomainPattern::Any => false, + DomainPattern::Exact(candidate) => is_strict_subdomain(candidate, domain), + DomainPattern::SubdomainsOnly(candidate) => { + is_subdomain_or_equal(candidate, domain) + } + DomainPattern::ApexAndSubdomains(candidate) => { + is_strict_subdomain(candidate, domain) + } + }, + DomainPattern::ApexAndSubdomains(domain) => match candidate { + DomainPattern::Any => false, + DomainPattern::Exact(candidate) => is_subdomain_or_equal(candidate, domain), + DomainPattern::SubdomainsOnly(candidate) => { + is_subdomain_or_equal(candidate, domain) + } + DomainPattern::ApexAndSubdomains(candidate) => { + is_subdomain_or_equal(candidate, domain) + } + }, + } + } +} + +fn parse_domain_for_constraints(domain: &str) -> String { + let domain = domain.trim().trim_end_matches('.'); + if domain.is_empty() { + return String::new(); + } + let host = if domain.starts_with('[') && domain.ends_with(']') { + &domain[1..domain.len().saturating_sub(1)] + } else { + domain + }; + if host.contains('*') || host.contains('?') || host.contains('%') { + return domain.to_string(); + } + match UrlHost::parse(host) { + Ok(host) => host.to_string(), + Err(_) => String::new(), + } +} + +fn expand_domain_pattern(pattern: &str) -> Vec { + match DomainPattern::parse(pattern) { + DomainPattern::Any => vec![pattern.to_string()], + DomainPattern::Exact(domain) => vec![domain], + DomainPattern::SubdomainsOnly(domain) => { + vec![format!("?*.{domain}")] + } + DomainPattern::ApexAndSubdomains(domain) => { + vec![domain.clone(), format!("?*.{domain}")] + } + } +} + +fn normalize_domain(domain: &str) -> String { + domain.trim_end_matches('.').to_ascii_lowercase() +} + +fn domain_eq(left: &str, right: &str) -> bool { + normalize_domain(left) == normalize_domain(right) +} + +fn is_subdomain_or_equal(child: &str, parent: &str) -> bool { + let child = normalize_domain(child); + let parent = normalize_domain(parent); + if child == parent { + return true; + } + child.ends_with(&format!(".{parent}")) +} + +fn is_strict_subdomain(child: &str, parent: &str) -> bool { + let child = normalize_domain(child); + let parent = normalize_domain(parent); + child != parent && child.ends_with(&format!(".{parent}")) +} + +#[cfg(test)] +mod tests { + use super::*; + + use pretty_assertions::assert_eq; + + #[test] + fn method_allowed_full_allows_everything() { + assert!(NetworkMode::Full.allows_method("GET")); + assert!(NetworkMode::Full.allows_method("POST")); + assert!(NetworkMode::Full.allows_method("CONNECT")); + } + + #[test] + fn method_allowed_limited_allows_only_safe_methods() { + assert!(NetworkMode::Limited.allows_method("GET")); + assert!(NetworkMode::Limited.allows_method("HEAD")); + assert!(NetworkMode::Limited.allows_method("OPTIONS")); + assert!(!NetworkMode::Limited.allows_method("POST")); + assert!(!NetworkMode::Limited.allows_method("CONNECT")); + } + + #[test] + fn compile_globset_normalizes_trailing_dots() { + let set = compile_globset(&["Example.COM.".to_string()]).unwrap(); + + assert_eq!(true, set.is_match("example.com")); + assert_eq!(false, set.is_match("api.example.com")); + } + + #[test] + fn compile_globset_normalizes_wildcards() { + let set = compile_globset(&["*.Example.COM.".to_string()]).unwrap(); + + assert_eq!(true, set.is_match("api.example.com")); + assert_eq!(false, set.is_match("example.com")); + } + + #[test] + fn compile_globset_normalizes_apex_and_subdomains() { + let set = compile_globset(&["**.Example.COM.".to_string()]).unwrap(); + + assert_eq!(true, set.is_match("example.com")); + assert_eq!(true, set.is_match("api.example.com")); + } + + #[test] + fn compile_globset_normalizes_bracketed_ipv6_literals() { + let set = compile_globset(&["[::1]".to_string()]).unwrap(); + + assert_eq!(true, set.is_match("::1")); + } + + #[test] + fn is_loopback_host_handles_localhost_variants() { + assert!(is_loopback_host(&Host::parse("localhost").unwrap())); + assert!(is_loopback_host(&Host::parse("localhost.").unwrap())); + assert!(is_loopback_host(&Host::parse("LOCALHOST").unwrap())); + assert!(!is_loopback_host(&Host::parse("notlocalhost").unwrap())); + } + + #[test] + fn is_loopback_host_handles_ip_literals() { + assert!(is_loopback_host(&Host::parse("127.0.0.1").unwrap())); + assert!(is_loopback_host(&Host::parse("::1").unwrap())); + assert!(!is_loopback_host(&Host::parse("1.2.3.4").unwrap())); + } + + #[test] + fn is_non_public_ip_rejects_private_and_loopback_ranges() { + assert!(is_non_public_ip("127.0.0.1".parse().unwrap())); + assert!(is_non_public_ip("10.0.0.1".parse().unwrap())); + assert!(is_non_public_ip("192.168.0.1".parse().unwrap())); + assert!(is_non_public_ip("100.64.0.1".parse().unwrap())); + assert!(is_non_public_ip("192.0.0.1".parse().unwrap())); + assert!(is_non_public_ip("192.0.2.1".parse().unwrap())); + assert!(is_non_public_ip("198.18.0.1".parse().unwrap())); + assert!(is_non_public_ip("198.51.100.1".parse().unwrap())); + assert!(is_non_public_ip("203.0.113.1".parse().unwrap())); + assert!(is_non_public_ip("240.0.0.1".parse().unwrap())); + assert!(is_non_public_ip("0.1.2.3".parse().unwrap())); + assert!(!is_non_public_ip("8.8.8.8".parse().unwrap())); + + assert!(is_non_public_ip("::ffff:127.0.0.1".parse().unwrap())); + assert!(is_non_public_ip("::ffff:10.0.0.1".parse().unwrap())); + assert!(!is_non_public_ip("::ffff:8.8.8.8".parse().unwrap())); + + assert!(is_non_public_ip("::1".parse().unwrap())); + assert!(is_non_public_ip("fe80::1".parse().unwrap())); + assert!(is_non_public_ip("fc00::1".parse().unwrap())); + } + + #[test] + fn normalize_host_lowercases_and_trims() { + assert_eq!(normalize_host(" ExAmPlE.CoM "), "example.com"); + } + + #[test] + fn normalize_host_strips_port_for_host_port() { + assert_eq!(normalize_host("example.com:1234"), "example.com"); + } + + #[test] + fn normalize_host_preserves_unbracketed_ipv6() { + assert_eq!(normalize_host("2001:db8::1"), "2001:db8::1"); + } + + #[test] + fn normalize_host_strips_trailing_dot() { + assert_eq!(normalize_host("example.com."), "example.com"); + assert_eq!(normalize_host("ExAmPlE.CoM."), "example.com"); + } + + #[test] + fn normalize_host_strips_trailing_dot_with_port() { + assert_eq!(normalize_host("example.com.:443"), "example.com"); + } + + #[test] + fn normalize_host_strips_brackets_for_ipv6() { + assert_eq!(normalize_host("[::1]"), "::1"); + assert_eq!(normalize_host("[::1]:443"), "::1"); + } +} diff --git a/codex-rs/network-proxy/src/proxy.rs b/codex-rs/network-proxy/src/proxy.rs new file mode 100644 index 0000000000..480880e946 --- /dev/null +++ b/codex-rs/network-proxy/src/proxy.rs @@ -0,0 +1,176 @@ +use crate::admin; +use crate::config; +use crate::http_proxy; +use crate::network_policy::NetworkPolicyDecider; +use crate::runtime::unix_socket_permissions_supported; +use crate::state::NetworkProxyState; +use anyhow::Context; +use anyhow::Result; +use clap::Parser; +use std::net::SocketAddr; +use std::sync::Arc; +use tokio::task::JoinHandle; +use tracing::warn; + +#[derive(Debug, Clone, Parser)] +#[command(name = "codex-network-proxy", about = "Codex network sandbox proxy")] +pub struct Args {} + +#[derive(Clone, Default)] +pub struct NetworkProxyBuilder { + state: Option>, + http_addr: Option, + admin_addr: Option, + policy_decider: Option>, +} + +impl NetworkProxyBuilder { + pub fn state(mut self, state: Arc) -> Self { + self.state = Some(state); + self + } + + pub fn http_addr(mut self, addr: SocketAddr) -> Self { + self.http_addr = Some(addr); + self + } + + pub fn admin_addr(mut self, addr: SocketAddr) -> Self { + self.admin_addr = Some(addr); + self + } + + pub fn policy_decider(mut self, decider: D) -> Self + where + D: NetworkPolicyDecider, + { + self.policy_decider = Some(Arc::new(decider)); + self + } + + pub fn policy_decider_arc(mut self, decider: Arc) -> Self { + self.policy_decider = Some(decider); + self + } + + pub async fn build(self) -> Result { + let state = match self.state { + Some(state) => state, + None => Arc::new(NetworkProxyState::new().await?), + }; + let current_cfg = state.current_cfg().await?; + let runtime = config::resolve_runtime(¤t_cfg)?; + // Reapply bind clamping for caller overrides so unix-socket proxying stays loopback-only. + let (http_addr, admin_addr) = config::clamp_bind_addrs( + self.http_addr.unwrap_or(runtime.http_addr), + self.admin_addr.unwrap_or(runtime.admin_addr), + ¤t_cfg.network_proxy, + ); + + Ok(NetworkProxy { + state, + http_addr, + admin_addr, + policy_decider: self.policy_decider, + }) + } +} + +#[derive(Clone)] +pub struct NetworkProxy { + state: Arc, + http_addr: SocketAddr, + admin_addr: SocketAddr, + policy_decider: Option>, +} + +impl NetworkProxy { + pub fn builder() -> NetworkProxyBuilder { + NetworkProxyBuilder::default() + } + + pub async fn run(&self) -> Result { + let current_cfg = self.state.current_cfg().await?; + if !current_cfg.network_proxy.enabled { + warn!("network_proxy.enabled is false; skipping proxy listeners"); + return Ok(NetworkProxyHandle::noop()); + } + + if !unix_socket_permissions_supported() { + warn!("allowUnixSockets is macOS-only; requests will be rejected on this platform"); + } + + let http_task = tokio::spawn(http_proxy::run_http_proxy( + self.state.clone(), + self.http_addr, + self.policy_decider.clone(), + )); + let admin_task = tokio::spawn(admin::run_admin_api(self.state.clone(), self.admin_addr)); + + Ok(NetworkProxyHandle { + http_task: Some(http_task), + admin_task: Some(admin_task), + completed: false, + }) + } +} + +pub struct NetworkProxyHandle { + http_task: Option>>, + admin_task: Option>>, + completed: bool, +} + +impl NetworkProxyHandle { + fn noop() -> Self { + Self { + http_task: Some(tokio::spawn(async { Ok(()) })), + admin_task: Some(tokio::spawn(async { Ok(()) })), + completed: true, + } + } + + pub async fn wait(mut self) -> Result<()> { + let http_task = self.http_task.take().context("missing http proxy task")?; + let admin_task = self.admin_task.take().context("missing admin proxy task")?; + let http_result = http_task.await; + let admin_result = admin_task.await; + self.completed = true; + http_result??; + admin_result??; + Ok(()) + } + + pub async fn shutdown(mut self) -> Result<()> { + abort_tasks(self.http_task.take(), self.admin_task.take()).await; + self.completed = true; + Ok(()) + } +} + +async fn abort_tasks( + http_task: Option>>, + admin_task: Option>>, +) { + if let Some(http_task) = http_task { + http_task.abort(); + let _ = http_task.await; + } + if let Some(admin_task) = admin_task { + admin_task.abort(); + let _ = admin_task.await; + } +} + +impl Drop for NetworkProxyHandle { + fn drop(&mut self) { + if self.completed { + return; + } + let http_task = self.http_task.take(); + let admin_task = self.admin_task.take(); + tokio::spawn(async move { + abort_tasks(http_task, admin_task).await; + }); + } +} diff --git a/codex-rs/network-proxy/src/reasons.rs b/codex-rs/network-proxy/src/reasons.rs new file mode 100644 index 0000000000..99f7f742d0 --- /dev/null +++ b/codex-rs/network-proxy/src/reasons.rs @@ -0,0 +1,6 @@ +pub(crate) const REASON_DENIED: &str = "denied"; +pub(crate) const REASON_METHOD_NOT_ALLOWED: &str = "method_not_allowed"; +pub(crate) const REASON_NOT_ALLOWED: &str = "not_allowed"; +pub(crate) const REASON_NOT_ALLOWED_LOCAL: &str = "not_allowed_local"; +pub(crate) const REASON_POLICY_DENIED: &str = "policy_denied"; +pub(crate) const REASON_PROXY_DISABLED: &str = "proxy_disabled"; diff --git a/codex-rs/network-proxy/src/responses.rs b/codex-rs/network-proxy/src/responses.rs new file mode 100644 index 0000000000..d02d1910e8 --- /dev/null +++ b/codex-rs/network-proxy/src/responses.rs @@ -0,0 +1,67 @@ +use crate::reasons::REASON_DENIED; +use crate::reasons::REASON_METHOD_NOT_ALLOWED; +use crate::reasons::REASON_NOT_ALLOWED; +use crate::reasons::REASON_NOT_ALLOWED_LOCAL; +use rama_http::Body; +use rama_http::Response; +use rama_http::StatusCode; +use serde::Serialize; +use tracing::error; + +pub fn text_response(status: StatusCode, body: &str) -> Response { + Response::builder() + .status(status) + .header("content-type", "text/plain") + .body(Body::from(body.to_string())) + .unwrap_or_else(|_| Response::new(Body::from(body.to_string()))) +} + +pub fn json_response(value: &T) -> Response { + let body = match serde_json::to_string(value) { + Ok(body) => body, + Err(err) => { + error!("failed to serialize JSON response: {err}"); + "{}".to_string() + } + }; + Response::builder() + .status(StatusCode::OK) + .header("content-type", "application/json") + .body(Body::from(body)) + .unwrap_or_else(|err| { + error!("failed to build JSON response: {err}"); + Response::new(Body::from("{}")) + }) +} + +pub fn blocked_header_value(reason: &str) -> &'static str { + match reason { + REASON_NOT_ALLOWED | REASON_NOT_ALLOWED_LOCAL => "blocked-by-allowlist", + REASON_DENIED => "blocked-by-denylist", + REASON_METHOD_NOT_ALLOWED => "blocked-by-method-policy", + _ => "blocked-by-policy", + } +} + +pub fn blocked_message(reason: &str) -> &'static str { + match reason { + REASON_NOT_ALLOWED => "Codex blocked this request: domain not in allowlist.", + REASON_NOT_ALLOWED_LOCAL => { + "Codex blocked this request: local/private addresses not allowed." + } + REASON_DENIED => "Codex blocked this request: domain denied by policy.", + REASON_METHOD_NOT_ALLOWED => { + "Codex blocked this request: method not allowed in limited mode." + } + _ => "Codex blocked this request by network policy.", + } +} + +pub fn blocked_text_response(reason: &str) -> Response { + Response::builder() + .status(StatusCode::FORBIDDEN) + .header("content-type", "text/plain") + .header("x-proxy-error", blocked_header_value(reason)) + .body(Body::from(blocked_message(reason))) + .unwrap_or_else(|_| Response::new(Body::from("blocked"))) +} diff --git a/codex-rs/network-proxy/src/runtime.rs b/codex-rs/network-proxy/src/runtime.rs new file mode 100644 index 0000000000..49e34d5c0a --- /dev/null +++ b/codex-rs/network-proxy/src/runtime.rs @@ -0,0 +1,984 @@ +use crate::config::NetworkMode; +use crate::config::NetworkProxyConfig; +use crate::policy::Host; +use crate::policy::is_loopback_host; +use crate::policy::is_non_public_ip; +use crate::policy::normalize_host; +use crate::reasons::REASON_DENIED; +use crate::reasons::REASON_NOT_ALLOWED; +use crate::reasons::REASON_NOT_ALLOWED_LOCAL; +use crate::state::NetworkProxyConstraints; +use crate::state::build_config_state; +use crate::state::validate_policy_against_constraints; +use anyhow::Context; +use anyhow::Result; +use codex_utils_absolute_path::AbsolutePathBuf; +use globset::GlobSet; +use serde::Serialize; +use std::collections::HashSet; +use std::collections::VecDeque; +use std::net::IpAddr; +use std::path::Path; +use std::path::PathBuf; +use std::sync::Arc; +use std::time::Duration; +use std::time::SystemTime; +use time::OffsetDateTime; +use tokio::net::lookup_host; +use tokio::sync::RwLock; +use tokio::time::timeout; +use tracing::info; +use tracing::warn; + +const MAX_BLOCKED_EVENTS: usize = 200; +const DNS_LOOKUP_TIMEOUT: Duration = Duration::from_secs(2); + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum HostBlockReason { + Denied, + NotAllowed, + NotAllowedLocal, +} + +impl HostBlockReason { + pub const fn as_str(self) -> &'static str { + match self { + Self::Denied => REASON_DENIED, + Self::NotAllowed => REASON_NOT_ALLOWED, + Self::NotAllowedLocal => REASON_NOT_ALLOWED_LOCAL, + } + } +} + +impl std::fmt::Display for HostBlockReason { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(self.as_str()) + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum HostBlockDecision { + Allowed, + Blocked(HostBlockReason), +} + +#[derive(Clone, Debug, Serialize)] +pub struct BlockedRequest { + pub host: String, + pub reason: String, + pub client: Option, + pub method: Option, + pub mode: Option, + pub protocol: String, + pub timestamp: i64, +} + +impl BlockedRequest { + pub fn new( + host: String, + reason: String, + client: Option, + method: Option, + mode: Option, + protocol: String, + ) -> Self { + Self { + host, + reason, + client, + method, + mode, + protocol, + timestamp: unix_timestamp(), + } + } +} + +#[derive(Clone)] +pub(crate) struct ConfigState { + pub(crate) config: NetworkProxyConfig, + pub(crate) allow_set: GlobSet, + pub(crate) deny_set: GlobSet, + pub(crate) constraints: NetworkProxyConstraints, + pub(crate) layer_mtimes: Vec, + pub(crate) cfg_path: PathBuf, + pub(crate) blocked: VecDeque, +} + +#[derive(Clone)] +pub(crate) struct LayerMtime { + pub(crate) path: PathBuf, + pub(crate) mtime: Option, +} + +impl LayerMtime { + pub(crate) fn new(path: PathBuf) -> Self { + let mtime = path.metadata().and_then(|m| m.modified()).ok(); + Self { path, mtime } + } +} + +#[derive(Clone)] +pub struct NetworkProxyState { + state: Arc>, +} + +impl std::fmt::Debug for NetworkProxyState { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + // Avoid logging internal state (config contents, derived globsets, etc.) which can be noisy + // and may contain sensitive paths. + f.debug_struct("NetworkProxyState").finish_non_exhaustive() + } +} + +impl NetworkProxyState { + pub async fn new() -> Result { + let cfg_state = build_config_state().await?; + Ok(Self { + state: Arc::new(RwLock::new(cfg_state)), + }) + } + + pub async fn current_cfg(&self) -> Result { + // Callers treat `NetworkProxyState` as a live view of policy. We reload-on-demand so edits to + // `config.toml` (including Codex-managed writes) take effect without a restart. + self.reload_if_needed().await?; + let guard = self.state.read().await; + Ok(guard.config.clone()) + } + + pub async fn current_patterns(&self) -> Result<(Vec, Vec)> { + self.reload_if_needed().await?; + let guard = self.state.read().await; + Ok(( + guard.config.network_proxy.policy.allowed_domains.clone(), + guard.config.network_proxy.policy.denied_domains.clone(), + )) + } + + pub async fn enabled(&self) -> Result { + self.reload_if_needed().await?; + let guard = self.state.read().await; + Ok(guard.config.network_proxy.enabled) + } + + pub async fn force_reload(&self) -> Result<()> { + let (previous_cfg, cfg_path) = { + let guard = self.state.read().await; + (guard.config.clone(), guard.cfg_path.clone()) + }; + + match build_config_state().await { + Ok(mut new_state) => { + // Policy changes are operationally sensitive; logging diffs makes changes traceable + // without needing to dump full config blobs (which can include unrelated settings). + log_policy_changes(&previous_cfg, &new_state.config); + let mut guard = self.state.write().await; + new_state.blocked = guard.blocked.clone(); + *guard = new_state; + let path = guard.cfg_path.display(); + info!("reloaded config from {path}"); + Ok(()) + } + Err(err) => { + let path = cfg_path.display(); + warn!("failed to reload config from {path}: {err}; keeping previous config"); + Err(err) + } + } + } + + pub async fn host_blocked(&self, host: &str, port: u16) -> Result { + self.reload_if_needed().await?; + let host = match Host::parse(host) { + Ok(host) => host, + Err(_) => return Ok(HostBlockDecision::Blocked(HostBlockReason::NotAllowed)), + }; + let (deny_set, allow_set, allow_local_binding, allowed_domains_empty, allowed_domains) = { + let guard = self.state.read().await; + ( + guard.deny_set.clone(), + guard.allow_set.clone(), + guard.config.network_proxy.policy.allow_local_binding, + guard.config.network_proxy.policy.allowed_domains.is_empty(), + guard.config.network_proxy.policy.allowed_domains.clone(), + ) + }; + + let host_str = host.as_str(); + + // Decision order matters: + // 1) explicit deny always wins + // 2) local/private networking is opt-in (defense-in-depth) + // 3) allowlist is enforced when configured + if deny_set.is_match(host_str) { + return Ok(HostBlockDecision::Blocked(HostBlockReason::Denied)); + } + + let is_allowlisted = allow_set.is_match(host_str); + if !allow_local_binding { + // If the intent is "prevent access to local/internal networks", we must not rely solely + // on string checks like `localhost` / `127.0.0.1`. Attackers can use DNS rebinding or + // public suffix services that map hostnames onto private IPs. + // + // We therefore do a best-effort DNS + IP classification check before allowing the + // request. Explicit local/loopback literals are allowed only when explicitly + // allowlisted; hostnames that resolve to local/private IPs are blocked even if + // allowlisted. + let local_literal = { + let host_no_scope = host_str + .split_once('%') + .map(|(ip, _)| ip) + .unwrap_or(host_str); + if is_loopback_host(&host) { + true + } else if let Ok(ip) = host_no_scope.parse::() { + is_non_public_ip(ip) + } else { + false + } + }; + + if local_literal { + if !is_explicit_local_allowlisted(&allowed_domains, &host) { + return Ok(HostBlockDecision::Blocked(HostBlockReason::NotAllowedLocal)); + } + } else if host_resolves_to_non_public_ip(host_str, port).await { + return Ok(HostBlockDecision::Blocked(HostBlockReason::NotAllowedLocal)); + } + } + + if allowed_domains_empty || !is_allowlisted { + Ok(HostBlockDecision::Blocked(HostBlockReason::NotAllowed)) + } else { + Ok(HostBlockDecision::Allowed) + } + } + + pub async fn record_blocked(&self, entry: BlockedRequest) -> Result<()> { + self.reload_if_needed().await?; + let mut guard = self.state.write().await; + guard.blocked.push_back(entry); + while guard.blocked.len() > MAX_BLOCKED_EVENTS { + guard.blocked.pop_front(); + } + Ok(()) + } + + /// Drain and return the buffered blocked-request entries in FIFO order. + pub async fn drain_blocked(&self) -> Result> { + self.reload_if_needed().await?; + let blocked = { + let mut guard = self.state.write().await; + std::mem::take(&mut guard.blocked) + }; + Ok(blocked.into_iter().collect()) + } + + pub async fn is_unix_socket_allowed(&self, path: &str) -> Result { + self.reload_if_needed().await?; + if !unix_socket_permissions_supported() { + return Ok(false); + } + + // We only support absolute unix socket paths (a relative path would be ambiguous with + // respect to the proxy process's CWD and can lead to confusing allowlist behavior). + let requested_path = Path::new(path); + if !requested_path.is_absolute() { + return Ok(false); + } + + let guard = self.state.read().await; + // Normalize the path while keeping the absolute-path requirement explicit. + let requested_abs = match AbsolutePathBuf::from_absolute_path(requested_path) { + Ok(path) => path, + Err(_) => return Ok(false), + }; + let requested_canonical = std::fs::canonicalize(requested_abs.as_path()).ok(); + for allowed in &guard.config.network_proxy.policy.allow_unix_sockets { + if allowed == path { + return Ok(true); + } + + // Best-effort canonicalization to reduce surprises with symlinks. + // If canonicalization fails (e.g., socket not created yet), fall back to raw comparison. + let Some(requested_canonical) = &requested_canonical else { + continue; + }; + if let Ok(allowed_canonical) = std::fs::canonicalize(allowed) + && &allowed_canonical == requested_canonical + { + return Ok(true); + } + } + Ok(false) + } + + pub async fn method_allowed(&self, method: &str) -> Result { + self.reload_if_needed().await?; + let guard = self.state.read().await; + Ok(guard.config.network_proxy.mode.allows_method(method)) + } + + pub async fn allow_upstream_proxy(&self) -> Result { + self.reload_if_needed().await?; + let guard = self.state.read().await; + Ok(guard.config.network_proxy.allow_upstream_proxy) + } + + pub async fn network_mode(&self) -> Result { + self.reload_if_needed().await?; + let guard = self.state.read().await; + Ok(guard.config.network_proxy.mode) + } + + pub async fn set_network_mode(&self, mode: NetworkMode) -> Result<()> { + loop { + self.reload_if_needed().await?; + let (candidate, constraints) = { + let guard = self.state.read().await; + let mut candidate = guard.config.clone(); + candidate.network_proxy.mode = mode; + (candidate, guard.constraints.clone()) + }; + + validate_policy_against_constraints(&candidate, &constraints) + .context("network_proxy.mode constrained by managed config")?; + + let mut guard = self.state.write().await; + if guard.constraints != constraints { + drop(guard); + continue; + } + guard.config.network_proxy.mode = mode; + info!("updated network mode to {mode:?}"); + return Ok(()); + } + } + + async fn reload_if_needed(&self) -> Result<()> { + let needs_reload = { + let guard = self.state.read().await; + guard.layer_mtimes.iter().any(|layer| { + let metadata = std::fs::metadata(&layer.path).ok(); + match (metadata.and_then(|m| m.modified().ok()), layer.mtime) { + (Some(new_mtime), Some(old_mtime)) => new_mtime > old_mtime, + (Some(_), None) => true, + (None, Some(_)) => true, + (None, None) => false, + } + }) + }; + + if !needs_reload { + return Ok(()); + } + + self.force_reload().await + } +} + +pub(crate) fn unix_socket_permissions_supported() -> bool { + cfg!(target_os = "macos") +} + +async fn host_resolves_to_non_public_ip(host: &str, port: u16) -> bool { + if let Ok(ip) = host.parse::() { + return is_non_public_ip(ip); + } + + // If DNS lookup fails, default to "not local/private" rather than blocking. In practice, the + // subsequent connect attempt will fail anyway, and blocking on transient resolver issues would + // make the proxy fragile. The allowlist/denylist remains the primary control plane. + let addrs = match timeout(DNS_LOOKUP_TIMEOUT, lookup_host((host, port))).await { + Ok(Ok(addrs)) => addrs, + Ok(Err(_)) | Err(_) => return false, + }; + + for addr in addrs { + if is_non_public_ip(addr.ip()) { + return true; + } + } + + false +} + +fn log_policy_changes(previous: &NetworkProxyConfig, next: &NetworkProxyConfig) { + log_domain_list_changes( + "allowlist", + &previous.network_proxy.policy.allowed_domains, + &next.network_proxy.policy.allowed_domains, + ); + log_domain_list_changes( + "denylist", + &previous.network_proxy.policy.denied_domains, + &next.network_proxy.policy.denied_domains, + ); +} + +fn log_domain_list_changes(list_name: &str, previous: &[String], next: &[String]) { + let previous_set: HashSet = previous + .iter() + .map(|entry| entry.to_ascii_lowercase()) + .collect(); + let next_set: HashSet = next + .iter() + .map(|entry| entry.to_ascii_lowercase()) + .collect(); + + let added = next_set + .difference(&previous_set) + .cloned() + .collect::>(); + let removed = previous_set + .difference(&next_set) + .cloned() + .collect::>(); + + let mut seen_next = HashSet::new(); + for entry in next { + let key = entry.to_ascii_lowercase(); + if seen_next.insert(key.clone()) && added.contains(&key) { + info!("config entry added to {list_name}: {entry}"); + } + } + + let mut seen_previous = HashSet::new(); + for entry in previous { + let key = entry.to_ascii_lowercase(); + if seen_previous.insert(key.clone()) && removed.contains(&key) { + info!("config entry removed from {list_name}: {entry}"); + } + } +} + +fn is_explicit_local_allowlisted(allowed_domains: &[String], host: &Host) -> bool { + let normalized_host = host.as_str(); + allowed_domains.iter().any(|pattern| { + let pattern = pattern.trim(); + if pattern == "*" || pattern.starts_with("*.") || pattern.starts_with("**.") { + return false; + } + if pattern.contains('*') || pattern.contains('?') { + return false; + } + normalize_host(pattern) == normalized_host + }) +} + +fn unix_timestamp() -> i64 { + OffsetDateTime::now_utc().unix_timestamp() +} + +#[cfg(test)] +pub(crate) fn network_proxy_state_for_policy( + policy: crate::config::NetworkPolicy, +) -> NetworkProxyState { + let config = NetworkProxyConfig { + network_proxy: crate::config::NetworkProxySettings { + enabled: true, + mode: NetworkMode::Full, + policy, + ..crate::config::NetworkProxySettings::default() + }, + }; + + let allow_set = + crate::policy::compile_globset(&config.network_proxy.policy.allowed_domains).unwrap(); + let deny_set = + crate::policy::compile_globset(&config.network_proxy.policy.denied_domains).unwrap(); + + let state = ConfigState { + config, + allow_set, + deny_set, + constraints: NetworkProxyConstraints::default(), + layer_mtimes: Vec::new(), + cfg_path: PathBuf::from("/nonexistent/config.toml"), + blocked: VecDeque::new(), + }; + + NetworkProxyState { + state: Arc::new(RwLock::new(state)), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + use crate::config::NetworkPolicy; + use crate::config::NetworkProxyConfig; + use crate::config::NetworkProxySettings; + use crate::policy::compile_globset; + use crate::state::NetworkProxyConstraints; + use crate::state::validate_policy_against_constraints; + use pretty_assertions::assert_eq; + + #[tokio::test] + async fn host_blocked_denied_wins_over_allowed() { + let state = network_proxy_state_for_policy(NetworkPolicy { + allowed_domains: vec!["example.com".to_string()], + denied_domains: vec!["example.com".to_string()], + ..NetworkPolicy::default() + }); + + assert_eq!( + state.host_blocked("example.com", 80).await.unwrap(), + HostBlockDecision::Blocked(HostBlockReason::Denied) + ); + } + + #[tokio::test] + async fn host_blocked_requires_allowlist_match() { + let state = network_proxy_state_for_policy(NetworkPolicy { + allowed_domains: vec!["example.com".to_string()], + ..NetworkPolicy::default() + }); + + assert_eq!( + state.host_blocked("example.com", 80).await.unwrap(), + HostBlockDecision::Allowed + ); + assert_eq!( + // Use a public IP literal to avoid relying on ambient DNS behavior (some networks + // resolve unknown hostnames to private IPs, which would trigger `not_allowed_local`). + state.host_blocked("8.8.8.8", 80).await.unwrap(), + HostBlockDecision::Blocked(HostBlockReason::NotAllowed) + ); + } + + #[tokio::test] + async fn host_blocked_subdomain_wildcards_exclude_apex() { + let state = network_proxy_state_for_policy(NetworkPolicy { + allowed_domains: vec!["*.openai.com".to_string()], + ..NetworkPolicy::default() + }); + + assert_eq!( + state.host_blocked("api.openai.com", 80).await.unwrap(), + HostBlockDecision::Allowed + ); + assert_eq!( + state.host_blocked("openai.com", 80).await.unwrap(), + HostBlockDecision::Blocked(HostBlockReason::NotAllowed) + ); + } + + #[tokio::test] + async fn host_blocked_rejects_loopback_when_local_binding_disabled() { + let state = network_proxy_state_for_policy(NetworkPolicy { + allowed_domains: vec!["example.com".to_string()], + allow_local_binding: false, + ..NetworkPolicy::default() + }); + + assert_eq!( + state.host_blocked("127.0.0.1", 80).await.unwrap(), + HostBlockDecision::Blocked(HostBlockReason::NotAllowedLocal) + ); + assert_eq!( + state.host_blocked("localhost", 80).await.unwrap(), + HostBlockDecision::Blocked(HostBlockReason::NotAllowedLocal) + ); + } + + #[tokio::test] + async fn host_blocked_rejects_loopback_when_allowlist_is_wildcard() { + let state = network_proxy_state_for_policy(NetworkPolicy { + allowed_domains: vec!["*".to_string()], + allow_local_binding: false, + ..NetworkPolicy::default() + }); + + assert_eq!( + state.host_blocked("127.0.0.1", 80).await.unwrap(), + HostBlockDecision::Blocked(HostBlockReason::NotAllowedLocal) + ); + } + + #[tokio::test] + async fn host_blocked_rejects_private_ip_literal_when_allowlist_is_wildcard() { + let state = network_proxy_state_for_policy(NetworkPolicy { + allowed_domains: vec!["*".to_string()], + allow_local_binding: false, + ..NetworkPolicy::default() + }); + + assert_eq!( + state.host_blocked("10.0.0.1", 80).await.unwrap(), + HostBlockDecision::Blocked(HostBlockReason::NotAllowedLocal) + ); + } + + #[tokio::test] + async fn host_blocked_allows_loopback_when_explicitly_allowlisted_and_local_binding_disabled() { + let state = network_proxy_state_for_policy(NetworkPolicy { + allowed_domains: vec!["localhost".to_string()], + allow_local_binding: false, + ..NetworkPolicy::default() + }); + + assert_eq!( + state.host_blocked("localhost", 80).await.unwrap(), + HostBlockDecision::Allowed + ); + } + + #[tokio::test] + async fn host_blocked_allows_private_ip_literal_when_explicitly_allowlisted() { + let state = network_proxy_state_for_policy(NetworkPolicy { + allowed_domains: vec!["10.0.0.1".to_string()], + allow_local_binding: false, + ..NetworkPolicy::default() + }); + + assert_eq!( + state.host_blocked("10.0.0.1", 80).await.unwrap(), + HostBlockDecision::Allowed + ); + } + + #[tokio::test] + async fn host_blocked_rejects_scoped_ipv6_literal_when_not_allowlisted() { + let state = network_proxy_state_for_policy(NetworkPolicy { + allowed_domains: vec!["example.com".to_string()], + allow_local_binding: false, + ..NetworkPolicy::default() + }); + + assert_eq!( + state.host_blocked("fe80::1%lo0", 80).await.unwrap(), + HostBlockDecision::Blocked(HostBlockReason::NotAllowedLocal) + ); + } + + #[tokio::test] + async fn host_blocked_allows_scoped_ipv6_literal_when_explicitly_allowlisted() { + let state = network_proxy_state_for_policy(NetworkPolicy { + allowed_domains: vec!["fe80::1%lo0".to_string()], + allow_local_binding: false, + ..NetworkPolicy::default() + }); + + assert_eq!( + state.host_blocked("fe80::1%lo0", 80).await.unwrap(), + HostBlockDecision::Allowed + ); + } + + #[tokio::test] + async fn host_blocked_rejects_private_ip_literals_when_local_binding_disabled() { + let state = network_proxy_state_for_policy(NetworkPolicy { + allowed_domains: vec!["example.com".to_string()], + allow_local_binding: false, + ..NetworkPolicy::default() + }); + + assert_eq!( + state.host_blocked("10.0.0.1", 80).await.unwrap(), + HostBlockDecision::Blocked(HostBlockReason::NotAllowedLocal) + ); + } + + #[tokio::test] + async fn host_blocked_rejects_loopback_when_allowlist_empty() { + let state = network_proxy_state_for_policy(NetworkPolicy { + allowed_domains: vec![], + allow_local_binding: false, + ..NetworkPolicy::default() + }); + + assert_eq!( + state.host_blocked("127.0.0.1", 80).await.unwrap(), + HostBlockDecision::Blocked(HostBlockReason::NotAllowedLocal) + ); + } + + #[test] + fn validate_policy_against_constraints_disallows_widening_allowed_domains() { + let constraints = NetworkProxyConstraints { + allowed_domains: Some(vec!["example.com".to_string()]), + ..NetworkProxyConstraints::default() + }; + + let config = NetworkProxyConfig { + network_proxy: NetworkProxySettings { + enabled: true, + policy: NetworkPolicy { + allowed_domains: vec!["example.com".to_string(), "evil.com".to_string()], + ..NetworkPolicy::default() + }, + ..NetworkProxySettings::default() + }, + }; + + assert!(validate_policy_against_constraints(&config, &constraints).is_err()); + } + + #[test] + fn validate_policy_against_constraints_disallows_widening_mode() { + let constraints = NetworkProxyConstraints { + mode: Some(NetworkMode::Limited), + ..NetworkProxyConstraints::default() + }; + + let config = NetworkProxyConfig { + network_proxy: NetworkProxySettings { + enabled: true, + mode: NetworkMode::Full, + ..NetworkProxySettings::default() + }, + }; + + assert!(validate_policy_against_constraints(&config, &constraints).is_err()); + } + + #[test] + fn validate_policy_against_constraints_allows_narrowing_wildcard_allowlist() { + let constraints = NetworkProxyConstraints { + allowed_domains: Some(vec!["*.example.com".to_string()]), + ..NetworkProxyConstraints::default() + }; + + let config = NetworkProxyConfig { + network_proxy: NetworkProxySettings { + enabled: true, + policy: NetworkPolicy { + allowed_domains: vec!["api.example.com".to_string()], + ..NetworkPolicy::default() + }, + ..NetworkProxySettings::default() + }, + }; + + assert!(validate_policy_against_constraints(&config, &constraints).is_ok()); + } + + #[test] + fn validate_policy_against_constraints_rejects_widening_wildcard_allowlist() { + let constraints = NetworkProxyConstraints { + allowed_domains: Some(vec!["*.example.com".to_string()]), + ..NetworkProxyConstraints::default() + }; + + let config = NetworkProxyConfig { + network_proxy: NetworkProxySettings { + enabled: true, + policy: NetworkPolicy { + allowed_domains: vec!["**.example.com".to_string()], + ..NetworkPolicy::default() + }, + ..NetworkProxySettings::default() + }, + }; + + assert!(validate_policy_against_constraints(&config, &constraints).is_err()); + } + + #[test] + fn validate_policy_against_constraints_requires_managed_denied_domains_entries() { + let constraints = NetworkProxyConstraints { + denied_domains: Some(vec!["evil.com".to_string()]), + ..NetworkProxyConstraints::default() + }; + + let config = NetworkProxyConfig { + network_proxy: NetworkProxySettings { + enabled: true, + policy: NetworkPolicy { + denied_domains: vec![], + ..NetworkPolicy::default() + }, + ..NetworkProxySettings::default() + }, + }; + + assert!(validate_policy_against_constraints(&config, &constraints).is_err()); + } + + #[test] + fn validate_policy_against_constraints_disallows_enabling_when_managed_disabled() { + let constraints = NetworkProxyConstraints { + enabled: Some(false), + ..NetworkProxyConstraints::default() + }; + + let config = NetworkProxyConfig { + network_proxy: NetworkProxySettings { + enabled: true, + ..NetworkProxySettings::default() + }, + }; + + assert!(validate_policy_against_constraints(&config, &constraints).is_err()); + } + + #[test] + fn validate_policy_against_constraints_disallows_allow_local_binding_when_managed_disabled() { + let constraints = NetworkProxyConstraints { + allow_local_binding: Some(false), + ..NetworkProxyConstraints::default() + }; + + let config = NetworkProxyConfig { + network_proxy: NetworkProxySettings { + enabled: true, + policy: NetworkPolicy { + allow_local_binding: true, + ..NetworkPolicy::default() + }, + ..NetworkProxySettings::default() + }, + }; + + assert!(validate_policy_against_constraints(&config, &constraints).is_err()); + } + + #[test] + fn validate_policy_against_constraints_disallows_non_loopback_admin_without_managed_opt_in() { + let constraints = NetworkProxyConstraints { + dangerously_allow_non_loopback_admin: Some(false), + ..NetworkProxyConstraints::default() + }; + + let config = NetworkProxyConfig { + network_proxy: NetworkProxySettings { + enabled: true, + dangerously_allow_non_loopback_admin: true, + ..NetworkProxySettings::default() + }, + }; + + assert!(validate_policy_against_constraints(&config, &constraints).is_err()); + } + + #[test] + fn validate_policy_against_constraints_allows_non_loopback_admin_with_managed_opt_in() { + let constraints = NetworkProxyConstraints { + dangerously_allow_non_loopback_admin: Some(true), + ..NetworkProxyConstraints::default() + }; + + let config = NetworkProxyConfig { + network_proxy: NetworkProxySettings { + enabled: true, + dangerously_allow_non_loopback_admin: true, + ..NetworkProxySettings::default() + }, + }; + + assert!(validate_policy_against_constraints(&config, &constraints).is_ok()); + } + + #[test] + fn compile_globset_is_case_insensitive() { + let patterns = vec!["ExAmPle.CoM".to_string()]; + let set = compile_globset(&patterns).unwrap(); + assert!(set.is_match("example.com")); + assert!(set.is_match("EXAMPLE.COM")); + } + + #[test] + fn compile_globset_excludes_apex_for_subdomain_patterns() { + let patterns = vec!["*.openai.com".to_string()]; + let set = compile_globset(&patterns).unwrap(); + assert!(set.is_match("api.openai.com")); + assert!(!set.is_match("openai.com")); + assert!(!set.is_match("evilopenai.com")); + } + + #[test] + fn compile_globset_includes_apex_for_double_wildcard_patterns() { + let patterns = vec!["**.openai.com".to_string()]; + let set = compile_globset(&patterns).unwrap(); + assert!(set.is_match("openai.com")); + assert!(set.is_match("api.openai.com")); + assert!(!set.is_match("evilopenai.com")); + } + + #[test] + fn compile_globset_matches_all_with_star() { + let patterns = vec!["*".to_string()]; + let set = compile_globset(&patterns).unwrap(); + assert!(set.is_match("openai.com")); + assert!(set.is_match("api.openai.com")); + } + + #[test] + fn compile_globset_dedupes_patterns_without_changing_behavior() { + let patterns = vec!["example.com".to_string(), "example.com".to_string()]; + let set = compile_globset(&patterns).unwrap(); + assert!(set.is_match("example.com")); + assert!(set.is_match("EXAMPLE.COM")); + assert!(!set.is_match("not-example.com")); + } + + #[test] + fn compile_globset_rejects_invalid_patterns() { + let patterns = vec!["[".to_string()]; + assert!(compile_globset(&patterns).is_err()); + } + + #[cfg(target_os = "macos")] + #[tokio::test] + async fn unix_socket_allowlist_is_respected_on_macos() { + let socket_path = "/tmp/example.sock".to_string(); + let state = network_proxy_state_for_policy(NetworkPolicy { + allowed_domains: vec!["example.com".to_string()], + allow_unix_sockets: vec![socket_path.clone()], + ..NetworkPolicy::default() + }); + + assert!(state.is_unix_socket_allowed(&socket_path).await.unwrap()); + assert!( + !state + .is_unix_socket_allowed("/tmp/not-allowed.sock") + .await + .unwrap() + ); + } + + #[cfg(target_os = "macos")] + #[tokio::test] + async fn unix_socket_allowlist_resolves_symlinks() { + use std::os::unix::fs::symlink; + use tempfile::tempdir; + + let temp_dir = tempdir().unwrap(); + let dir = temp_dir.path(); + + let real = dir.join("real.sock"); + let link = dir.join("link.sock"); + + // The allowlist mechanism is path-based; for test purposes we don't need an actual unix + // domain socket. Any filesystem entry works for canonicalization. + std::fs::write(&real, b"not a socket").unwrap(); + symlink(&real, &link).unwrap(); + + let real_s = real.to_str().unwrap().to_string(); + let link_s = link.to_str().unwrap().to_string(); + + let state = network_proxy_state_for_policy(NetworkPolicy { + allowed_domains: vec!["example.com".to_string()], + allow_unix_sockets: vec![real_s], + ..NetworkPolicy::default() + }); + + assert!(state.is_unix_socket_allowed(&link_s).await.unwrap()); + } + + #[cfg(not(target_os = "macos"))] + #[tokio::test] + async fn unix_socket_allowlist_is_rejected_on_non_macos() { + let socket_path = "/tmp/example.sock".to_string(); + let state = network_proxy_state_for_policy(NetworkPolicy { + allowed_domains: vec!["example.com".to_string()], + allow_unix_sockets: vec![socket_path.clone()], + ..NetworkPolicy::default() + }); + + assert!(!state.is_unix_socket_allowed(&socket_path).await.unwrap()); + } +} diff --git a/codex-rs/network-proxy/src/state.rs b/codex-rs/network-proxy/src/state.rs new file mode 100644 index 0000000000..e1e8a1e4e6 --- /dev/null +++ b/codex-rs/network-proxy/src/state.rs @@ -0,0 +1,419 @@ +use crate::config::NetworkMode; +use crate::config::NetworkProxyConfig; +use crate::policy::DomainPattern; +use crate::policy::compile_globset; +use crate::runtime::ConfigState; +use crate::runtime::LayerMtime; +use anyhow::Context; +use anyhow::Result; +use codex_app_server_protocol::ConfigLayerSource; +use codex_core::config::CONFIG_TOML_FILE; +use codex_core::config::Constrained; +use codex_core::config::ConstraintError; +use codex_core::config::find_codex_home; +use codex_core::config_loader::ConfigLayerStack; +use codex_core::config_loader::ConfigLayerStackOrdering; +use codex_core::config_loader::LoaderOverrides; +use codex_core::config_loader::RequirementSource; +use codex_core::config_loader::load_config_layers_state; +use serde::Deserialize; +use std::collections::HashSet; + +pub use crate::runtime::BlockedRequest; +pub use crate::runtime::NetworkProxyState; +#[cfg(test)] +pub(crate) use crate::runtime::network_proxy_state_for_policy; + +pub(crate) async fn build_config_state() -> Result { + // Load config through `codex-core` so we inherit the same layer ordering and semantics as the + // rest of Codex (system/managed layers, user layers, session flags, etc.). + let codex_home = find_codex_home().context("failed to resolve CODEX_HOME")?; + let cli_overrides = Vec::new(); + let overrides = LoaderOverrides::default(); + let config_layer_stack = load_config_layers_state(&codex_home, None, &cli_overrides, overrides) + .await + .context("failed to load Codex config")?; + + let cfg_path = codex_home.join(CONFIG_TOML_FILE); + + // Deserialize from the merged effective config, rather than parsing config.toml ourselves. + // This avoids a second parser/merger implementation (and the drift that comes with it). + let merged_toml = config_layer_stack.effective_config(); + let config: NetworkProxyConfig = merged_toml + .try_into() + .context("failed to deserialize network proxy config")?; + + // Security boundary: user-controlled layers must not be able to widen restrictions set by + // trusted/managed layers (e.g., MDM). Enforce this before building runtime state. + let constraints = enforce_trusted_constraints(&config_layer_stack, &config)?; + + let layer_mtimes = collect_layer_mtimes(&config_layer_stack); + let deny_set = compile_globset(&config.network_proxy.policy.denied_domains)?; + let allow_set = compile_globset(&config.network_proxy.policy.allowed_domains)?; + Ok(ConfigState { + config, + allow_set, + deny_set, + constraints, + layer_mtimes, + cfg_path, + blocked: std::collections::VecDeque::new(), + }) +} + +fn collect_layer_mtimes(stack: &ConfigLayerStack) -> Vec { + stack + .get_layers(ConfigLayerStackOrdering::LowestPrecedenceFirst, false) + .iter() + .filter_map(|layer| { + let path = match &layer.name { + ConfigLayerSource::System { file } => Some(file.as_path().to_path_buf()), + ConfigLayerSource::User { file } => Some(file.as_path().to_path_buf()), + ConfigLayerSource::Project { dot_codex_folder } => dot_codex_folder + .join(CONFIG_TOML_FILE) + .ok() + .map(|p| p.as_path().to_path_buf()), + ConfigLayerSource::LegacyManagedConfigTomlFromFile { file } => { + Some(file.as_path().to_path_buf()) + } + _ => None, + }; + path.map(LayerMtime::new) + }) + .collect() +} + +#[derive(Debug, Default, Deserialize)] +struct PartialConfig { + #[serde(default)] + network_proxy: PartialNetworkProxyConfig, +} + +#[derive(Debug, Default, Deserialize)] +struct PartialNetworkProxyConfig { + enabled: Option, + mode: Option, + allow_upstream_proxy: Option, + dangerously_allow_non_loopback_proxy: Option, + dangerously_allow_non_loopback_admin: Option, + #[serde(default)] + policy: PartialNetworkPolicy, +} + +#[derive(Debug, Default, Deserialize)] +struct PartialNetworkPolicy { + #[serde(default)] + allowed_domains: Option>, + #[serde(default)] + denied_domains: Option>, + #[serde(default)] + allow_unix_sockets: Option>, + #[serde(default)] + allow_local_binding: Option, +} + +#[derive(Debug, Default, Clone, PartialEq, Eq)] +pub(crate) struct NetworkProxyConstraints { + pub(crate) enabled: Option, + pub(crate) mode: Option, + pub(crate) allow_upstream_proxy: Option, + pub(crate) dangerously_allow_non_loopback_proxy: Option, + pub(crate) dangerously_allow_non_loopback_admin: Option, + pub(crate) allowed_domains: Option>, + pub(crate) denied_domains: Option>, + pub(crate) allow_unix_sockets: Option>, + pub(crate) allow_local_binding: Option, +} + +fn enforce_trusted_constraints( + layers: &codex_core::config_loader::ConfigLayerStack, + config: &NetworkProxyConfig, +) -> Result { + let constraints = network_proxy_constraints_from_trusted_layers(layers)?; + validate_policy_against_constraints(config, &constraints) + .context("network proxy constraints")?; + Ok(constraints) +} + +fn network_proxy_constraints_from_trusted_layers( + layers: &codex_core::config_loader::ConfigLayerStack, +) -> Result { + let mut constraints = NetworkProxyConstraints::default(); + for layer in layers.get_layers( + codex_core::config_loader::ConfigLayerStackOrdering::LowestPrecedenceFirst, + false, + ) { + // Only trusted layers contribute constraints. User-controlled layers can narrow policy but + // must never widen beyond what managed config allows. + if is_user_controlled_layer(&layer.name) { + continue; + } + + let partial: PartialConfig = layer + .config + .clone() + .try_into() + .context("failed to deserialize trusted config layer")?; + + if let Some(enabled) = partial.network_proxy.enabled { + constraints.enabled = Some(enabled); + } + if let Some(mode) = partial.network_proxy.mode { + constraints.mode = Some(mode); + } + if let Some(allow_upstream_proxy) = partial.network_proxy.allow_upstream_proxy { + constraints.allow_upstream_proxy = Some(allow_upstream_proxy); + } + if let Some(dangerously_allow_non_loopback_proxy) = + partial.network_proxy.dangerously_allow_non_loopback_proxy + { + constraints.dangerously_allow_non_loopback_proxy = + Some(dangerously_allow_non_loopback_proxy); + } + if let Some(dangerously_allow_non_loopback_admin) = + partial.network_proxy.dangerously_allow_non_loopback_admin + { + constraints.dangerously_allow_non_loopback_admin = + Some(dangerously_allow_non_loopback_admin); + } + + if let Some(allowed_domains) = partial.network_proxy.policy.allowed_domains { + constraints.allowed_domains = Some(allowed_domains); + } + if let Some(denied_domains) = partial.network_proxy.policy.denied_domains { + constraints.denied_domains = Some(denied_domains); + } + if let Some(allow_unix_sockets) = partial.network_proxy.policy.allow_unix_sockets { + constraints.allow_unix_sockets = Some(allow_unix_sockets); + } + if let Some(allow_local_binding) = partial.network_proxy.policy.allow_local_binding { + constraints.allow_local_binding = Some(allow_local_binding); + } + } + Ok(constraints) +} + +fn is_user_controlled_layer(layer: &ConfigLayerSource) -> bool { + matches!( + layer, + ConfigLayerSource::User { .. } + | ConfigLayerSource::Project { .. } + | ConfigLayerSource::SessionFlags + ) +} + +pub(crate) fn validate_policy_against_constraints( + config: &NetworkProxyConfig, + constraints: &NetworkProxyConstraints, +) -> std::result::Result<(), ConstraintError> { + fn invalid_value( + field_name: &'static str, + candidate: impl Into, + allowed: impl Into, + ) -> ConstraintError { + ConstraintError::InvalidValue { + field_name, + candidate: candidate.into(), + allowed: allowed.into(), + requirement_source: RequirementSource::Unknown, + } + } + + let enabled = config.network_proxy.enabled; + if let Some(max_enabled) = constraints.enabled { + let _ = Constrained::new(enabled, move |candidate| { + if *candidate && !max_enabled { + Err(invalid_value( + "network_proxy.enabled", + "true", + "false (disabled by managed config)", + )) + } else { + Ok(()) + } + })?; + } + + if let Some(max_mode) = constraints.mode { + let _ = Constrained::new(config.network_proxy.mode, move |candidate| { + if network_mode_rank(*candidate) > network_mode_rank(max_mode) { + Err(invalid_value( + "network_proxy.mode", + format!("{candidate:?}"), + format!("{max_mode:?} or more restrictive"), + )) + } else { + Ok(()) + } + })?; + } + + let allow_upstream_proxy = constraints.allow_upstream_proxy; + let _ = Constrained::new( + config.network_proxy.allow_upstream_proxy, + move |candidate| match allow_upstream_proxy { + Some(true) | None => Ok(()), + Some(false) => { + if *candidate { + Err(invalid_value( + "network_proxy.allow_upstream_proxy", + "true", + "false (disabled by managed config)", + )) + } else { + Ok(()) + } + } + }, + )?; + + let allow_non_loopback_admin = constraints.dangerously_allow_non_loopback_admin; + let _ = Constrained::new( + config.network_proxy.dangerously_allow_non_loopback_admin, + move |candidate| match allow_non_loopback_admin { + Some(true) | None => Ok(()), + Some(false) => { + if *candidate { + Err(invalid_value( + "network_proxy.dangerously_allow_non_loopback_admin", + "true", + "false (disabled by managed config)", + )) + } else { + Ok(()) + } + } + }, + )?; + + let allow_non_loopback_proxy = constraints.dangerously_allow_non_loopback_proxy; + let _ = Constrained::new( + config.network_proxy.dangerously_allow_non_loopback_proxy, + move |candidate| match allow_non_loopback_proxy { + Some(true) | None => Ok(()), + Some(false) => { + if *candidate { + Err(invalid_value( + "network_proxy.dangerously_allow_non_loopback_proxy", + "true", + "false (disabled by managed config)", + )) + } else { + Ok(()) + } + } + }, + )?; + + if let Some(allow_local_binding) = constraints.allow_local_binding { + let _ = Constrained::new( + config.network_proxy.policy.allow_local_binding, + move |candidate| { + if *candidate && !allow_local_binding { + Err(invalid_value( + "network_proxy.policy.allow_local_binding", + "true", + "false (disabled by managed config)", + )) + } else { + Ok(()) + } + }, + )?; + } + + if let Some(allowed_domains) = &constraints.allowed_domains { + let managed_patterns: Vec = allowed_domains + .iter() + .map(|entry| DomainPattern::parse_for_constraints(entry)) + .collect(); + let _ = Constrained::new( + config.network_proxy.policy.allowed_domains.clone(), + move |candidate| { + let mut invalid = Vec::new(); + for entry in candidate { + let candidate_pattern = DomainPattern::parse_for_constraints(entry); + if !managed_patterns + .iter() + .any(|managed| managed.allows(&candidate_pattern)) + { + invalid.push(entry.clone()); + } + } + if invalid.is_empty() { + Ok(()) + } else { + Err(invalid_value( + "network_proxy.policy.allowed_domains", + format!("{invalid:?}"), + "subset of managed allowed_domains", + )) + } + }, + )?; + } + + if let Some(denied_domains) = &constraints.denied_domains { + let required_set: HashSet = denied_domains + .iter() + .map(|s| s.to_ascii_lowercase()) + .collect(); + let _ = Constrained::new( + config.network_proxy.policy.denied_domains.clone(), + move |candidate| { + let candidate_set: HashSet = + candidate.iter().map(|s| s.to_ascii_lowercase()).collect(); + let missing: Vec = required_set + .iter() + .filter(|entry| !candidate_set.contains(*entry)) + .cloned() + .collect(); + if missing.is_empty() { + Ok(()) + } else { + Err(invalid_value( + "network_proxy.policy.denied_domains", + "missing managed denied_domains entries", + format!("{missing:?}"), + )) + } + }, + )?; + } + + if let Some(allow_unix_sockets) = &constraints.allow_unix_sockets { + let allowed_set: HashSet = allow_unix_sockets + .iter() + .map(|s| s.to_ascii_lowercase()) + .collect(); + let _ = Constrained::new( + config.network_proxy.policy.allow_unix_sockets.clone(), + move |candidate| { + let mut invalid = Vec::new(); + for entry in candidate { + if !allowed_set.contains(&entry.to_ascii_lowercase()) { + invalid.push(entry.clone()); + } + } + if invalid.is_empty() { + Ok(()) + } else { + Err(invalid_value( + "network_proxy.policy.allow_unix_sockets", + format!("{invalid:?}"), + "subset of managed allow_unix_sockets", + )) + } + }, + )?; + } + + Ok(()) +} + +fn network_mode_rank(mode: NetworkMode) -> u8 { + match mode { + NetworkMode::Limited => 0, + NetworkMode::Full => 1, + } +} diff --git a/codex-rs/network-proxy/src/upstream.rs b/codex-rs/network-proxy/src/upstream.rs new file mode 100644 index 0000000000..9b69955bd4 --- /dev/null +++ b/codex-rs/network-proxy/src/upstream.rs @@ -0,0 +1,188 @@ +use rama_core::Layer; +use rama_core::Service; +use rama_core::error::BoxError; +use rama_core::error::ErrorContext as _; +use rama_core::error::OpaqueError; +use rama_core::extensions::ExtensionsMut; +use rama_core::extensions::ExtensionsRef; +use rama_core::service::BoxService; +use rama_http::Body; +use rama_http::Request; +use rama_http::Response; +use rama_http::layer::version_adapter::RequestVersionAdapter; +use rama_http_backend::client::HttpClientService; +use rama_http_backend::client::HttpConnector; +use rama_http_backend::client::proxy::layer::HttpProxyConnectorLayer; +use rama_net::address::ProxyAddress; +use rama_net::client::EstablishedClientConnection; +use rama_net::http::RequestContext; +use rama_tcp::client::service::TcpConnector; +use rama_tls_boring::client::TlsConnectorDataBuilder; +use rama_tls_boring::client::TlsConnectorLayer; +use tracing::warn; + +#[cfg(target_os = "macos")] +use rama_unix::client::UnixConnector; + +#[derive(Clone, Default)] +struct ProxyConfig { + http: Option, + https: Option, + all: Option, +} + +impl ProxyConfig { + fn from_env() -> Self { + let http = read_proxy_env(&["HTTP_PROXY", "http_proxy"]); + let https = read_proxy_env(&["HTTPS_PROXY", "https_proxy"]); + let all = read_proxy_env(&["ALL_PROXY", "all_proxy"]); + Self { http, https, all } + } + + fn proxy_for_request(&self, req: &Request) -> Option { + let is_secure = RequestContext::try_from(req) + .map(|ctx| ctx.protocol.is_secure()) + .unwrap_or(false); + self.proxy_for_protocol(is_secure) + } + + fn proxy_for_protocol(&self, is_secure: bool) -> Option { + if is_secure { + self.https + .clone() + .or_else(|| self.http.clone()) + .or_else(|| self.all.clone()) + } else { + self.http.clone().or_else(|| self.all.clone()) + } + } +} + +fn read_proxy_env(keys: &[&str]) -> Option { + for key in keys { + let Ok(value) = std::env::var(key) else { + continue; + }; + let value = value.trim(); + if value.is_empty() { + continue; + } + match ProxyAddress::try_from(value) { + Ok(proxy) => { + if proxy + .protocol + .as_ref() + .map(rama_net::Protocol::is_http) + .unwrap_or(true) + { + return Some(proxy); + } + warn!("ignoring {key}: non-http proxy protocol"); + } + Err(err) => { + warn!("ignoring {key}: invalid proxy address ({err})"); + } + } + } + None +} + +pub(crate) fn proxy_for_connect() -> Option { + ProxyConfig::from_env().proxy_for_protocol(true) +} + +#[derive(Clone)] +pub(crate) struct UpstreamClient { + connector: BoxService< + Request, + EstablishedClientConnection, Request>, + BoxError, + >, + proxy_config: ProxyConfig, +} + +impl UpstreamClient { + pub(crate) fn direct() -> Self { + Self::new(ProxyConfig::default()) + } + + pub(crate) fn from_env_proxy() -> Self { + Self::new(ProxyConfig::from_env()) + } + + #[cfg(target_os = "macos")] + pub(crate) fn unix_socket(path: &str) -> Self { + let connector = build_unix_connector(path); + Self { + connector, + proxy_config: ProxyConfig::default(), + } + } + + fn new(proxy_config: ProxyConfig) -> Self { + let connector = build_http_connector(); + Self { + connector, + proxy_config, + } + } +} + +impl Service> for UpstreamClient { + type Output = Response; + type Error = OpaqueError; + + async fn serve(&self, mut req: Request) -> Result { + if let Some(proxy) = self.proxy_config.proxy_for_request(&req) { + req.extensions_mut().insert(proxy); + } + + let uri = req.uri().clone(); + let EstablishedClientConnection { + input: mut req, + conn: http_connection, + } = self + .connector + .serve(req) + .await + .map_err(OpaqueError::from_boxed)?; + + req.extensions_mut() + .extend(http_connection.extensions().clone()); + + http_connection + .serve(req) + .await + .map_err(OpaqueError::from_boxed) + .with_context(|| format!("http request failure for uri: {uri}")) + } +} + +fn build_http_connector() -> BoxService< + Request, + EstablishedClientConnection, Request>, + BoxError, +> { + let transport = TcpConnector::default(); + let proxy = HttpProxyConnectorLayer::optional().into_layer(transport); + let tls_config = TlsConnectorDataBuilder::new_http_auto().into_shared_builder(); + let tls = TlsConnectorLayer::auto() + .with_connector_data(tls_config) + .into_layer(proxy); + let tls = RequestVersionAdapter::new(tls); + let connector = HttpConnector::new(tls); + connector.boxed() +} + +#[cfg(target_os = "macos")] +fn build_unix_connector( + path: &str, +) -> BoxService< + Request, + EstablishedClientConnection, Request>, + BoxError, +> { + let transport = UnixConnector::fixed(path); + let connector = HttpConnector::new(transport); + connector.boxed() +} From b3127e2eebcdc872cf43966649561b6d10aa8c56 Mon Sep 17 00:00:00 2001 From: Ahmed Ibrahim Date: Fri, 23 Jan 2026 19:28:49 -0800 Subject: [PATCH 18/36] Have a coding mode and only show coding and plan (#9802) --- .../tests/suite/v2/collaboration_mode_list.rs | 18 +++++++- codex-rs/core/config.schema.json | 1 + codex-rs/core/src/features.rs | 2 +- .../collaboration_mode_presets.rs | 19 ++++++++- .../core/templates/collaboration_mode/code.md | 1 + codex-rs/protocol/src/config_types.rs | 1 + codex-rs/tui/src/bottom_pane/footer.rs | 3 ++ codex-rs/tui/src/chatwidget.rs | 19 +++++---- ...get__tests__plan_implementation_popup.snap | 2 +- ...plan_implementation_popup_no_selected.snap | 2 +- codex-rs/tui/src/chatwidget/tests.rs | 41 +++++++++---------- codex-rs/tui/src/collaboration_modes.rs | 34 +++++++++++---- 12 files changed, 99 insertions(+), 44 deletions(-) create mode 100644 codex-rs/core/templates/collaboration_mode/code.md diff --git a/codex-rs/app-server/tests/suite/v2/collaboration_mode_list.rs b/codex-rs/app-server/tests/suite/v2/collaboration_mode_list.rs index f3a931f003..cb9ff3e18e 100644 --- a/codex-rs/app-server/tests/suite/v2/collaboration_mode_list.rs +++ b/codex-rs/app-server/tests/suite/v2/collaboration_mode_list.rs @@ -1,7 +1,7 @@ //! Validates that the collaboration mode list endpoint returns the expected default presets. //! //! The test drives the app server through the MCP harness and asserts that the list response -//! includes the plan, pair programming, and execute modes with their default model and reasoning +//! includes the plan, coding, pair programming, and execute modes with their default model and reasoning //! effort settings, which keeps the API contract visible in one place. #![allow(clippy::unwrap_used)] @@ -45,7 +45,12 @@ async fn list_collaboration_modes_returns_presets() -> Result<()> { let CollaborationModeListResponse { data: items } = to_response::(response)?; - let expected = vec![plan_preset(), pair_programming_preset(), execute_preset()]; + let expected = vec![ + plan_preset(), + code_preset(), + pair_programming_preset(), + execute_preset(), + ]; assert_eq!(expected, items); Ok(()) } @@ -74,6 +79,15 @@ fn pair_programming_preset() -> CollaborationMode { .unwrap() } +/// Builds the code preset that the list response is expected to return. +fn code_preset() -> CollaborationMode { + let presets = test_builtin_collaboration_mode_presets(); + presets + .into_iter() + .find(|p| p.mode == ModeKind::Code) + .unwrap() +} + /// Builds the execute preset that the list response is expected to return. /// /// The execute preset uses a different reasoning effort to capture the higher-effort diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index 13cdbc6740..68b9ba699c 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -350,6 +350,7 @@ "description": "Initial collaboration mode to use when the TUI starts.", "enum": [ "plan", + "code", "pair_programming", "execute", "custom" diff --git a/codex-rs/core/src/features.rs b/codex-rs/core/src/features.rs index a408f8ffcc..9a9527aa7a 100644 --- a/codex-rs/core/src/features.rs +++ b/codex-rs/core/src/features.rs @@ -105,7 +105,7 @@ pub enum Feature { Connectors, /// Steer feature flag - when enabled, Enter submits immediately instead of queuing. Steer, - /// Enable collaboration modes (Plan, Pair Programming, Execute). + /// Enable collaboration modes (Plan, Code, Pair Programming, Execute). CollaborationModes, /// Use the Responses API WebSocket transport for OpenAI by default. ResponsesWebsockets, diff --git a/codex-rs/core/src/models_manager/collaboration_mode_presets.rs b/codex-rs/core/src/models_manager/collaboration_mode_presets.rs index 70989cb7cd..ac1334c56c 100644 --- a/codex-rs/core/src/models_manager/collaboration_mode_presets.rs +++ b/codex-rs/core/src/models_manager/collaboration_mode_presets.rs @@ -4,13 +4,19 @@ use codex_protocol::config_types::Settings; use codex_protocol::openai_models::ReasoningEffort; const COLLABORATION_MODE_PLAN: &str = include_str!("../../templates/collaboration_mode/plan.md"); +const COLLABORATION_MODE_CODE: &str = include_str!("../../templates/collaboration_mode/code.md"); const COLLABORATION_MODE_PAIR_PROGRAMMING: &str = include_str!("../../templates/collaboration_mode/pair_programming.md"); const COLLABORATION_MODE_EXECUTE: &str = include_str!("../../templates/collaboration_mode/execute.md"); pub(super) fn builtin_collaboration_mode_presets() -> Vec { - vec![plan_preset(), pair_programming_preset(), execute_preset()] + vec![ + plan_preset(), + code_preset(), + pair_programming_preset(), + execute_preset(), + ] } #[cfg(any(test, feature = "test-support"))] @@ -29,6 +35,17 @@ fn plan_preset() -> CollaborationMode { } } +fn code_preset() -> CollaborationMode { + CollaborationMode { + mode: ModeKind::Code, + settings: Settings { + model: "gpt-5.2-codex".to_string(), + reasoning_effort: Some(ReasoningEffort::Medium), + developer_instructions: Some(COLLABORATION_MODE_CODE.to_string()), + }, + } +} + fn pair_programming_preset() -> CollaborationMode { CollaborationMode { mode: ModeKind::PairProgramming, diff --git a/codex-rs/core/templates/collaboration_mode/code.md b/codex-rs/core/templates/collaboration_mode/code.md new file mode 100644 index 0000000000..2294e61993 --- /dev/null +++ b/codex-rs/core/templates/collaboration_mode/code.md @@ -0,0 +1 @@ +you are now in code mode. diff --git a/codex-rs/protocol/src/config_types.rs b/codex-rs/protocol/src/config_types.rs index c242d07de4..444901bf75 100644 --- a/codex-rs/protocol/src/config_types.rs +++ b/codex-rs/protocol/src/config_types.rs @@ -156,6 +156,7 @@ pub enum AltScreenMode { #[serde(rename_all = "snake_case")] pub enum ModeKind { Plan, + Code, PairProgramming, Execute, Custom, diff --git a/codex-rs/tui/src/bottom_pane/footer.rs b/codex-rs/tui/src/bottom_pane/footer.rs index ca83af9013..16f4f22a94 100644 --- a/codex-rs/tui/src/bottom_pane/footer.rs +++ b/codex-rs/tui/src/bottom_pane/footer.rs @@ -49,6 +49,7 @@ pub(crate) struct FooterProps { #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub(crate) enum CollaborationModeIndicator { Plan, + Code, PairProgramming, Execute, } @@ -64,6 +65,7 @@ impl CollaborationModeIndicator { }; match self { CollaborationModeIndicator::Plan => format!("Plan mode{suffix}"), + CollaborationModeIndicator::Code => format!("Code mode{suffix}"), CollaborationModeIndicator::PairProgramming => { format!("Pair Programming mode{suffix}") } @@ -75,6 +77,7 @@ impl CollaborationModeIndicator { let label = self.label(show_cycle_hint); match self { CollaborationModeIndicator::Plan => Span::from(label).magenta(), + CollaborationModeIndicator::Code => Span::from(label).cyan(), CollaborationModeIndicator::PairProgramming => Span::from(label).cyan(), CollaborationModeIndicator::Execute => Span::from(label).dim(), } diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 89f6a96c5c..1f7fad3ea7 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -122,7 +122,7 @@ const DEFAULT_MODEL_DISPLAY_NAME: &str = "loading"; const PLAN_IMPLEMENTATION_TITLE: &str = "Implement this plan?"; const PLAN_IMPLEMENTATION_YES: &str = "Yes, implement this plan"; const PLAN_IMPLEMENTATION_NO: &str = "No, stay in Plan mode"; -const PLAN_IMPLEMENTATION_EXECUTE_MESSAGE: &str = "Implement the plan."; +const PLAN_IMPLEMENTATION_CODING_MESSAGE: &str = "Implement the plan."; use crate::app_event::AppEvent; use crate::app_event::ExitMode; @@ -932,10 +932,10 @@ impl ChatWidget { } fn open_plan_implementation_prompt(&mut self) { - let execute_mode = collaboration_modes::execute_mode(self.models_manager.as_ref()); - let (implement_actions, implement_disabled_reason) = match execute_mode { + let code_mode = collaboration_modes::code_mode(self.models_manager.as_ref()); + let (implement_actions, implement_disabled_reason) = match code_mode { Some(collaboration_mode) => { - let user_text = PLAN_IMPLEMENTATION_EXECUTE_MESSAGE.to_string(); + let user_text = PLAN_IMPLEMENTATION_CODING_MESSAGE.to_string(); let actions: Vec = vec![Box::new(move |tx| { tx.send(AppEvent::SubmitUserMessageWithMode { text: user_text.clone(), @@ -944,13 +944,13 @@ impl ChatWidget { })]; (actions, None) } - None => (Vec::new(), Some("Execute mode unavailable".to_string())), + None => (Vec::new(), Some("Code mode unavailable".to_string())), }; let items = vec![ SelectionItem { name: PLAN_IMPLEMENTATION_YES.to_string(), - description: Some("Switch to Execute and start coding.".to_string()), + description: Some("Switch to Code and start coding.".to_string()), selected_description: None, is_current: false, actions: implement_actions, @@ -3549,7 +3549,7 @@ impl ChatWidget { } pub(crate) fn open_collaboration_modes_popup(&mut self) { - let presets = self.models_manager.list_collaboration_modes(); + let presets = collaboration_modes::presets_for_tui(self.models_manager.as_ref()); if presets.is_empty() { self.add_info_message( "No collaboration modes are available right now.".to_string(), @@ -3563,6 +3563,7 @@ impl ChatWidget { .map(|preset| { let name = match preset.mode { ModeKind::Plan => "Plan", + ModeKind::Code => "Code", ModeKind::PairProgramming => "Pair Programming", ModeKind::Execute => "Execute", ModeKind::Custom => "Custom", @@ -4641,6 +4642,7 @@ impl ChatWidget { } match self.stored_collaboration_mode.mode { ModeKind::Plan => Some("Plan"), + ModeKind::Code => Some("Code"), ModeKind::PairProgramming => Some("Pair Programming"), ModeKind::Execute => Some("Execute"), ModeKind::Custom => None, @@ -4653,6 +4655,7 @@ impl ChatWidget { } match self.stored_collaboration_mode.mode { ModeKind::Plan => Some(CollaborationModeIndicator::Plan), + ModeKind::Code => Some(CollaborationModeIndicator::Code), ModeKind::PairProgramming => Some(CollaborationModeIndicator::PairProgramming), ModeKind::Execute => Some(CollaborationModeIndicator::Execute), ModeKind::Custom => None, @@ -4664,7 +4667,7 @@ impl ChatWidget { self.bottom_pane.set_collaboration_mode_indicator(indicator); } - /// Cycle to the next collaboration mode variant (Plan -> PairProgramming -> Execute -> Plan). + /// Cycle to the next collaboration mode variant (Plan -> Code -> Plan). fn cycle_collaboration_mode(&mut self) { if !self.collaboration_modes_enabled() { return; diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__plan_implementation_popup.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__plan_implementation_popup.snap index da42eb07bb..1fe6fe9e88 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__plan_implementation_popup.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__plan_implementation_popup.snap @@ -4,7 +4,7 @@ expression: popup --- Implement this plan? -› 1. Yes, implement this plan Switch to Execute and start coding. +› 1. Yes, implement this plan Switch to Code and start coding. 2. No, stay in Plan mode Continue planning with the model. Press enter to confirm or esc to go back diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__plan_implementation_popup_no_selected.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__plan_implementation_popup_no_selected.snap index d5ba66c1bc..4179616589 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__plan_implementation_popup_no_selected.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__plan_implementation_popup_no_selected.snap @@ -4,7 +4,7 @@ expression: popup --- Implement this plan? - 1. Yes, implement this plan Switch to Execute and start coding. + 1. Yes, implement this plan Switch to Code and start coding. › 2. No, stay in Plan mode Continue planning with the model. 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 9fe7d3a1c9..6a7401ce6c 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -1213,32 +1213,32 @@ async fn plan_implementation_popup_yes_emits_submit_message_event() { else { panic!("expected SubmitUserMessageWithMode, got {event:?}"); }; - assert_eq!(text, PLAN_IMPLEMENTATION_EXECUTE_MESSAGE); - assert_eq!(collaboration_mode.mode, ModeKind::Execute); + assert_eq!(text, PLAN_IMPLEMENTATION_CODING_MESSAGE); + assert_eq!(collaboration_mode.mode, ModeKind::Code); } #[tokio::test] -async fn submit_user_message_with_mode_sets_execute_collaboration_mode() { +async fn submit_user_message_with_mode_sets_coding_collaboration_mode() { let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(Some("gpt-5")).await; chat.thread_id = Some(ThreadId::new()); chat.set_feature_enabled(Feature::CollaborationModes, true); - let execute_mode = collaboration_modes::execute_mode(chat.models_manager.as_ref()) - .expect("expected execute collaboration mode"); - chat.submit_user_message_with_mode("Implement the plan.".to_string(), execute_mode); + let code_mode = collaboration_modes::code_mode(chat.models_manager.as_ref()) + .expect("expected code collaboration mode"); + chat.submit_user_message_with_mode("Implement the plan.".to_string(), code_mode); match next_submit_op(&mut op_rx) { Op::UserTurn { collaboration_mode: Some(CollaborationMode { - mode: ModeKind::Execute, + mode: ModeKind::Code, .. }), personality: None, .. } => {} other => { - panic!("expected Op::UserTurn with execute collab mode, got {other:?}") + panic!("expected Op::UserTurn with code collab mode, got {other:?}") } } } @@ -2229,10 +2229,10 @@ async fn collab_mode_shift_tab_cycles_only_when_enabled_and_idle() { chat.set_feature_enabled(Feature::CollaborationModes, true); chat.handle_key_event(KeyEvent::from(KeyCode::BackTab)); - assert_eq!(chat.stored_collaboration_mode.mode, ModeKind::Execute); + assert_eq!(chat.stored_collaboration_mode.mode, ModeKind::Plan); chat.handle_key_event(KeyEvent::from(KeyCode::BackTab)); - assert_eq!(chat.stored_collaboration_mode.mode, ModeKind::Plan); + assert_eq!(chat.stored_collaboration_mode.mode, ModeKind::Code); chat.on_task_started(); let before = chat.stored_collaboration_mode.clone(); @@ -2267,14 +2267,14 @@ async fn collab_slash_command_opens_picker_and_updates_mode() { Op::UserTurn { collaboration_mode: Some(CollaborationMode { - mode: ModeKind::PairProgramming, + mode: ModeKind::Code, .. }), personality: None, .. } => {} other => { - panic!("expected Op::UserTurn with pair programming collab mode, got {other:?}") + panic!("expected Op::UserTurn with code collab mode, got {other:?}") } } @@ -2285,20 +2285,20 @@ async fn collab_slash_command_opens_picker_and_updates_mode() { Op::UserTurn { collaboration_mode: Some(CollaborationMode { - mode: ModeKind::PairProgramming, + mode: ModeKind::Code, .. }), personality: None, .. } => {} other => { - panic!("expected Op::UserTurn with pair programming collab mode, got {other:?}") + panic!("expected Op::UserTurn with code collab mode, got {other:?}") } } } #[tokio::test] -async fn collab_mode_defaults_to_pair_programming_when_enabled() { +async fn collab_mode_defaults_to_coding_when_enabled() { let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(None).await; chat.thread_id = Some(ThreadId::new()); chat.set_feature_enabled(Feature::CollaborationModes, true); @@ -2310,26 +2310,23 @@ async fn collab_mode_defaults_to_pair_programming_when_enabled() { Op::UserTurn { collaboration_mode: Some(CollaborationMode { - mode: ModeKind::PairProgramming, + mode: ModeKind::Code, .. }), personality: None, .. } => {} other => { - panic!("expected Op::UserTurn with pair programming collab mode, got {other:?}") + panic!("expected Op::UserTurn with code collab mode, got {other:?}") } } } #[tokio::test] -async fn collab_mode_enabling_sets_pair_programming_default() { +async fn collab_mode_enabling_sets_coding_default() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; chat.set_feature_enabled(Feature::CollaborationModes, true); - assert_eq!( - chat.stored_collaboration_mode.mode, - ModeKind::PairProgramming - ); + assert_eq!(chat.stored_collaboration_mode.mode, ModeKind::Code); } #[tokio::test] diff --git a/codex-rs/tui/src/collaboration_modes.rs b/codex-rs/tui/src/collaboration_modes.rs index df034053d4..c66010b57a 100644 --- a/codex-rs/tui/src/collaboration_modes.rs +++ b/codex-rs/tui/src/collaboration_modes.rs @@ -6,11 +6,27 @@ fn mode_kind(mode: &CollaborationMode) -> ModeKind { mode.mode } +fn is_tui_mode(kind: ModeKind) -> bool { + matches!(kind, ModeKind::Plan | ModeKind::Code) +} + +fn filtered_presets(models_manager: &ModelsManager) -> Vec { + models_manager + .list_collaboration_modes() + .into_iter() + .filter(|preset| is_tui_mode(mode_kind(preset))) + .collect() +} + +pub(crate) fn presets_for_tui(models_manager: &ModelsManager) -> Vec { + filtered_presets(models_manager) +} + pub(crate) fn default_mode(models_manager: &ModelsManager) -> Option { - let presets = models_manager.list_collaboration_modes(); + let presets = filtered_presets(models_manager); presets .iter() - .find(|preset| preset.mode == ModeKind::PairProgramming) + .find(|preset| preset.mode == ModeKind::Code) .cloned() .or_else(|| presets.into_iter().next()) } @@ -19,7 +35,10 @@ pub(crate) fn mode_for_kind( models_manager: &ModelsManager, kind: ModeKind, ) -> Option { - let presets = models_manager.list_collaboration_modes(); + if !is_tui_mode(kind) { + return None; + } + let presets = filtered_presets(models_manager); presets.into_iter().find(|preset| mode_kind(preset) == kind) } @@ -32,7 +51,7 @@ pub(crate) fn next_mode( models_manager: &ModelsManager, current: &CollaborationMode, ) -> Option { - let presets = models_manager.list_collaboration_modes(); + let presets = filtered_presets(models_manager); if presets.is_empty() { return None; } @@ -44,9 +63,8 @@ pub(crate) fn next_mode( presets.get(next_index).cloned() } -pub(crate) fn execute_mode(models_manager: &ModelsManager) -> Option { - models_manager - .list_collaboration_modes() +pub(crate) fn code_mode(models_manager: &ModelsManager) -> Option { + filtered_presets(models_manager) .into_iter() - .find(|preset| mode_kind(preset) == ModeKind::Execute) + .find(|preset| mode_kind(preset) == ModeKind::Code) } From 713ae22c040e37df725719f2bf830ab8a1e30305 Mon Sep 17 00:00:00 2001 From: Eric Traut Date: Fri, 23 Jan 2026 20:11:09 -0800 Subject: [PATCH 19/36] Another round of improvements for config error messages (#9746) In a [recent PR](https://github.com/openai/codex/pull/9182), I made some improvements to config error messages so errors didn't leave app server clients in a dead state. This is a follow-on PR to make these error messages more readable and actionable for both TUI and GUI users. For example, see #9668 where the user was understandably confused about the source of the problem and how to fix it. The improved error message: 1. Clearly identifies the config file where the error was found (which is more important now that we support layered configs) 2. Provides a line and column number of the error 3. Displays the line where the error occurred and underlines it For example, if my `config.toml` includes the following: ```toml [features] collaboration_modes = "true" ``` Here's the current CLI error message: ``` Error loading config.toml: invalid type: string "true", expected a boolean in `features` ``` And here's the improved message: ``` Error loading config.toml: /Users/etraut/.codex/config.toml:43:23: invalid type: string "true", expected a boolean | 43 | collaboration_modes = "true" | ^^^^^^ ``` The bulk of the new logic is contained within a new module `config_loader/diagnostics.rs` that is responsible for calculating the text range for a given toml path (which is more involved than I would have expected). In addition, this PR adds the file name and text range to the `ConfigWarningNotification` app server struct. This allows GUI clients to present the user with a better error message and an optional link to open the errant config file. This was a suggestion from @.bolinfest when he reviewed my previous PR. --- codex-rs/Cargo.lock | 1 + codex-rs/Cargo.toml | 1 + .../app-server-protocol/src/protocol/v2.rs | 26 ++ codex-rs/app-server/src/lib.rs | 85 +++- codex-rs/app-server/src/outgoing_message.rs | 2 + codex-rs/core/Cargo.toml | 1 + codex-rs/core/src/config/mod.rs | 18 +- .../core/src/config_loader/diagnostics.rs | 388 ++++++++++++++++++ codex-rs/core/src/config_loader/layer_io.rs | 9 +- codex-rs/core/src/config_loader/mod.rs | 55 ++- codex-rs/core/src/config_loader/tests.rs | 100 +++++ codex-rs/exec/src/lib.rs | 15 +- codex-rs/execpolicy/src/error.rs | 42 ++ codex-rs/execpolicy/src/lib.rs | 3 + codex-rs/tui/src/lib.rs | 15 +- 15 files changed, 738 insertions(+), 23 deletions(-) create mode 100644 codex-rs/core/src/config_loader/diagnostics.rs diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 2f2da13454..633899b68e 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -1402,6 +1402,7 @@ dependencies = [ "seccompiler", "serde", "serde_json", + "serde_path_to_error", "serde_yaml", "serial_test", "sha1", diff --git a/codex-rs/Cargo.toml b/codex-rs/Cargo.toml index 9a58bcbfac..b39cfd5e15 100644 --- a/codex-rs/Cargo.toml +++ b/codex-rs/Cargo.toml @@ -188,6 +188,7 @@ seccompiler = "0.5.0" sentry = "0.46.0" serde = "1" serde_json = "1" +serde_path_to_error = "0.1.20" serde_with = "3.16" serde_yaml = "0.9" serial_test = "3.2.0" diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index ee65a9ac77..acf08be990 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -2503,6 +2503,24 @@ pub struct DeprecationNoticeNotification { pub details: Option, } +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct TextPosition { + /// 1-based line number. + pub line: usize, + /// 1-based column number (in Unicode scalar values). + pub column: usize, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct TextRange { + pub start: TextPosition, + pub end: TextPosition, +} + #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] #[ts(export_to = "v2/")] @@ -2511,6 +2529,14 @@ pub struct ConfigWarningNotification { pub summary: String, /// Optional extra guidance or error details. pub details: Option, + /// Optional path to the config file that triggered the warning. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub path: Option, + /// Optional range for the error location inside the config file. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub range: Option, } #[cfg(test)] diff --git a/codex-rs/app-server/src/lib.rs b/codex-rs/app-server/src/lib.rs index 022ae9a9c0..fde208106d 100644 --- a/codex-rs/app-server/src/lib.rs +++ b/codex-rs/app-server/src/lib.rs @@ -15,7 +15,12 @@ use crate::outgoing_message::OutgoingMessageSender; use codex_app_server_protocol::ConfigLayerSource; use codex_app_server_protocol::ConfigWarningNotification; use codex_app_server_protocol::JSONRPCMessage; +use codex_app_server_protocol::TextPosition as AppTextPosition; +use codex_app_server_protocol::TextRange as AppTextRange; +use codex_core::ExecPolicyError; use codex_core::check_execpolicy_for_warnings; +use codex_core::config_loader::ConfigLoadError; +use codex_core::config_loader::TextRange as CoreTextRange; use codex_feedback::CodexFeedback; use tokio::io::AsyncBufReadExt; use tokio::io::AsyncWriteExt; @@ -46,6 +51,69 @@ mod outgoing_message; /// plenty for an interactive CLI. const CHANNEL_CAPACITY: usize = 128; +fn config_warning_from_error( + summary: impl Into, + err: &std::io::Error, +) -> ConfigWarningNotification { + let (path, range) = match config_error_location(err) { + Some((path, range)) => (Some(path), Some(range)), + None => (None, None), + }; + ConfigWarningNotification { + summary: summary.into(), + details: Some(err.to_string()), + path, + range, + } +} + +fn config_error_location(err: &std::io::Error) -> Option<(String, AppTextRange)> { + err.get_ref() + .and_then(|err| err.downcast_ref::()) + .map(|err| { + let config_error = err.config_error(); + ( + config_error.path.to_string_lossy().to_string(), + app_text_range(&config_error.range), + ) + }) +} + +fn exec_policy_warning_location(err: &ExecPolicyError) -> (Option, Option) { + match err { + ExecPolicyError::ParsePolicy { path, source } => { + if let Some(location) = source.location() { + let range = AppTextRange { + start: AppTextPosition { + line: location.range.start.line, + column: location.range.start.column, + }, + end: AppTextPosition { + line: location.range.end.line, + column: location.range.end.column, + }, + }; + return (Some(location.path), Some(range)); + } + (Some(path.clone()), None) + } + _ => (None, None), + } +} + +fn app_text_range(range: &CoreTextRange) -> AppTextRange { + AppTextRange { + start: AppTextPosition { + line: range.start.line, + column: range.start.column, + }, + end: AppTextPosition { + line: range.end.line, + column: range.end.column, + }, + } +} + fn project_config_warning(config: &Config) -> Option { let mut disabled_folders = Vec::new(); @@ -53,12 +121,11 @@ fn project_config_warning(config: &Config) -> Option .config_layer_stack .get_layers(ConfigLayerStackOrdering::LowestPrecedenceFirst, true) { - if !matches!(layer.name, ConfigLayerSource::Project { .. }) { + if !matches!(layer.name, ConfigLayerSource::Project { .. }) + || layer.disabled_reason.is_none() + { continue; } - if layer.disabled_reason.is_none() { - continue; - }; if let ConfigLayerSource::Project { dot_codex_folder } = &layer.name { disabled_folders.push(( dot_codex_folder.as_path().display().to_string(), @@ -85,6 +152,8 @@ fn project_config_warning(config: &Config) -> Option Some(ConfigWarningNotification { summary: message, details: None, + path: None, + range: None, }) } @@ -139,10 +208,7 @@ pub async fn run_main( { Ok(config) => config, Err(err) => { - let message = ConfigWarningNotification { - summary: "Invalid configuration; using defaults.".to_string(), - details: Some(err.to_string()), - }; + let message = config_warning_from_error("Invalid configuration; using defaults.", &err); config_warnings.push(message); Config::load_default_with_cli_overrides(cli_kv_overrides.clone()).map_err(|e| { std::io::Error::new( @@ -156,9 +222,12 @@ pub async fn run_main( if let Ok(Some(err)) = check_execpolicy_for_warnings(&config.features, &config.config_layer_stack).await { + let (path, range) = exec_policy_warning_location(&err); let message = ConfigWarningNotification { summary: "Error parsing rules; custom rules not applied.".to_string(), details: Some(err.to_string()), + path, + range, }; config_warnings.push(message); } diff --git a/codex-rs/app-server/src/outgoing_message.rs b/codex-rs/app-server/src/outgoing_message.rs index cf720ef831..7b4a599b00 100644 --- a/codex-rs/app-server/src/outgoing_message.rs +++ b/codex-rs/app-server/src/outgoing_message.rs @@ -286,6 +286,8 @@ mod tests { let notification = ServerNotification::ConfigWarning(ConfigWarningNotification { summary: "Config error: using defaults".to_string(), details: Some("error loading config: bad config".to_string()), + path: None, + range: None, }); let jsonrpc_notification = OutgoingMessage::AppServerNotification(notification); diff --git a/codex-rs/core/Cargo.toml b/codex-rs/core/Cargo.toml index 8ee2848a98..c151356e3d 100644 --- a/codex-rs/core/Cargo.toml +++ b/codex-rs/core/Cargo.toml @@ -64,6 +64,7 @@ reqwest = { workspace = true, features = ["json", "stream"] } schemars = { workspace = true } serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } +serde_path_to_error = { workspace = true } serde_yaml = { workspace = true } sha1 = { workspace = true } sha2 = { workspace = true } diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index ada42663bf..fae370ec0e 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -410,9 +410,21 @@ impl ConfigBuilder { // relative paths to absolute paths based on the parent folder of the // respective config file, so we should be safe to deserialize without // AbsolutePathBufGuard here. - let config_toml: ConfigToml = merged_toml - .try_into() - .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?; + let config_toml: ConfigToml = match merged_toml.try_into() { + Ok(config_toml) => config_toml, + Err(err) => { + if let Some(config_error) = + crate::config_loader::first_layer_config_error(&config_layer_stack).await + { + return Err(crate::config_loader::io_error_from_config_error( + std::io::ErrorKind::InvalidData, + config_error, + Some(err), + )); + } + return Err(std::io::Error::new(std::io::ErrorKind::InvalidData, err)); + } + }; Config::load_config_with_layer_stack( config_toml, harness_overrides, diff --git a/codex-rs/core/src/config_loader/diagnostics.rs b/codex-rs/core/src/config_loader/diagnostics.rs new file mode 100644 index 0000000000..64f9c83888 --- /dev/null +++ b/codex-rs/core/src/config_loader/diagnostics.rs @@ -0,0 +1,388 @@ +//! Helpers for mapping config parse/validation failures to file locations and +//! rendering them in a user-friendly way. + +use crate::config::CONFIG_TOML_FILE; +use crate::config::ConfigToml; +use codex_app_server_protocol::ConfigLayerSource; +use codex_utils_absolute_path::AbsolutePathBufGuard; +use serde_path_to_error::Path as SerdePath; +use serde_path_to_error::Segment as SerdeSegment; +use std::fmt; +use std::fmt::Write; +use std::io; +use std::path::Path; +use std::path::PathBuf; +use toml_edit::Document; +use toml_edit::Item; +use toml_edit::Table; +use toml_edit::Value; + +use super::ConfigLayerEntry; +use super::ConfigLayerStack; +use super::ConfigLayerStackOrdering; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct TextPosition { + pub line: usize, + pub column: usize, +} + +/// Text range in 1-based line/column coordinates. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct TextRange { + pub start: TextPosition, + pub end: TextPosition, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ConfigError { + pub path: PathBuf, + pub range: TextRange, + pub message: String, +} + +impl ConfigError { + pub fn new(path: PathBuf, range: TextRange, message: impl Into) -> Self { + Self { + path, + range, + message: message.into(), + } + } +} + +#[derive(Debug)] +pub struct ConfigLoadError { + error: ConfigError, + source: Option, +} + +impl ConfigLoadError { + pub fn new(error: ConfigError, source: Option) -> Self { + Self { error, source } + } + + pub fn config_error(&self) -> &ConfigError { + &self.error + } +} + +impl fmt::Display for ConfigLoadError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!( + f, + "{}:{}:{}: {}", + self.error.path.display(), + self.error.range.start.line, + self.error.range.start.column, + self.error.message + ) + } +} + +impl std::error::Error for ConfigLoadError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + self.source + .as_ref() + .map(|err| err as &dyn std::error::Error) + } +} + +pub(crate) fn io_error_from_config_error( + kind: io::ErrorKind, + error: ConfigError, + source: Option, +) -> io::Error { + io::Error::new(kind, ConfigLoadError::new(error, source)) +} + +pub(crate) fn config_error_from_toml( + path: impl AsRef, + contents: &str, + err: toml::de::Error, +) -> ConfigError { + let range = err + .span() + .map(|span| text_range_from_span(contents, span)) + .unwrap_or_else(default_range); + ConfigError::new(path.as_ref().to_path_buf(), range, err.message()) +} + +pub(crate) fn config_error_from_config_toml( + path: impl AsRef, + contents: &str, +) -> Option { + let deserializer = match toml::de::Deserializer::parse(contents) { + Ok(deserializer) => deserializer, + Err(err) => return Some(config_error_from_toml(path, contents, err)), + }; + + let result: Result = serde_path_to_error::deserialize(deserializer); + match result { + Ok(_) => None, + Err(err) => { + let path_hint = err.path().clone(); + let toml_err: toml::de::Error = err.into_inner(); + let range = span_for_config_path(contents, &path_hint) + .or_else(|| toml_err.span()) + .map(|span| text_range_from_span(contents, span)) + .unwrap_or_else(default_range); + Some(ConfigError::new( + path.as_ref().to_path_buf(), + range, + toml_err.message(), + )) + } + } +} + +pub(crate) async fn first_layer_config_error(layers: &ConfigLayerStack) -> Option { + // When the merged config fails schema validation, we surface the first concrete + // per-file error to point users at a specific file and range rather than an + // opaque merged-layer failure. + first_layer_config_error_for_entries( + layers.get_layers(ConfigLayerStackOrdering::LowestPrecedenceFirst, false), + ) + .await +} + +pub(crate) async fn first_layer_config_error_from_entries( + layers: &[ConfigLayerEntry], +) -> Option { + first_layer_config_error_for_entries(layers.iter()).await +} + +async fn first_layer_config_error_for_entries<'a, I>(layers: I) -> Option +where + I: IntoIterator, +{ + for layer in layers { + let Some(path) = config_path_for_layer(layer) else { + continue; + }; + let contents = match tokio::fs::read_to_string(&path).await { + Ok(contents) => contents, + Err(err) if err.kind() == io::ErrorKind::NotFound => continue, + Err(err) => { + tracing::debug!("Failed to read config file {}: {err}", path.display()); + continue; + } + }; + + let Some(parent) = path.parent() else { + tracing::debug!("Config file {} has no parent directory", path.display()); + continue; + }; + let _guard = AbsolutePathBufGuard::new(parent); + if let Some(error) = config_error_from_config_toml(&path, &contents) { + return Some(error); + } + } + + None +} + +fn config_path_for_layer(layer: &ConfigLayerEntry) -> Option { + match &layer.name { + ConfigLayerSource::System { file } => Some(file.to_path_buf()), + ConfigLayerSource::User { file } => Some(file.to_path_buf()), + ConfigLayerSource::Project { dot_codex_folder } => { + Some(dot_codex_folder.as_path().join(CONFIG_TOML_FILE)) + } + ConfigLayerSource::LegacyManagedConfigTomlFromFile { file } => Some(file.to_path_buf()), + ConfigLayerSource::Mdm { .. } + | ConfigLayerSource::SessionFlags + | ConfigLayerSource::LegacyManagedConfigTomlFromMdm => None, + } +} + +fn text_range_from_span(contents: &str, span: std::ops::Range) -> TextRange { + let start = position_for_offset(contents, span.start); + let end_index = if span.end > span.start { + span.end - 1 + } else { + span.end + }; + let end = position_for_offset(contents, end_index); + TextRange { start, end } +} + +pub fn format_config_error(error: &ConfigError, contents: &str) -> String { + let mut output = String::new(); + let start = error.range.start; + let _ = writeln!( + output, + "{}:{}:{}: {}", + error.path.display(), + start.line, + start.column, + error.message + ); + + let line_index = start.line.saturating_sub(1); + let line = match contents.lines().nth(line_index) { + Some(line) => line.trim_end_matches('\r'), + None => return output.trim_end().to_string(), + }; + + let line_number = start.line; + let gutter = line_number.to_string().len(); + let _ = writeln!(output, "{:width$} |", "", width = gutter); + let _ = writeln!(output, "{line_number:>gutter$} | {line}"); + + let highlight_len = if error.range.end.line == error.range.start.line + && error.range.end.column >= error.range.start.column + { + error.range.end.column - error.range.start.column + 1 + } else { + 1 + }; + let spaces = " ".repeat(start.column.saturating_sub(1)); + let carets = "^".repeat(highlight_len.max(1)); + let _ = writeln!(output, "{:width$} | {spaces}{carets}", "", width = gutter); + output.trim_end().to_string() +} + +pub fn format_config_error_with_source(error: &ConfigError) -> String { + match std::fs::read_to_string(&error.path) { + Ok(contents) => format_config_error(error, &contents), + Err(_) => format_config_error(error, ""), + } +} + +fn position_for_offset(contents: &str, index: usize) -> TextPosition { + let bytes = contents.as_bytes(); + if bytes.is_empty() { + return TextPosition { line: 1, column: 1 }; + } + + let safe_index = index.min(bytes.len().saturating_sub(1)); + let column_offset = index.saturating_sub(safe_index); + let index = safe_index; + + let line_start = bytes[..index] + .iter() + .rposition(|byte| *byte == b'\n') + .map(|pos| pos + 1) + .unwrap_or(0); + let line = bytes[..line_start] + .iter() + .filter(|byte| **byte == b'\n') + .count(); + + let column = std::str::from_utf8(&bytes[line_start..=index]) + .map(|slice| slice.chars().count().saturating_sub(1)) + .unwrap_or_else(|_| index - line_start); + let column = column + column_offset; + + TextPosition { + line: line + 1, + column: column + 1, + } +} + +fn default_range() -> TextRange { + let position = TextPosition { line: 1, column: 1 }; + TextRange { + start: position, + end: position, + } +} + +enum TomlNode<'a> { + Item(&'a Item), + Table(&'a Table), + Value(&'a Value), +} + +fn span_for_path(contents: &str, path: &SerdePath) -> Option> { + let doc = contents.parse::>().ok()?; + let node = node_for_path(doc.as_item(), path)?; + match node { + TomlNode::Item(item) => item.span(), + TomlNode::Table(table) => table.span(), + TomlNode::Value(value) => value.span(), + } +} + +fn span_for_config_path(contents: &str, path: &SerdePath) -> Option> { + if is_features_table_path(path) + && let Some(span) = span_for_features_value(contents) + { + return Some(span); + } + span_for_path(contents, path) +} + +fn is_features_table_path(path: &SerdePath) -> bool { + let mut segments = path.iter(); + matches!(segments.next(), Some(SerdeSegment::Map { key }) if key == "features") + && segments.next().is_none() +} + +fn span_for_features_value(contents: &str) -> Option> { + let doc = contents.parse::>().ok()?; + let root = doc.as_item().as_table_like()?; + let features_item = root.get("features")?; + let features_table = features_item.as_table_like()?; + for (_, item) in features_table.iter() { + match item { + Item::Value(Value::Boolean(_)) => continue, + Item::Value(value) => return value.span(), + Item::Table(table) => return table.span(), + Item::ArrayOfTables(array) => return array.span(), + Item::None => continue, + } + } + None +} + +fn node_for_path<'a>(item: &'a Item, path: &SerdePath) -> Option> { + let segments: Vec<_> = path.iter().cloned().collect(); + let mut node = TomlNode::Item(item); + let mut index = 0; + while index < segments.len() { + match &segments[index] { + SerdeSegment::Map { key } | SerdeSegment::Enum { variant: key } => { + if let Some(next) = map_child(&node, key) { + node = next; + index += 1; + continue; + } + + if index + 1 < segments.len() { + index += 1; + continue; + } + return None; + } + SerdeSegment::Seq { index: seq_index } => { + node = seq_child(&node, *seq_index)?; + index += 1; + } + SerdeSegment::Unknown => return None, + } + } + Some(node) +} + +fn map_child<'a>(node: &TomlNode<'a>, key: &str) -> Option> { + match node { + TomlNode::Item(item) => { + let table = item.as_table_like()?; + table.get(key).map(TomlNode::Item) + } + TomlNode::Table(table) => table.get(key).map(TomlNode::Item), + TomlNode::Value(Value::InlineTable(table)) => table.get(key).map(TomlNode::Value), + _ => None, + } +} + +fn seq_child<'a>(node: &TomlNode<'a>, index: usize) -> Option> { + match node { + TomlNode::Item(Item::Value(Value::Array(array))) => array.get(index).map(TomlNode::Value), + TomlNode::Item(Item::ArrayOfTables(array)) => array.get(index).map(TomlNode::Table), + TomlNode::Value(Value::Array(array)) => array.get(index).map(TomlNode::Value), + _ => None, + } +} diff --git a/codex-rs/core/src/config_loader/layer_io.rs b/codex-rs/core/src/config_loader/layer_io.rs index 0ece69b471..f6f7d2e37d 100644 --- a/codex-rs/core/src/config_loader/layer_io.rs +++ b/codex-rs/core/src/config_loader/layer_io.rs @@ -1,4 +1,6 @@ use super::LoaderOverrides; +use super::diagnostics::config_error_from_toml; +use super::diagnostics::io_error_from_config_error; #[cfg(target_os = "macos")] use super::macos::load_managed_admin_config_layer; use codex_utils_absolute_path::AbsolutePathBuf; @@ -75,7 +77,12 @@ pub(super) async fn read_config_from_path( Ok(value) => Ok(Some(value)), Err(err) => { tracing::error!("Failed to parse {}: {err}", path.as_ref().display()); - Err(io::Error::new(io::ErrorKind::InvalidData, err)) + let config_error = config_error_from_toml(path.as_ref(), &contents, err.clone()); + Err(io_error_from_config_error( + io::ErrorKind::InvalidData, + config_error, + Some(err), + )) } }, Err(err) if err.kind() == io::ErrorKind::NotFound => { diff --git a/codex-rs/core/src/config_loader/mod.rs b/codex-rs/core/src/config_loader/mod.rs index 8f2365f673..6d85a79360 100644 --- a/codex-rs/core/src/config_loader/mod.rs +++ b/codex-rs/core/src/config_loader/mod.rs @@ -1,4 +1,5 @@ mod config_requirements; +mod diagnostics; mod fingerprint; mod layer_io; #[cfg(target_os = "macos")] @@ -34,6 +35,16 @@ pub use config_requirements::McpServerRequirement; pub use config_requirements::RequirementSource; pub use config_requirements::SandboxModeRequirement; pub use config_requirements::Sourced; +pub use diagnostics::ConfigError; +pub use diagnostics::ConfigLoadError; +pub use diagnostics::TextPosition; +pub use diagnostics::TextRange; +pub(crate) use diagnostics::config_error_from_toml; +pub(crate) use diagnostics::first_layer_config_error; +pub(crate) use diagnostics::first_layer_config_error_from_entries; +pub use diagnostics::format_config_error; +pub use diagnostics::format_config_error_with_source; +pub(crate) use diagnostics::io_error_from_config_error; pub use merge::merge_toml_values; pub(crate) use overrides::build_cli_overrides_layer; pub use state::ConfigLayerEntry; @@ -171,16 +182,44 @@ pub async fn load_config_layers_state( merge_toml_values(&mut merged_so_far, cli_overrides_layer); } - let project_root_markers = project_root_markers_from_config(&merged_so_far)? - .unwrap_or_else(default_project_root_markers); - let project_trust_context = project_trust_context( + let project_root_markers = match project_root_markers_from_config(&merged_so_far) { + Ok(markers) => markers.unwrap_or_else(default_project_root_markers), + Err(err) => { + if let Some(config_error) = first_layer_config_error_from_entries(&layers).await { + return Err(io_error_from_config_error( + io::ErrorKind::InvalidData, + config_error, + None, + )); + } + return Err(err); + } + }; + let project_trust_context = match project_trust_context( &merged_so_far, &cwd, &project_root_markers, codex_home, &user_file, ) - .await?; + .await + { + Ok(context) => context, + Err(err) => { + let source = err + .get_ref() + .and_then(|err| err.downcast_ref::()) + .cloned(); + if let Some(config_error) = first_layer_config_error_from_entries(&layers).await { + return Err(io_error_from_config_error( + io::ErrorKind::InvalidData, + config_error, + source, + )); + } + return Err(err); + } + }; let project_layers = load_project_layers( &cwd, &project_trust_context.project_root, @@ -252,11 +291,9 @@ async fn load_config_toml_for_required_layer( let toml_file = config_toml.as_ref(); let toml_value = match tokio::fs::read_to_string(toml_file).await { Ok(contents) => { - let config: TomlValue = toml::from_str(&contents).map_err(|e| { - io::Error::new( - io::ErrorKind::InvalidData, - format!("Error parsing config file {}: {e}", toml_file.display()), - ) + let config: TomlValue = toml::from_str(&contents).map_err(|err| { + let config_error = config_error_from_toml(toml_file, &contents, err.clone()); + io_error_from_config_error(io::ErrorKind::InvalidData, config_error, Some(err)) })?; let config_parent = toml_file.parent().ok_or_else(|| { io::Error::new( diff --git a/codex-rs/core/src/config_loader/tests.rs b/codex-rs/core/src/config_loader/tests.rs index 9167f9f87d..03b0706c84 100644 --- a/codex-rs/core/src/config_loader/tests.rs +++ b/codex-rs/core/src/config_loader/tests.rs @@ -6,6 +6,7 @@ use crate::config::ConfigOverrides; use crate::config::ConfigToml; use crate::config::ProjectConfig; use crate::config_loader::ConfigLayerEntry; +use crate::config_loader::ConfigLoadError; use crate::config_loader::ConfigRequirements; use crate::config_loader::config_requirements::ConfigRequirementsWithSources; use crate::config_loader::fingerprint::version_for_toml; @@ -21,6 +22,13 @@ use std::path::Path; use tempfile::tempdir; use toml::Value as TomlValue; +fn config_error_from_io(err: &std::io::Error) -> &super::ConfigError { + err.get_ref() + .and_then(|err| err.downcast_ref::()) + .map(ConfigLoadError::config_error) + .expect("expected ConfigLoadError") +} + async fn make_config_for_test( codex_home: &Path, project_path: &Path, @@ -44,6 +52,98 @@ async fn make_config_for_test( .await } +#[tokio::test] +async fn returns_config_error_for_invalid_user_config_toml() { + let tmp = tempdir().expect("tempdir"); + let contents = "model = \"gpt-4\"\ninvalid = ["; + let config_path = tmp.path().join(CONFIG_TOML_FILE); + std::fs::write(&config_path, contents).expect("write config"); + + let cwd = AbsolutePathBuf::try_from(tmp.path()).expect("cwd"); + let err = load_config_layers_state( + tmp.path(), + Some(cwd), + &[] as &[(String, TomlValue)], + LoaderOverrides::default(), + ) + .await + .expect_err("expected error"); + + let config_error = config_error_from_io(&err); + let expected_toml_error = toml::from_str::(contents).expect_err("parse error"); + let expected_config_error = + super::config_error_from_toml(&config_path, contents, expected_toml_error); + assert_eq!(config_error, &expected_config_error); +} + +#[tokio::test] +async fn returns_config_error_for_invalid_managed_config_toml() { + let tmp = tempdir().expect("tempdir"); + let managed_path = tmp.path().join("managed_config.toml"); + let contents = "model = \"gpt-4\"\ninvalid = ["; + std::fs::write(&managed_path, contents).expect("write managed config"); + + let overrides = LoaderOverrides { + managed_config_path: Some(managed_path.clone()), + ..Default::default() + }; + + let cwd = AbsolutePathBuf::try_from(tmp.path()).expect("cwd"); + let err = load_config_layers_state( + tmp.path(), + Some(cwd), + &[] as &[(String, TomlValue)], + overrides, + ) + .await + .expect_err("expected error"); + + let config_error = config_error_from_io(&err); + let expected_toml_error = toml::from_str::(contents).expect_err("parse error"); + let expected_config_error = + super::config_error_from_toml(&managed_path, contents, expected_toml_error); + assert_eq!(config_error, &expected_config_error); +} + +#[tokio::test] +async fn returns_config_error_for_schema_error_in_user_config() { + let tmp = tempdir().expect("tempdir"); + let contents = "model_context_window = \"not_a_number\""; + let config_path = tmp.path().join(CONFIG_TOML_FILE); + std::fs::write(&config_path, contents).expect("write config"); + + let err = ConfigBuilder::default() + .codex_home(tmp.path().to_path_buf()) + .fallback_cwd(Some(tmp.path().to_path_buf())) + .build() + .await + .expect_err("expected error"); + + let config_error = config_error_from_io(&err); + let _guard = codex_utils_absolute_path::AbsolutePathBufGuard::new(tmp.path()); + let expected_config_error = + super::diagnostics::config_error_from_config_toml(&config_path, contents) + .expect("schema error"); + assert_eq!(config_error, &expected_config_error); +} + +#[test] +fn schema_error_points_to_feature_value() { + let tmp = tempdir().expect("tempdir"); + let contents = "[features]\ncollaboration_modes = \"true\""; + let config_path = tmp.path().join(CONFIG_TOML_FILE); + std::fs::write(&config_path, contents).expect("write config"); + + let _guard = codex_utils_absolute_path::AbsolutePathBufGuard::new(tmp.path()); + let error = super::diagnostics::config_error_from_config_toml(&config_path, contents) + .expect("schema error"); + + let value_line = contents.lines().nth(1).expect("value line"); + let value_column = value_line.find("\"true\"").expect("value") + 1; + assert_eq!(error.range.start.line, 2); + assert_eq!(error.range.start.column, value_column); +} + #[tokio::test] async fn merges_managed_config_layer_on_top() { let tmp = tempdir().expect("tempdir"); diff --git a/codex-rs/exec/src/lib.rs b/codex-rs/exec/src/lib.rs index 533b328e22..7679891287 100644 --- a/codex-rs/exec/src/lib.rs +++ b/codex-rs/exec/src/lib.rs @@ -28,6 +28,8 @@ use codex_core::config::ConfigOverrides; use codex_core::config::find_codex_home; use codex_core::config::load_config_as_toml_with_cli_overrides; use codex_core::config::resolve_oss_provider; +use codex_core::config_loader::ConfigLoadError; +use codex_core::config_loader::format_config_error_with_source; use codex_core::git_info::get_git_repo_root; use codex_core::models_manager::manager::RefreshStrategy; use codex_core::protocol::AskForApproval; @@ -162,7 +164,18 @@ pub async fn run_main(cli: Cli, codex_linux_sandbox_exe: Option) -> any { Ok(config_toml) => config_toml, Err(err) => { - eprintln!("Error loading config.toml: {err}"); + let config_error = err + .get_ref() + .and_then(|err| err.downcast_ref::()) + .map(ConfigLoadError::config_error); + if let Some(config_error) = config_error { + eprintln!( + "Error loading config.toml:\n{}", + format_config_error_with_source(config_error) + ); + } else { + eprintln!("Error loading config.toml: {err}"); + } std::process::exit(1); } } diff --git a/codex-rs/execpolicy/src/error.rs b/codex-rs/execpolicy/src/error.rs index 9664e71a5c..25f9227210 100644 --- a/codex-rs/execpolicy/src/error.rs +++ b/codex-rs/execpolicy/src/error.rs @@ -3,6 +3,24 @@ use thiserror::Error; pub type Result = std::result::Result; +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct TextPosition { + pub line: usize, + pub column: usize, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct TextRange { + pub start: TextPosition, + pub end: TextPosition, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ErrorLocation { + pub path: String, + pub range: TextRange, +} + #[derive(Debug, Error)] pub enum Error { #[error("invalid decision: {0}")] @@ -26,3 +44,27 @@ pub enum Error { #[error("starlark error: {0}")] Starlark(StarlarkError), } + +impl Error { + pub fn location(&self) -> Option { + match self { + Error::Starlark(err) => err.span().map(|span| { + let resolved = span.resolve_span(); + ErrorLocation { + path: span.filename().to_string(), + range: TextRange { + start: TextPosition { + line: resolved.begin.line + 1, + column: resolved.begin.column + 1, + }, + end: TextPosition { + line: resolved.end.line + 1, + column: resolved.end.column + 1, + }, + }, + } + }), + _ => None, + } + } +} diff --git a/codex-rs/execpolicy/src/lib.rs b/codex-rs/execpolicy/src/lib.rs index 31062f1cb6..a9453f28d3 100644 --- a/codex-rs/execpolicy/src/lib.rs +++ b/codex-rs/execpolicy/src/lib.rs @@ -10,7 +10,10 @@ pub use amend::AmendError; pub use amend::blocking_append_allow_prefix_rule; pub use decision::Decision; pub use error::Error; +pub use error::ErrorLocation; pub use error::Result; +pub use error::TextPosition; +pub use error::TextRange; pub use execpolicycheck::ExecPolicyCheckCommand; pub use parser::PolicyParser; pub use policy::Evaluation; diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index cac1b12e06..e3ee2ed4e6 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -23,6 +23,8 @@ use codex_core::config::ConfigOverrides; use codex_core::config::find_codex_home; use codex_core::config::load_config_as_toml_with_cli_overrides; use codex_core::config::resolve_oss_provider; +use codex_core::config_loader::ConfigLoadError; +use codex_core::config_loader::format_config_error_with_source; use codex_core::find_thread_path_by_id_str; use codex_core::get_platform_sandbox; use codex_core::protocol::AskForApproval; @@ -177,7 +179,18 @@ pub async fn run_main( { Ok(config_toml) => config_toml, Err(err) => { - eprintln!("Error loading config.toml: {err}"); + let config_error = err + .get_ref() + .and_then(|err| err.downcast_ref::()) + .map(ConfigLoadError::config_error); + if let Some(config_error) = config_error { + eprintln!( + "Error loading config.toml:\n{}", + format_config_error_with_source(config_error) + ); + } else { + eprintln!("Error loading config.toml: {err}"); + } std::process::exit(1); } }; From eb7558ba8542344db7af489024abcc670ee14899 Mon Sep 17 00:00:00 2001 From: Charley Cunningham Date: Sat, 24 Jan 2026 05:24:36 -0800 Subject: [PATCH 20/36] Remove batman reference from experimental prompt (#9812) https://www.reddit.com/r/codex/comments/1qldbmg/if_you_enable_experimental_subagents_in_openai/ --- codex-rs/core/templates/collab/experimental_prompt.md | 3 --- 1 file changed, 3 deletions(-) diff --git a/codex-rs/core/templates/collab/experimental_prompt.md b/codex-rs/core/templates/collab/experimental_prompt.md index 9763592e27..c6cd6f7ac4 100644 --- a/codex-rs/core/templates/collab/experimental_prompt.md +++ b/codex-rs/core/templates/collab/experimental_prompt.md @@ -13,6 +13,3 @@ This feature must be used wisely. For simple or straightforward tasks, you don't * When you're done with a sub-agent, don't forget to close it using `close_agent`. * Be careful on the `timeout_ms` parameter you choose for `wait`. It should be wisely scaled. * Sub-agents have access to the same set of tools as you do so you must tell them if they are allowed to spawn sub-agents themselves or not. - -## Global -You're name is Batman From 515ac2cd19a22151388d16c2d6c73e604cdbd3a0 Mon Sep 17 00:00:00 2001 From: jif-oai Date: Sat, 24 Jan 2026 15:21:34 +0100 Subject: [PATCH 21/36] feat: add thread spawn source for collab tools (#9769) --- .../app-server-protocol/src/protocol/v2.rs | 5 ++- codex-rs/codex-api/src/requests/headers.rs | 11 +++---- codex-rs/core/src/agent/control.rs | 26 ++++++++++----- codex-rs/core/src/client.rs | 12 +++---- codex-rs/core/src/thread_manager.rs | 33 +++++++++++++++++-- codex-rs/core/src/tools/handlers/collab.rs | 10 +++++- codex-rs/protocol/src/protocol.rs | 4 +++ 7 files changed, 75 insertions(+), 26 deletions(-) diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index acf08be990..d1accddc02 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -30,6 +30,7 @@ use codex_protocol::protocol::SkillErrorInfo as CoreSkillErrorInfo; use codex_protocol::protocol::SkillInterface as CoreSkillInterface; use codex_protocol::protocol::SkillMetadata as CoreSkillMetadata; use codex_protocol::protocol::SkillScope as CoreSkillScope; +use codex_protocol::protocol::SubAgentSource as CoreSubAgentSource; use codex_protocol::protocol::TokenUsage as CoreTokenUsage; use codex_protocol::protocol::TokenUsageInfo as CoreTokenUsageInfo; use codex_protocol::user_input::ByteRange as CoreByteRange; @@ -700,6 +701,7 @@ pub enum SessionSource { VsCode, Exec, AppServer, + SubAgent(CoreSubAgentSource), #[serde(other)] Unknown, } @@ -711,7 +713,7 @@ impl From for SessionSource { CoreSessionSource::VSCode => SessionSource::VsCode, CoreSessionSource::Exec => SessionSource::Exec, CoreSessionSource::Mcp => SessionSource::AppServer, - CoreSessionSource::SubAgent(_) => SessionSource::Unknown, + CoreSessionSource::SubAgent(sub) => SessionSource::SubAgent(sub), CoreSessionSource::Unknown => SessionSource::Unknown, } } @@ -724,6 +726,7 @@ impl From for CoreSessionSource { SessionSource::VsCode => CoreSessionSource::VSCode, SessionSource::Exec => CoreSessionSource::Exec, SessionSource::AppServer => CoreSessionSource::Mcp, + SessionSource::SubAgent(sub) => CoreSessionSource::SubAgent(sub), SessionSource::Unknown => CoreSessionSource::Unknown, } } diff --git a/codex-rs/codex-api/src/requests/headers.rs b/codex-rs/codex-api/src/requests/headers.rs index 02f08724ff..02f8c61c31 100644 --- a/codex-rs/codex-api/src/requests/headers.rs +++ b/codex-rs/codex-api/src/requests/headers.rs @@ -15,13 +15,12 @@ pub(crate) fn subagent_header(source: &Option) -> Option return None; }; match sub { + codex_protocol::protocol::SubAgentSource::Review => Some("review".to_string()), + codex_protocol::protocol::SubAgentSource::Compact => Some("compact".to_string()), + codex_protocol::protocol::SubAgentSource::ThreadSpawn { .. } => { + Some("collab_spawn".to_string()) + } codex_protocol::protocol::SubAgentSource::Other(label) => Some(label.clone()), - other => Some( - serde_json::to_value(other) - .ok() - .and_then(|v| v.as_str().map(std::string::ToString::to_string)) - .unwrap_or_else(|| "other".to_string()), - ), } } diff --git a/codex-rs/core/src/agent/control.rs b/codex-rs/core/src/agent/control.rs index 6c5ebf9f02..611c0d164c 100644 --- a/codex-rs/core/src/agent/control.rs +++ b/codex-rs/core/src/agent/control.rs @@ -39,12 +39,20 @@ impl AgentControl { &self, config: crate::config::Config, prompt: String, + session_source: Option, ) -> CodexResult { let state = self.upgrade()?; let reservation = self.state.reserve_spawn_slot(config.agent_max_threads)?; // The same `AgentControl` is sent to spawn the thread. - let new_thread = state.spawn_new_thread(config, self.clone()).await?; + let new_thread = match session_source { + Some(session_source) => { + state + .spawn_new_thread_with_source(config, self.clone(), session_source) + .await? + } + None => state.spawn_new_thread(config, self.clone()).await?, + }; reservation.commit(new_thread.thread_id); // Notify a new thread has been created. This notification will be processed by clients @@ -268,7 +276,7 @@ mod tests { let control = AgentControl::default(); let (_home, config) = test_config().await; let err = control - .spawn_agent(config, "hello".to_string()) + .spawn_agent(config, "hello".to_string(), None) .await .expect_err("spawn_agent should fail without a manager"); assert_eq!( @@ -370,7 +378,7 @@ mod tests { let harness = AgentControlHarness::new().await; let thread_id = harness .control - .spawn_agent(harness.config.clone(), "spawned".to_string()) + .spawn_agent(harness.config.clone(), "spawned".to_string(), None) .await .expect("spawn_agent should succeed"); let _thread = harness @@ -417,12 +425,12 @@ mod tests { .expect("start thread"); let first_agent_id = control - .spawn_agent(config.clone(), "hello".to_string()) + .spawn_agent(config.clone(), "hello".to_string(), None) .await .expect("spawn_agent should succeed"); let err = control - .spawn_agent(config, "hello again".to_string()) + .spawn_agent(config, "hello again".to_string(), None) .await .expect_err("spawn_agent should respect max threads"); let CodexErr::AgentLimitReached { @@ -455,7 +463,7 @@ mod tests { let control = manager.agent_control(); let first_agent_id = control - .spawn_agent(config.clone(), "hello".to_string()) + .spawn_agent(config.clone(), "hello".to_string(), None) .await .expect("spawn_agent should succeed"); let _ = control @@ -464,7 +472,7 @@ mod tests { .expect("shutdown agent"); let second_agent_id = control - .spawn_agent(config.clone(), "hello again".to_string()) + .spawn_agent(config.clone(), "hello again".to_string(), None) .await .expect("spawn_agent should succeed after shutdown"); let _ = control @@ -490,12 +498,12 @@ mod tests { let cloned = control.clone(); let first_agent_id = cloned - .spawn_agent(config.clone(), "hello".to_string()) + .spawn_agent(config.clone(), "hello".to_string(), None) .await .expect("spawn_agent should succeed"); let err = control - .spawn_agent(config, "hello again".to_string()) + .spawn_agent(config, "hello again".to_string(), None) .await .expect_err("spawn_agent should respect shared guard"); let CodexErr::AgentLimitReached { max_threads } = err else { diff --git a/codex-rs/core/src/client.rs b/codex-rs/core/src/client.rs index f0f2b125fd..437380deef 100644 --- a/codex-rs/core/src/client.rs +++ b/codex-rs/core/src/client.rs @@ -226,13 +226,11 @@ impl ModelClient { let mut extra_headers = ApiHeaderMap::new(); if let SessionSource::SubAgent(sub) = &self.state.session_source { - let subagent = if let crate::protocol::SubAgentSource::Other(label) = sub { - label.clone() - } else { - serde_json::to_value(sub) - .ok() - .and_then(|v| v.as_str().map(std::string::ToString::to_string)) - .unwrap_or_else(|| "other".to_string()) + let subagent = match sub { + crate::protocol::SubAgentSource::Review => "review".to_string(), + crate::protocol::SubAgentSource::Compact => "compact".to_string(), + crate::protocol::SubAgentSource::ThreadSpawn { .. } => "collab_spawn".to_string(), + crate::protocol::SubAgentSource::Other(label) => label.clone(), }; if let Ok(val) = HeaderValue::from_str(&subagent) { extra_headers.insert("x-openai-subagent", val); diff --git a/codex-rs/core/src/thread_manager.rs b/codex-rs/core/src/thread_manager.rs index 70d8cb7900..fac08cb3f2 100644 --- a/codex-rs/core/src/thread_manager.rs +++ b/codex-rs/core/src/thread_manager.rs @@ -314,11 +314,22 @@ impl ThreadManagerState { config: Config, agent_control: AgentControl, ) -> CodexResult { - self.spawn_thread( + self.spawn_new_thread_with_source(config, agent_control, self.session_source.clone()) + .await + } + + pub(crate) async fn spawn_new_thread_with_source( + &self, + config: Config, + agent_control: AgentControl, + session_source: SessionSource, + ) -> CodexResult { + self.spawn_thread_with_source( config, InitialHistory::New, Arc::clone(&self.auth_manager), agent_control, + session_source, ) .await } @@ -330,6 +341,24 @@ impl ThreadManagerState { initial_history: InitialHistory, auth_manager: Arc, agent_control: AgentControl, + ) -> CodexResult { + self.spawn_thread_with_source( + config, + initial_history, + auth_manager, + agent_control, + self.session_source.clone(), + ) + .await + } + + pub(crate) async fn spawn_thread_with_source( + &self, + config: Config, + initial_history: InitialHistory, + auth_manager: Arc, + agent_control: AgentControl, + session_source: SessionSource, ) -> CodexResult { let CodexSpawnOk { codex, thread_id, .. @@ -339,7 +368,7 @@ impl ThreadManagerState { Arc::clone(&self.models_manager), Arc::clone(&self.skills_manager), initial_history, - self.session_source.clone(), + session_source, agent_control, ) .await?; diff --git a/codex-rs/core/src/tools/handlers/collab.rs b/codex-rs/core/src/tools/handlers/collab.rs index 56463e644a..6bdebf6a21 100644 --- a/codex-rs/core/src/tools/handlers/collab.rs +++ b/codex-rs/core/src/tools/handlers/collab.rs @@ -78,6 +78,8 @@ impl ToolHandler for CollabHandler { mod spawn { use super::*; use crate::agent::AgentRole; + use codex_protocol::protocol::SessionSource; + use codex_protocol::protocol::SubAgentSource; use std::sync::Arc; #[derive(Debug, Deserialize)] @@ -125,7 +127,13 @@ mod spawn { let result = session .services .agent_control - .spawn_agent(config, prompt.clone()) + .spawn_agent( + config, + prompt.clone(), + Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn { + parent_thread_id: session.conversation_id, + })), + ) .await .map_err(collab_spawn_error); let (new_thread_id, status) = match &result { diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index 1f35fed60e..5fcefc7a27 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -1518,6 +1518,7 @@ pub enum SessionSource { pub enum SubAgentSource { Review, Compact, + ThreadSpawn { parent_thread_id: ThreadId }, Other(String), } @@ -1539,6 +1540,9 @@ impl fmt::Display for SubAgentSource { match self { SubAgentSource::Review => f.write_str("review"), SubAgentSource::Compact => f.write_str("compact"), + SubAgentSource::ThreadSpawn { parent_thread_id } => { + write!(f, "thread_spawn_{parent_thread_id}") + } SubAgentSource::Other(other) => f.write_str(other), } } From 83775f4df117b991e99db962171552fc056395e8 Mon Sep 17 00:00:00 2001 From: jif-oai Date: Sat, 24 Jan 2026 15:57:40 +0100 Subject: [PATCH 22/36] feat: ephemeral threads (#9765) Add ephemeral threads capabilities. Only exposed through the `app-server` v2 The idea is to disable the rollout recorder for those threads. --- codex-rs/Cargo.lock | 1 + .../app-server-protocol/src/protocol/v2.rs | 3 +- codex-rs/app-server/Cargo.toml | 1 + .../app-server/src/bespoke_event_handling.rs | 10 +- .../app-server/src/codex_message_processor.rs | 291 +++++++++++------- .../app-server/tests/suite/v2/thread_fork.rs | 5 +- .../app-server/tests/suite/v2/thread_read.rs | 2 +- .../tests/suite/v2/thread_resume.rs | 4 +- codex-rs/core/src/codex.rs | 39 ++- codex-rs/core/src/codex_delegate.rs | 7 +- codex-rs/core/src/codex_thread.rs | 27 +- codex-rs/core/src/config/mod.rs | 10 + codex-rs/core/src/lib.rs | 1 + .../tests/suite/collaboration_instructions.rs | 6 +- codex-rs/core/tests/suite/compact.rs | 10 +- codex-rs/core/tests/suite/compact_remote.rs | 7 +- .../core/tests/suite/compact_resume_fork.rs | 2 +- codex-rs/core/tests/suite/fork_thread.rs | 6 +- codex-rs/core/tests/suite/image_rollout.rs | 4 +- codex-rs/core/tests/suite/override_updates.rs | 6 +- .../core/tests/suite/permissions_messages.rs | 12 +- codex-rs/core/tests/suite/resume.rs | 18 +- codex-rs/core/tests/suite/review.rs | 4 +- codex-rs/exec/src/lib.rs | 1 + .../tests/event_processor_with_json_output.rs | 2 +- codex-rs/mcp-server/src/outgoing_message.rs | 6 +- codex-rs/protocol/src/protocol.rs | 6 +- codex-rs/tui/src/app.rs | 10 +- codex-rs/tui/src/chatwidget.rs | 2 +- codex-rs/tui/src/chatwidget/tests.rs | 6 +- 30 files changed, 343 insertions(+), 166 deletions(-) diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 633899b68e..d0b3a0ec37 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -1096,6 +1096,7 @@ dependencies = [ "serial_test", "shlex", "tempfile", + "time", "tokio", "toml 0.9.5", "tracing", diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index d1accddc02..9eddf28148 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -1086,6 +1086,7 @@ pub struct ThreadStartParams { pub base_instructions: Option, pub developer_instructions: Option, pub personality: Option, + pub ephemeral: Option, /// If true, opt into emitting raw response items on the event stream. /// /// This is for internal use only (e.g. Codex Cloud). @@ -1469,7 +1470,7 @@ pub struct Thread { #[ts(type = "number")] pub updated_at: i64, /// [UNSTABLE] Path to the thread on disk. - pub path: PathBuf, + pub path: Option, /// Working directory captured for the thread. pub cwd: PathBuf, /// Version of the CLI that created the thread. diff --git a/codex-rs/app-server/Cargo.toml b/codex-rs/app-server/Cargo.toml index f31df68b3f..9fa2044058 100644 --- a/codex-rs/app-server/Cargo.toml +++ b/codex-rs/app-server/Cargo.toml @@ -35,6 +35,7 @@ serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } mcp-types = { workspace = true } tempfile = { workspace = true } +time = { workspace = true } toml = { workspace = true } tokio = { workspace = true, features = [ "io-std", diff --git a/codex-rs/app-server/src/bespoke_event_handling.rs b/codex-rs/app-server/src/bespoke_event_handling.rs index 931c1073aa..68e233b843 100644 --- a/codex-rs/app-server/src/bespoke_event_handling.rs +++ b/codex-rs/app-server/src/bespoke_event_handling.rs @@ -1006,7 +1006,15 @@ pub(crate) async fn apply_bespoke_event_handling( }; if let Some(request_id) = pending { - let rollout_path = conversation.rollout_path(); + let Some(rollout_path) = conversation.rollout_path() else { + let error = JSONRPCErrorError { + code: INVALID_REQUEST_ERROR_CODE, + message: "thread has no persisted rollout".to_string(), + data: None, + }; + outgoing.send_error(request_id, error).await; + return; + }; let response = match read_summary_from_rollout( rollout_path.as_path(), fallback_model_provider.as_str(), diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index daf0fb3d73..d0b8c7f949 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -134,6 +134,7 @@ use codex_core::InitialHistory; use codex_core::NewThread; use codex_core::RolloutRecorder; use codex_core::SessionMeta; +use codex_core::ThreadConfigSnapshot; use codex_core::ThreadManager; use codex_core::ThreadSortKey as CoreThreadSortKey; use codex_core::auth::CLIENT_ID; @@ -172,6 +173,7 @@ use codex_protocol::config_types::ForcedLoginMethod; use codex_protocol::config_types::Personality; use codex_protocol::items::TurnItem; use codex_protocol::models::ResponseItem; +use codex_protocol::protocol::AgentStatus; use codex_protocol::protocol::GitInfo as CoreGitInfo; use codex_protocol::protocol::McpAuthStatus as CoreMcpAuthStatus; use codex_protocol::protocol::McpServerRefreshConfig; @@ -192,7 +194,6 @@ use std::sync::Arc; use std::sync::atomic::AtomicBool; use std::sync::atomic::Ordering; use std::time::Duration; -use tokio::select; use tokio::sync::Mutex; use tokio::sync::broadcast; use tokio::sync::oneshot; @@ -1378,11 +1379,23 @@ impl CodexMessageProcessor { session_configured, .. } = new_thread; + let rollout_path = match session_configured.rollout_path { + Some(path) => path, + None => { + let error = JSONRPCErrorError { + code: INTERNAL_ERROR_CODE, + message: "rollout path missing for v1 conversation".to_string(), + data: None, + }; + self.outgoing.send_error(request_id, error).await; + return; + } + }; let response = NewConversationResponse { conversation_id: thread_id, model: session_configured.model, reasoning_effort: session_configured.reasoning_effort, - rollout_path: session_configured.rollout_path, + rollout_path, }; self.outgoing.send_response(request_id, response).await; } @@ -1398,7 +1411,7 @@ impl CodexMessageProcessor { } async fn thread_start(&mut self, request_id: RequestId, params: ThreadStartParams) { - let typesafe_overrides = self.build_thread_config_overrides( + let mut typesafe_overrides = self.build_thread_config_overrides( params.model, params.model_provider, params.cwd, @@ -1408,6 +1421,7 @@ impl CodexMessageProcessor { params.developer_instructions, params.personality, ); + typesafe_overrides.ephemeral = Some(params.ephemeral.unwrap_or_default()); let config = match derive_config_from_params(&self.cli_overrides, params.config, typesafe_overrides) @@ -1429,50 +1443,45 @@ impl CodexMessageProcessor { Ok(new_conv) => { let NewThread { thread_id, + thread, session_configured, .. } = new_conv; - let rollout_path = session_configured.rollout_path.clone(); + let config_snapshot = thread.config_snapshot().await; let fallback_provider = self.config.model_provider_id.as_str(); // A bit hacky, but the summary contains a lot of useful information for the thread // that unfortunately does not get returned from thread_manager.start_thread(). - let thread = match read_summary_from_rollout( - rollout_path.as_path(), - fallback_provider, - ) - .await - { - Ok(summary) => summary_to_thread(summary), - Err(err) => { - self.send_internal_error( - request_id, - format!( - "failed to load rollout `{}` for thread {thread_id}: {err}", - rollout_path.display() - ), - ) - .await; - return; + let thread = match session_configured.rollout_path.as_ref() { + Some(rollout_path) => { + match read_summary_from_rollout(rollout_path.as_path(), fallback_provider) + .await + { + Ok(summary) => summary_to_thread(summary), + Err(err) => { + self.send_internal_error( + request_id, + format!( + "failed to load rollout `{}` for thread {thread_id}: {err}", + rollout_path.display() + ), + ) + .await; + return; + } + } } + None => build_ephemeral_thread(thread_id, &config_snapshot), }; - let SessionConfiguredEvent { - model, - model_provider_id, - cwd, - approval_policy, - sandbox_policy, - .. - } = session_configured; let response = ThreadStartResponse { thread: thread.clone(), - model, - model_provider: model_provider_id, - cwd, - approval_policy: approval_policy.into(), - sandbox: sandbox_policy.into(), - reasoning_effort: session_configured.reasoning_effort, + model: config_snapshot.model, + model_provider: config_snapshot.model_provider_id, + cwd: config_snapshot.cwd, + approval_policy: config_snapshot.approval_policy.into(), + sandbox: config_snapshot.sandbox_policy.into(), + reasoning_effort: config_snapshot.reasoning_effort, }; // Auto-attach a thread listener when starting a thread. @@ -1725,7 +1734,7 @@ impl CodexMessageProcessor { self.outgoing.send_response(request_id, response).await; } - async fn thread_read(&self, request_id: RequestId, params: ThreadReadParams) { + async fn thread_read(&mut self, request_id: RequestId, params: ThreadReadParams) { let ThreadReadParams { thread_id, include_turns, @@ -1744,15 +1753,8 @@ impl CodexMessageProcessor { match find_thread_path_by_id_str(&self.config.codex_home, &thread_uuid.to_string()) .await { - Ok(Some(path)) => path, - Ok(None) => { - self.send_invalid_request_error( - request_id, - format!("no rollout found for thread id {thread_uuid}"), - ) - .await; - return; - } + Ok(Some(path)) => Some(path), + Ok(None) => None, Err(err) => { self.send_invalid_request_error( request_id, @@ -1763,24 +1765,45 @@ impl CodexMessageProcessor { } }; - let fallback_provider = self.config.model_provider_id.as_str(); - let mut thread = match read_summary_from_rollout(&rollout_path, fallback_provider).await { - Ok(summary) => summary_to_thread(summary), - Err(err) => { - self.send_internal_error( + let mut thread = if let Some(rollout_path) = rollout_path.as_ref() { + let fallback_provider = self.config.model_provider_id.as_str(); + match read_summary_from_rollout(rollout_path, fallback_provider).await { + Ok(summary) => summary_to_thread(summary), + Err(err) => { + self.send_internal_error( + request_id, + format!( + "failed to load rollout `{}` for thread {thread_uuid}: {err}", + rollout_path.display() + ), + ) + .await; + return; + } + } + } else { + let Ok(thread) = self.thread_manager.get_thread(thread_uuid).await else { + self.send_invalid_request_error( request_id, - format!( - "failed to load rollout `{}` for thread {thread_uuid}: {err}", - rollout_path.display() - ), + format!("thread not loaded: {thread_uuid}"), + ) + .await; + return; + }; + let config_snapshot = thread.config_snapshot().await; + if include_turns { + self.send_invalid_request_error( + request_id, + "ephemeral threads do not support includeTurns".to_string(), ) .await; return; } + build_ephemeral_thread(thread_uuid, &config_snapshot) }; - if include_turns { - match read_event_msgs_from_rollout(&rollout_path).await { + if include_turns && let Some(rollout_path) = rollout_path.as_ref() { + match read_event_msgs_from_rollout(rollout_path).await { Ok(events) => { thread.turns = build_turns_from_event_msgs(&events); } @@ -1967,6 +1990,14 @@ impl CodexMessageProcessor { initial_messages, .. } = session_configured; + let Some(rollout_path) = rollout_path else { + self.send_internal_error( + request_id, + format!("rollout path missing for thread {thread_id}"), + ) + .await; + return; + }; // Auto-attach a thread listener when resuming a thread. if let Err(err) = self .attach_conversation_listener(thread_id, false, ApiVersion::V2) @@ -2170,6 +2201,14 @@ impl CodexMessageProcessor { initial_messages, .. } = session_configured; + let Some(rollout_path) = rollout_path else { + self.send_internal_error( + request_id, + format!("rollout path missing for thread {thread_id}"), + ) + .await; + return; + }; // Auto-attach a conversation listener when forking a thread. if let Err(err) = self .attach_conversation_listener(thread_id, false, ApiVersion::V2) @@ -2919,6 +2958,18 @@ impl CodexMessageProcessor { session_configured, .. }) => { + let rollout_path = match session_configured.rollout_path.clone() { + Some(path) => path, + None => { + let error = JSONRPCErrorError { + code: INTERNAL_ERROR_CODE, + message: "rollout path missing for resumed conversation".to_string(), + data: None, + }; + self.outgoing.send_error(request_id, error).await; + return; + } + }; self.outgoing .send_server_notification(ServerNotification::SessionConfigured( SessionConfiguredNotification { @@ -2928,7 +2979,7 @@ impl CodexMessageProcessor { history_log_id: session_configured.history_log_id, history_entry_count: session_configured.history_entry_count, initial_messages: session_configured.initial_messages.clone(), - rollout_path: session_configured.rollout_path.clone(), + rollout_path: rollout_path.clone(), }, )) .await; @@ -2941,7 +2992,7 @@ impl CodexMessageProcessor { conversation_id: thread_id, model: session_configured.model.clone(), initial_messages, - rollout_path: session_configured.rollout_path.clone(), + rollout_path, }; self.outgoing.send_response(request_id, response).await; } @@ -3117,6 +3168,19 @@ impl CodexMessageProcessor { } }; + let rollout_path = match session_configured.rollout_path.clone() { + Some(path) => path, + None => { + let error = JSONRPCErrorError { + code: INTERNAL_ERROR_CODE, + message: "rollout path missing for forked conversation".to_string(), + data: None, + }; + self.outgoing.send_error(request_id, error).await; + return; + } + }; + self.outgoing .send_server_notification(ServerNotification::SessionConfigured( SessionConfiguredNotification { @@ -3126,7 +3190,7 @@ impl CodexMessageProcessor { history_log_id: session_configured.history_log_id, history_entry_count: session_configured.history_entry_count, initial_messages: session_configured.initial_messages.clone(), - rollout_path: session_configured.rollout_path.clone(), + rollout_path: rollout_path.clone(), }, )) .await; @@ -3139,7 +3203,7 @@ impl CodexMessageProcessor { conversation_id: thread_id, model: session_configured.model.clone(), initial_messages, - rollout_path: session_configured.rollout_path.clone(), + rollout_path, }; self.outgoing.send_response(request_id, response).await; } @@ -3250,51 +3314,27 @@ impl CodexMessageProcessor { // If the thread is active, request shutdown and wait briefly. if let Some(conversation) = self.thread_manager.remove_thread(&thread_id).await { info!("thread {thread_id} was active; shutting down"); - let conversation_clone = conversation.clone(); - let notify = Arc::new(tokio::sync::Notify::new()); - let notify_clone = notify.clone(); - - // Establish the listener for ShutdownComplete before submitting - // Shutdown so it is not missed. - let is_shutdown = tokio::spawn(async move { - // Create the notified future outside the loop to avoid losing notifications. - let notified = notify_clone.notified(); - tokio::pin!(notified); - loop { - select! { - _ = &mut notified => { break; } - event = conversation_clone.next_event() => { - match event { - Ok(event) => { - if matches!(event.msg, EventMsg::ShutdownComplete) { break; } - } - // Break on errors to avoid tight loops when the agent loop has exited. - Err(_) => { break; } - } - } - } - } - }); // Request shutdown. match conversation.submit(Op::Shutdown).await { Ok(_) => { - // Successfully submitted Shutdown; wait before proceeding. - select! { - _ = is_shutdown => { - // Normal shutdown: proceed with archive. - } - _ = tokio::time::sleep(Duration::from_secs(10)) => { - warn!("thread {thread_id} shutdown timed out; proceeding with archive"); - // Wake any waiter; use notify_waiters to avoid missing the signal. - notify.notify_waiters(); - // Perhaps we lost a shutdown race, so let's continue to - // clean up the .jsonl file. + // Poll agent status rather than consuming events so attached listeners do not block shutdown. + let wait_for_shutdown = async { + loop { + if matches!(conversation.agent_status().await, AgentStatus::Shutdown) { + break; + } + tokio::time::sleep(Duration::from_millis(50)).await; } + }; + if tokio::time::timeout(Duration::from_secs(10), wait_for_shutdown) + .await + .is_err() + { + warn!("thread {thread_id} shutdown timed out; proceeding with archive"); } } Err(err) => { error!("failed to submit Shutdown to thread {thread_id}: {err}"); - notify.notify_waiters(); } } } @@ -3809,23 +3849,29 @@ impl CodexMessageProcessor { ); } - let rollout_path = review_thread.rollout_path(); let fallback_provider = self.config.model_provider_id.as_str(); - match read_summary_from_rollout(rollout_path.as_path(), fallback_provider).await { - Ok(summary) => { - let thread = summary_to_thread(summary); - let notif = ThreadStartedNotification { thread }; - self.outgoing - .send_server_notification(ServerNotification::ThreadStarted(notif)) - .await; - } - Err(err) => { - tracing::warn!( - "failed to load summary for review thread {}: {}", - session_configured.session_id, - err - ); + if let Some(rollout_path) = review_thread.rollout_path() { + match read_summary_from_rollout(rollout_path.as_path(), fallback_provider).await { + Ok(summary) => { + let thread = summary_to_thread(summary); + let notif = ThreadStartedNotification { thread }; + self.outgoing + .send_server_notification(ServerNotification::ThreadStarted(notif)) + .await; + } + Err(err) => { + tracing::warn!( + "failed to load summary for review thread {}: {}", + session_configured.session_id, + err + ); + } } + } else { + tracing::warn!( + "review thread {} has no rollout path", + session_configured.session_id + ); } let turn_id = review_thread @@ -4228,7 +4274,7 @@ impl CodexMessageProcessor { async fn resolve_rollout_path(&self, conversation_id: ThreadId) -> Option { match self.thread_manager.get_thread(conversation_id).await { - Ok(conv) => Some(conv.rollout_path()), + Ok(conv) => conv.rollout_path(), Err(_) => None, } } @@ -4493,6 +4539,23 @@ async fn read_updated_at(path: &Path, created_at: Option<&str>) -> Option Thread { + let now = time::OffsetDateTime::now_utc().unix_timestamp(); + Thread { + id: thread_id.to_string(), + preview: String::new(), + model_provider: config_snapshot.model_provider_id.clone(), + created_at: now, + updated_at: now, + path: None, + cwd: config_snapshot.cwd.clone(), + cli_version: env!("CARGO_PKG_VERSION").to_string(), + source: config_snapshot.session_source.clone().into(), + git_info: None, + turns: Vec::new(), + } +} + pub(crate) fn summary_to_thread(summary: ConversationSummary) -> Thread { let ConversationSummary { conversation_id, @@ -4521,7 +4584,7 @@ pub(crate) fn summary_to_thread(summary: ConversationSummary) -> Thread { model_provider, created_at: created_at.map(|dt| dt.timestamp()).unwrap_or(0), updated_at: updated_at.map(|dt| dt.timestamp()).unwrap_or(0), - path, + path: Some(path), cwd, cli_version, source: source.into(), diff --git a/codex-rs/app-server/tests/suite/v2/thread_fork.rs b/codex-rs/app-server/tests/suite/v2/thread_fork.rs index 32abc237e3..c06f387fd2 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_fork.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_fork.rs @@ -77,8 +77,9 @@ async fn thread_fork_creates_new_thread_and_emits_started() -> Result<()> { assert_ne!(thread.id, conversation_id); assert_eq!(thread.preview, preview); assert_eq!(thread.model_provider, "mock_provider"); - assert!(thread.path.is_absolute()); - assert_ne!(thread.path, original_path); + let thread_path = thread.path.clone().expect("thread path"); + assert!(thread_path.is_absolute()); + assert_ne!(thread_path, original_path); assert!(thread.cwd.is_absolute()); assert_eq!(thread.source, SessionSource::VsCode); diff --git a/codex-rs/app-server/tests/suite/v2/thread_read.rs b/codex-rs/app-server/tests/suite/v2/thread_read.rs index 8704a25468..d8ca3aa696 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_read.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_read.rs @@ -64,7 +64,7 @@ async fn thread_read_returns_summary_without_turns() -> Result<()> { assert_eq!(thread.id, conversation_id); assert_eq!(thread.preview, preview); assert_eq!(thread.model_provider, "mock_provider"); - assert!(thread.path.is_absolute()); + assert!(thread.path.as_ref().expect("thread path").is_absolute()); assert_eq!(thread.cwd, PathBuf::from("/")); assert_eq!(thread.cli_version, "0.0.0"); assert_eq!(thread.source, SessionSource::Cli); diff --git a/codex-rs/app-server/tests/suite/v2/thread_resume.rs b/codex-rs/app-server/tests/suite/v2/thread_resume.rs index 183532ea7d..6129c0de2f 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_resume.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_resume.rs @@ -117,7 +117,7 @@ async fn thread_resume_returns_rollout_history() -> Result<()> { assert_eq!(thread.id, conversation_id); assert_eq!(thread.preview, preview); assert_eq!(thread.model_provider, "mock_provider"); - assert!(thread.path.is_absolute()); + assert!(thread.path.as_ref().expect("thread path").is_absolute()); assert_eq!(thread.cwd, PathBuf::from("/")); assert_eq!(thread.cli_version, "0.0.0"); assert_eq!(thread.source, SessionSource::Cli); @@ -169,7 +169,7 @@ async fn thread_resume_prefers_path_over_thread_id() -> Result<()> { .await??; let ThreadStartResponse { thread, .. } = to_response::(start_resp)?; - let thread_path = thread.path.clone(); + let thread_path = thread.path.clone().expect("thread path"); let resume_id = mcp .send_thread_resume_request(ThreadResumeParams { thread_id: "not-a-valid-thread-id".to_string(), diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 25c2b225a3..9fc2bae165 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -89,6 +89,7 @@ use crate::client::ModelClient; use crate::client::ModelClientSession; use crate::client_common::Prompt; use crate::client_common::ResponseEvent; +use crate::codex_thread::ThreadConfigSnapshot; use crate::compact::collect_user_messages; use crate::config::Config; use crate::config::Constrained; @@ -192,6 +193,7 @@ pub struct Codex { pub(crate) rx_event: Receiver, // Last known status of the agent. pub(crate) agent_status: watch::Receiver, + pub(crate) session: Arc, } /// Wrapper returned by [`Codex::spawn`] containing the spawned [`Codex`], @@ -345,12 +347,13 @@ impl Codex { let thread_id = session.conversation_id; // This task will run until Op::Shutdown is received. - tokio::spawn(submission_loop(session, config, rx_sub)); + tokio::spawn(submission_loop(Arc::clone(&session), config, rx_sub)); let codex = Codex { next_id: AtomicU64::new(0), tx_sub, rx_event, agent_status: agent_status_rx, + session, }; #[allow(deprecated)] @@ -394,6 +397,11 @@ impl Codex { pub(crate) async fn agent_status(&self) -> AgentStatus { self.agent_status.borrow().clone() } + + pub(crate) async fn thread_config_snapshot(&self) -> ThreadConfigSnapshot { + let state = self.session.state.lock().await; + state.session_configuration.thread_config_snapshot() + } } /// Context for an initialized model agent @@ -495,6 +503,19 @@ pub(crate) struct SessionConfiguration { } impl SessionConfiguration { + fn thread_config_snapshot(&self) -> ThreadConfigSnapshot { + ThreadConfigSnapshot { + model: self.collaboration_mode.model().to_string(), + model_provider_id: self.original_config_do_not_use.model_provider_id.clone(), + approval_policy: self.approval_policy.value(), + sandbox_policy: self.sandbox_policy.get().clone(), + cwd: self.cwd.clone(), + reasoning_effort: self.collaboration_mode.reasoning_effort(), + personality: self.personality, + session_source: self.session_source.clone(), + } + } + pub(crate) fn apply(&self, updates: &SessionSettingsUpdate) -> ConstraintResult { let mut next_configuration = self.clone(); if let Some(collaboration_mode) = updates.collaboration_mode.clone() { @@ -652,7 +673,15 @@ impl Session { // - initialize RolloutRecorder with new or resumed session info // - perform default shell discovery // - load history metadata - let rollout_fut = RolloutRecorder::new(&config, rollout_params); + let rollout_fut = async { + if config.ephemeral { + Ok(None) + } else { + RolloutRecorder::new(&config, rollout_params) + .await + .map(Some) + } + }; let history_meta_fut = crate::message_history::history_metadata(&config); let auth_manager_clone = Arc::clone(&auth_manager); @@ -679,7 +708,9 @@ impl Session { error!("failed to initialize rollout recorder: {e:#}"); anyhow::Error::from(e) })?; - let rollout_path = rollout_recorder.rollout_path.clone(); + let rollout_path = rollout_recorder + .as_ref() + .map(|rec| rec.rollout_path.clone()); let mut post_session_configured_events = Vec::::new(); @@ -768,7 +799,7 @@ impl Session { mcp_startup_cancellation_token: Mutex::new(CancellationToken::new()), unified_exec_manager: UnifiedExecProcessManager::default(), notifier: UserNotifier::new(config.notify.clone()), - rollout: Mutex::new(Some(rollout_recorder)), + rollout: Mutex::new(rollout_recorder), user_shell: Arc::new(default_shell), show_raw_agent_reasoning: config.show_raw_agent_reasoning, exec_policy, diff --git a/codex-rs/core/src/codex_delegate.rs b/codex-rs/core/src/codex_delegate.rs index b855612113..9a94f988d5 100644 --- a/codex-rs/core/src/codex_delegate.rs +++ b/codex-rs/core/src/codex_delegate.rs @@ -92,6 +92,7 @@ pub(crate) async fn run_codex_thread_interactive( tx_sub: tx_ops, rx_event: rx_sub, agent_status: codex.agent_status.clone(), + session: Arc::clone(&codex.session), }) } @@ -134,6 +135,7 @@ pub(crate) async fn run_codex_thread_one_shot( let (tx_bridge, rx_bridge) = async_channel::bounded(SUBMISSION_CHANNEL_CAPACITY); let ops_tx = io.tx_sub.clone(); let agent_status = io.agent_status.clone(); + let session = Arc::clone(&io.session); let io_for_bridge = io; tokio::spawn(async move { while let Ok(event) = io_for_bridge.next_event().await { @@ -166,6 +168,7 @@ pub(crate) async fn run_codex_thread_one_shot( rx_event: rx_bridge, tx_sub: tx_closed, agent_status, + session, }) } @@ -442,15 +445,15 @@ mod tests { let (tx_events, rx_events) = bounded(1); let (tx_sub, rx_sub) = bounded(SUBMISSION_CHANNEL_CAPACITY); let (_agent_status_tx, agent_status) = watch::channel(AgentStatus::PendingInit); + let (session, ctx, _rx_evt) = crate::codex::make_session_and_context_with_rx().await; let codex = Arc::new(Codex { next_id: AtomicU64::new(0), tx_sub, rx_event: rx_events, agent_status, + session: Arc::clone(&session), }); - let (session, ctx, _rx_evt) = crate::codex::make_session_and_context_with_rx().await; - let (tx_out, rx_out) = bounded(1); tx_out .send(Event { diff --git a/codex-rs/core/src/codex_thread.rs b/codex-rs/core/src/codex_thread.rs index 6d132a9256..152679d146 100644 --- a/codex-rs/core/src/codex_thread.rs +++ b/codex-rs/core/src/codex_thread.rs @@ -4,18 +4,35 @@ use crate::error::Result as CodexResult; use crate::protocol::Event; use crate::protocol::Op; use crate::protocol::Submission; +use codex_protocol::config_types::Personality; +use codex_protocol::openai_models::ReasoningEffort; +use codex_protocol::protocol::AskForApproval; +use codex_protocol::protocol::SandboxPolicy; +use codex_protocol::protocol::SessionSource; use std::path::PathBuf; use tokio::sync::watch; +#[derive(Clone, Debug)] +pub struct ThreadConfigSnapshot { + pub model: String, + pub model_provider_id: String, + pub approval_policy: AskForApproval, + pub sandbox_policy: SandboxPolicy, + pub cwd: PathBuf, + pub reasoning_effort: Option, + pub personality: Option, + pub session_source: SessionSource, +} + pub struct CodexThread { codex: Codex, - rollout_path: PathBuf, + rollout_path: Option, } /// Conduit for the bidirectional stream of messages that compose a thread /// (formerly called a conversation) in Codex. impl CodexThread { - pub(crate) fn new(codex: Codex, rollout_path: PathBuf) -> Self { + pub(crate) fn new(codex: Codex, rollout_path: Option) -> Self { Self { codex, rollout_path, @@ -43,7 +60,11 @@ impl CodexThread { self.codex.agent_status.clone() } - pub fn rollout_path(&self) -> PathBuf { + pub fn rollout_path(&self) -> Option { self.rollout_path.clone() } + + pub async fn config_snapshot(&self) -> ThreadConfigSnapshot { + self.codex.thread_config_snapshot().await + } } diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index fae370ec0e..b3fb84c5fd 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -261,6 +261,9 @@ pub struct Config { /// Settings that govern if and what will be written to `~/.codex/history.jsonl`. pub history: History, + /// When true, session is not persisted on disk. Default to `false` + pub ephemeral: bool, + /// Optional URI-based file opener. If set, citations to files in the model /// output will be hyperlinked using the specified URI scheme. pub file_opener: UriBasedFileOpener, @@ -1158,6 +1161,7 @@ pub struct ConfigOverrides { pub include_apply_patch_tool: Option, pub show_raw_agent_reasoning: Option, pub tools_web_search_request: Option, + pub ephemeral: Option, /// Additional directories that should be treated as writable roots for this session. pub additional_writable_roots: Vec, } @@ -1246,6 +1250,7 @@ impl Config { include_apply_patch_tool: include_apply_patch_tool_override, show_raw_agent_reasoning, tools_web_search_request: override_tools_web_search_request, + ephemeral, additional_writable_roots, } = overrides; @@ -1530,6 +1535,7 @@ impl Config { codex_home, config_layer_stack, history, + ephemeral: ephemeral.unwrap_or_default(), file_opener: cfg.file_opener.unwrap_or(UriBasedFileOpener::VsCode), codex_linux_sandbox_exe, @@ -3691,6 +3697,7 @@ model_verbosity = "high" codex_home: fixture.codex_home(), config_layer_stack: Default::default(), history: History::default(), + ephemeral: false, file_opener: UriBasedFileOpener::VsCode, codex_linux_sandbox_exe: None, hide_agent_reasoning: false, @@ -3772,6 +3779,7 @@ model_verbosity = "high" codex_home: fixture.codex_home(), config_layer_stack: Default::default(), history: History::default(), + ephemeral: false, file_opener: UriBasedFileOpener::VsCode, codex_linux_sandbox_exe: None, hide_agent_reasoning: false, @@ -3868,6 +3876,7 @@ model_verbosity = "high" codex_home: fixture.codex_home(), config_layer_stack: Default::default(), history: History::default(), + ephemeral: false, file_opener: UriBasedFileOpener::VsCode, codex_linux_sandbox_exe: None, hide_agent_reasoning: false, @@ -3950,6 +3959,7 @@ model_verbosity = "high" codex_home: fixture.codex_home(), config_layer_stack: Default::default(), history: History::default(), + ephemeral: false, file_opener: UriBasedFileOpener::VsCode, codex_linux_sandbox_exe: None, hide_agent_reasoning: false, diff --git a/codex-rs/core/src/lib.rs b/codex-rs/core/src/lib.rs index 41d33a4ce4..2042e330ef 100644 --- a/codex-rs/core/src/lib.rs +++ b/codex-rs/core/src/lib.rs @@ -15,6 +15,7 @@ pub mod codex; mod codex_thread; mod compact_remote; pub use codex_thread::CodexThread; +pub use codex_thread::ThreadConfigSnapshot; mod agent; mod codex_delegate; mod command_safety; diff --git a/codex-rs/core/tests/suite/collaboration_instructions.rs b/codex-rs/core/tests/suite/collaboration_instructions.rs index 3c6619fcea..f7183b817e 100644 --- a/codex-rs/core/tests/suite/collaboration_instructions.rs +++ b/codex-rs/core/tests/suite/collaboration_instructions.rs @@ -422,7 +422,11 @@ async fn resume_replays_collaboration_instructions() -> Result<()> { let mut builder = test_codex(); let initial = builder.build(&server).await?; - let rollout_path = initial.session_configured.rollout_path.clone(); + let rollout_path = initial + .session_configured + .rollout_path + .clone() + .expect("rollout path"); let home = initial.home.clone(); let collab_text = "resume instructions"; diff --git a/codex-rs/core/tests/suite/compact.rs b/codex-rs/core/tests/suite/compact.rs index 230ab57840..1ecac3afe0 100644 --- a/codex-rs/core/tests/suite/compact.rs +++ b/codex-rs/core/tests/suite/compact.rs @@ -154,7 +154,7 @@ async fn summarize_context_three_requests_and_instructions() { session_configured, .. } = thread_manager.start_thread(config).await.unwrap(); - let rollout_path = session_configured.rollout_path; + let rollout_path = session_configured.rollout_path.expect("rollout path"); // 1) Normal user input – should hit server once. codex @@ -1237,7 +1237,11 @@ async fn auto_compact_runs_after_resume_when_token_usage_is_over_limit() { }); let initial = builder.build(&server).await.unwrap(); let home = initial.home.clone(); - let rollout_path = initial.session_configured.rollout_path.clone(); + let rollout_path = initial + .session_configured + .rollout_path + .clone() + .expect("rollout path"); // A single over-limit completion should not auto-compact until the next user message. mount_sse_once( @@ -1429,7 +1433,7 @@ async fn auto_compact_persists_rollout_entries() { codex.submit(Op::Shutdown).await.unwrap(); wait_for_event(&codex, |ev| matches!(ev, EventMsg::ShutdownComplete)).await; - let rollout_path = session_configured.rollout_path; + let rollout_path = session_configured.rollout_path.expect("rollout path"); let text = std::fs::read_to_string(&rollout_path).unwrap_or_else(|e| { panic!( "failed to read rollout file {}: {e}", diff --git a/codex-rs/core/tests/suite/compact_remote.rs b/codex-rs/core/tests/suite/compact_remote.rs index a2cf73e110..2fc5ba53c2 100644 --- a/codex-rs/core/tests/suite/compact_remote.rs +++ b/codex-rs/core/tests/suite/compact_remote.rs @@ -230,7 +230,12 @@ async fn remote_compact_persists_replacement_history_in_rollout() -> Result<()> ) .await?; let codex = harness.test().codex.clone(); - let rollout_path = harness.test().session_configured.rollout_path.clone(); + let rollout_path = harness + .test() + .session_configured + .rollout_path + .clone() + .expect("rollout path"); let responses_mock = responses::mount_sse_once( harness.server(), diff --git a/codex-rs/core/tests/suite/compact_resume_fork.rs b/codex-rs/core/tests/suite/compact_resume_fork.rs index b7ce49abbe..f6047ac733 100644 --- a/codex-rs/core/tests/suite/compact_resume_fork.rs +++ b/codex-rs/core/tests/suite/compact_resume_fork.rs @@ -1013,7 +1013,7 @@ async fn compact_conversation(conversation: &Arc) { } async fn fetch_conversation_path(conversation: &Arc) -> std::path::PathBuf { - conversation.rollout_path() + conversation.rollout_path().expect("rollout path") } async fn resume_conversation( diff --git a/codex-rs/core/tests/suite/fork_thread.rs b/codex-rs/core/tests/suite/fork_thread.rs index 2a9f604c3d..8698991846 100644 --- a/codex-rs/core/tests/suite/fork_thread.rs +++ b/codex-rs/core/tests/suite/fork_thread.rs @@ -80,7 +80,7 @@ async fn fork_thread_twice_drops_to_first_message() { } // Request history from the base conversation to obtain rollout path. - let base_path = codex.rollout_path(); + let base_path = codex.rollout_path().expect("rollout path"); // GetHistory flushes before returning the path; no wait needed. @@ -135,7 +135,7 @@ async fn fork_thread_twice_drops_to_first_message() { .await .expect("fork 1"); - let fork1_path = codex_fork1.rollout_path(); + let fork1_path = codex_fork1.rollout_path().expect("rollout path"); // GetHistory on fork1 flushed; the file is ready. let fork1_items = read_items(&fork1_path); @@ -154,7 +154,7 @@ async fn fork_thread_twice_drops_to_first_message() { .await .expect("fork 2"); - let fork2_path = codex_fork2.rollout_path(); + let fork2_path = codex_fork2.rollout_path().expect("rollout path"); // GetHistory on fork2 flushed; the file is ready. let fork1_items = read_items(&fork1_path); let fork1_user_inputs = find_user_input_positions(&fork1_items); diff --git a/codex-rs/core/tests/suite/image_rollout.rs b/codex-rs/core/tests/suite/image_rollout.rs index b6dad6a227..6aebe6630d 100644 --- a/codex-rs/core/tests/suite/image_rollout.rs +++ b/codex-rs/core/tests/suite/image_rollout.rs @@ -136,7 +136,7 @@ async fn copy_paste_local_image_persists_rollout_request_shape() -> anyhow::Resu codex.submit(Op::Shutdown).await?; wait_for_event(&codex, |event| matches!(event, EventMsg::ShutdownComplete)).await; - let rollout_path = codex.rollout_path(); + let rollout_path = codex.rollout_path().expect("rollout path"); let rollout_text = read_rollout_text(&rollout_path).await?; let actual = find_user_message_with_image(&rollout_text) .expect("expected user message with input image in rollout"); @@ -217,7 +217,7 @@ async fn drag_drop_image_persists_rollout_request_shape() -> anyhow::Result<()> codex.submit(Op::Shutdown).await?; wait_for_event(&codex, |event| matches!(event, EventMsg::ShutdownComplete)).await; - let rollout_path = codex.rollout_path(); + let rollout_path = codex.rollout_path().expect("rollout path"); let rollout_text = read_rollout_text(&rollout_path).await?; let actual = find_user_message_with_image(&rollout_text) .expect("expected user message with input image in rollout"); diff --git a/codex-rs/core/tests/suite/override_updates.rs b/codex-rs/core/tests/suite/override_updates.rs index 3ceb847daa..d995446864 100644 --- a/codex-rs/core/tests/suite/override_updates.rs +++ b/codex-rs/core/tests/suite/override_updates.rs @@ -129,7 +129,7 @@ async fn override_turn_context_records_permissions_update() -> Result<()> { test.codex.submit(Op::Shutdown).await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::ShutdownComplete)).await; - let rollout_path = test.codex.rollout_path(); + let rollout_path = test.codex.rollout_path().expect("rollout path"); let rollout_text = read_rollout_text(&rollout_path).await?; let developer_texts = rollout_developer_texts(&rollout_text); let approval_texts: Vec<&String> = developer_texts @@ -172,7 +172,7 @@ async fn override_turn_context_records_environment_update() -> Result<()> { test.codex.submit(Op::Shutdown).await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::ShutdownComplete)).await; - let rollout_path = test.codex.rollout_path(); + let rollout_path = test.codex.rollout_path().expect("rollout path"); let rollout_text = read_rollout_text(&rollout_path).await?; let env_texts = rollout_environment_texts(&rollout_text); let new_cwd_text = new_cwd.path().display().to_string(); @@ -209,7 +209,7 @@ async fn override_turn_context_records_collaboration_update() -> Result<()> { test.codex.submit(Op::Shutdown).await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::ShutdownComplete)).await; - let rollout_path = test.codex.rollout_path(); + let rollout_path = test.codex.rollout_path().expect("rollout path"); let rollout_text = read_rollout_text(&rollout_path).await?; let developer_texts = rollout_developer_texts(&rollout_text); let collab_text = collab_xml(collab_text); diff --git a/codex-rs/core/tests/suite/permissions_messages.rs b/codex-rs/core/tests/suite/permissions_messages.rs index 9a32a7d9f9..f3d9e8d47b 100644 --- a/codex-rs/core/tests/suite/permissions_messages.rs +++ b/codex-rs/core/tests/suite/permissions_messages.rs @@ -202,7 +202,11 @@ async fn resume_replays_permissions_messages() -> Result<()> { config.approval_policy = Constrained::allow_any(AskForApproval::OnRequest); }); let initial = builder.build(&server).await?; - let rollout_path = initial.session_configured.rollout_path.clone(); + let rollout_path = initial + .session_configured + .rollout_path + .clone() + .expect("rollout path"); let home = initial.home.clone(); initial @@ -280,7 +284,11 @@ async fn resume_and_fork_append_permissions_messages() -> Result<()> { config.approval_policy = Constrained::allow_any(AskForApproval::OnRequest); }); let initial = builder.build(&server).await?; - let rollout_path = initial.session_configured.rollout_path.clone(); + let rollout_path = initial + .session_configured + .rollout_path + .clone() + .expect("rollout path"); let home = initial.home.clone(); initial diff --git a/codex-rs/core/tests/suite/resume.rs b/codex-rs/core/tests/suite/resume.rs index d0718a334d..a7be42f430 100644 --- a/codex-rs/core/tests/suite/resume.rs +++ b/codex-rs/core/tests/suite/resume.rs @@ -26,7 +26,11 @@ async fn resume_includes_initial_messages_from_rollout_events() -> Result<()> { let initial = builder.build(&server).await?; let codex = Arc::clone(&initial.codex); let home = initial.home.clone(); - let rollout_path = initial.session_configured.rollout_path.clone(); + let rollout_path = initial + .session_configured + .rollout_path + .clone() + .expect("rollout path"); let initial_sse = sse(vec![ ev_response_created("resp-initial"), @@ -85,7 +89,11 @@ async fn resume_includes_initial_messages_from_reasoning_events() -> Result<()> let initial = builder.build(&server).await?; let codex = Arc::clone(&initial.codex); let home = initial.home.clone(); - let rollout_path = initial.session_configured.rollout_path.clone(); + let rollout_path = initial + .session_configured + .rollout_path + .clone() + .expect("rollout path"); let initial_sse = sse(vec![ ev_response_created("resp-initial"), @@ -143,7 +151,11 @@ async fn resume_switches_models_preserves_base_instructions() -> Result<()> { let initial = builder.build(&server).await?; let codex = Arc::clone(&initial.codex); let home = initial.home.clone(); - let rollout_path = initial.session_configured.rollout_path.clone(); + let rollout_path = initial + .session_configured + .rollout_path + .clone() + .expect("rollout path"); let initial_sse = sse(vec![ ev_response_created("resp-initial"), diff --git a/codex-rs/core/tests/suite/review.rs b/codex-rs/core/tests/suite/review.rs index 3a66e320b6..df678f9e9d 100644 --- a/codex-rs/core/tests/suite/review.rs +++ b/codex-rs/core/tests/suite/review.rs @@ -120,7 +120,7 @@ async fn review_op_emits_lifecycle_and_review_output() { // Also verify that a user message with the header and a formatted finding // was recorded back in the parent session's rollout. - let path = codex.rollout_path(); + let path = codex.rollout_path().expect("rollout path"); let text = std::fs::read_to_string(&path).expect("read rollout file"); let mut saw_header = false; @@ -627,7 +627,7 @@ async fn review_input_isolated_from_parent_history() { assert_eq!(instructions, REVIEW_PROMPT); // Also verify that a user interruption note was recorded in the rollout. - let path = codex.rollout_path(); + let path = codex.rollout_path().expect("rollout path"); let text = std::fs::read_to_string(&path).expect("read rollout file"); let mut saw_interruption_message = false; for line in text.lines() { diff --git a/codex-rs/exec/src/lib.rs b/codex-rs/exec/src/lib.rs index 7679891287..441041aa68 100644 --- a/codex-rs/exec/src/lib.rs +++ b/codex-rs/exec/src/lib.rs @@ -229,6 +229,7 @@ pub async fn run_main(cli: Cli, codex_linux_sandbox_exe: Option) -> any include_apply_patch_tool: None, show_raw_agent_reasoning: oss.then_some(true), tools_web_search_request: None, + ephemeral: None, additional_writable_roots: add_dir, }; diff --git a/codex-rs/exec/tests/event_processor_with_json_output.rs b/codex-rs/exec/tests/event_processor_with_json_output.rs index e0b2509667..43de1f95f8 100644 --- a/codex-rs/exec/tests/event_processor_with_json_output.rs +++ b/codex-rs/exec/tests/event_processor_with_json_output.rs @@ -85,7 +85,7 @@ fn session_configured_produces_thread_started_event() { history_log_id: 0, history_entry_count: 0, initial_messages: None, - rollout_path, + rollout_path: Some(rollout_path), }), ); let out = ep.collect_thread_events(&ev); diff --git a/codex-rs/mcp-server/src/outgoing_message.rs b/codex-rs/mcp-server/src/outgoing_message.rs index b7c66592ff..2069db69d0 100644 --- a/codex-rs/mcp-server/src/outgoing_message.rs +++ b/codex-rs/mcp-server/src/outgoing_message.rs @@ -267,7 +267,7 @@ mod tests { history_log_id: 1, history_entry_count: 1000, initial_messages: None, - rollout_path: rollout_file.path().to_path_buf(), + rollout_path: Some(rollout_file.path().to_path_buf()), }), }; @@ -307,7 +307,7 @@ mod tests { history_log_id: 1, history_entry_count: 1000, initial_messages: None, - rollout_path: rollout_file.path().to_path_buf(), + rollout_path: Some(rollout_file.path().to_path_buf()), }; let event = Event { id: "1".to_string(), @@ -371,7 +371,7 @@ mod tests { history_log_id: 1, history_entry_count: 1000, initial_messages: None, - rollout_path: rollout_file.path().to_path_buf(), + rollout_path: Some(rollout_file.path().to_path_buf()), }; let event = Event { id: "1".to_string(), diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index 5fcefc7a27..7b1c39d061 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -2143,7 +2143,9 @@ pub struct SessionConfiguredEvent { #[serde(skip_serializing_if = "Option::is_none")] pub initial_messages: Option>, - pub rollout_path: PathBuf, + /// Path in which the rollout is stored. Can be `None` for ephemeral threads + #[serde(skip_serializing_if = "Option::is_none")] + pub rollout_path: Option, } /// User's decision in response to an ExecApprovalRequest. @@ -2495,7 +2497,7 @@ mod tests { history_log_id: 0, history_entry_count: 0, initial_messages: None, - rollout_path: rollout_file.path().to_path_buf(), + rollout_path: Some(rollout_file.path().to_path_buf()), }), }; diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 7063f8f5ba..1af15f9ae7 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -2014,14 +2014,14 @@ impl App { history_log_id: 0, history_entry_count: 0, initial_messages: None, - rollout_path: PathBuf::new(), + rollout_path: Some(PathBuf::new()), }); session_configured.session_id = thread_id; session_configured.forked_from_id = None; session_configured.history_log_id = 0; session_configured.history_entry_count = 0; session_configured.initial_messages = None; - session_configured.rollout_path = PathBuf::new(); + session_configured.rollout_path = Some(PathBuf::new()); session_configured } @@ -2576,7 +2576,7 @@ mod tests { history_log_id: 0, history_entry_count: 0, initial_messages: None, - rollout_path: PathBuf::new(), + rollout_path: Some(PathBuf::new()), }; Arc::new(new_session_info( app.chat_widget.config_ref(), @@ -2628,7 +2628,7 @@ mod tests { history_log_id: 0, history_entry_count: 0, initial_messages: None, - rollout_path: PathBuf::new(), + rollout_path: Some(PathBuf::new()), }), }); @@ -2673,7 +2673,7 @@ mod tests { history_log_id: 0, history_entry_count: 0, initial_messages: None, - rollout_path: PathBuf::new(), + rollout_path: Some(PathBuf::new()), }; app.chat_widget.handle_codex_event(Event { diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 1f7fad3ea7..a0f5821d65 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -727,7 +727,7 @@ impl ChatWidget { self.set_skills(None); self.thread_id = Some(event.session_id); self.forked_from = event.forked_from_id; - self.current_rollout_path = Some(event.rollout_path.clone()); + self.current_rollout_path = event.rollout_path.clone(); let initial_messages = event.initial_messages.clone(); let model_for_header = event.model.clone(); self.session_header.set_model(&model_for_header); diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index 6a7401ce6c..f4ff932e20 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -160,7 +160,7 @@ async fn resumed_initial_messages_render_history() { message: "assistant reply".to_string(), }), ]), - rollout_path: rollout_file.path().to_path_buf(), + rollout_path: Some(rollout_file.path().to_path_buf()), }; chat.handle_codex_event(Event { @@ -221,7 +221,7 @@ async fn replayed_user_message_preserves_text_elements_and_local_images() { text_elements: text_elements.clone(), local_images: local_images.clone(), })]), - rollout_path: rollout_file.path().to_path_buf(), + rollout_path: Some(rollout_file.path().to_path_buf()), }; chat.handle_codex_event(Event { @@ -268,7 +268,7 @@ async fn submission_preserves_text_elements_and_local_images() { history_log_id: 0, history_entry_count: 0, initial_messages: None, - rollout_path: rollout_file.path().to_path_buf(), + rollout_path: Some(rollout_file.path().to_path_buf()), }; chat.handle_codex_event(Event { id: "initial".into(), From 365160836585a0e014075bfdbe77e324841e7f0d Mon Sep 17 00:00:00 2001 From: jif-oai Date: Sat, 24 Jan 2026 16:32:06 +0100 Subject: [PATCH 23/36] fix: libcc link (#9819) --- codex-rs/Cargo.toml | 2 +- codex-rs/responses-api-proxy/Cargo.toml | 6 +++++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/codex-rs/Cargo.toml b/codex-rs/Cargo.toml index b39cfd5e15..ef16a94291 100644 --- a/codex-rs/Cargo.toml +++ b/codex-rs/Cargo.toml @@ -181,7 +181,7 @@ ratatui = "0.29.0" ratatui-macros = "0.6.0" regex = "1.12.2" regex-lite = "0.1.8" -reqwest = "0.12" +reqwest = { version = "0.12", default-features = false, features = ["rustls-tls"] } rmcp = { version = "0.12.0", default-features = false } schemars = "0.8.22" seccompiler = "0.5.0" diff --git a/codex-rs/responses-api-proxy/Cargo.toml b/codex-rs/responses-api-proxy/Cargo.toml index e0ea600033..486e08c224 100644 --- a/codex-rs/responses-api-proxy/Cargo.toml +++ b/codex-rs/responses-api-proxy/Cargo.toml @@ -21,7 +21,11 @@ clap = { workspace = true, features = ["derive"] } codex-process-hardening = { workspace = true } ctor = { workspace = true } libc = { workspace = true } -reqwest = { workspace = true, features = ["blocking", "json", "rustls-tls"] } +reqwest = { workspace = true, features = [ + "blocking", + "json", + "rustls-tls", +] } serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } tiny_http = { workspace = true } From 2efcdf4062d9743097af9c54f0ce9ce70a722160 Mon Sep 17 00:00:00 2001 From: jif-oai Date: Sat, 24 Jan 2026 16:56:28 +0100 Subject: [PATCH 24/36] fix: musl build (#9820) --- .github/scripts/install-musl-build-tools.sh | 41 ++++++++++++--------- 1 file changed, 24 insertions(+), 17 deletions(-) diff --git a/.github/scripts/install-musl-build-tools.sh b/.github/scripts/install-musl-build-tools.sh index e2af7528d5..527890dd6a 100644 --- a/.github/scripts/install-musl-build-tools.sh +++ b/.github/scripts/install-musl-build-tools.sh @@ -32,28 +32,35 @@ case "${TARGET}" in ;; esac -if command -v clang++ >/dev/null; then - cxx="$(command -v clang++)" - echo "CXXFLAGS=--target=${TARGET} -stdlib=libc++ -pthread" >> "$GITHUB_ENV" +if command -v "${arch}-linux-musl-gcc" >/dev/null; then + cc="$(command -v "${arch}-linux-musl-gcc")" + echo "CFLAGS=-pthread" >> "$GITHUB_ENV" +elif command -v musl-gcc >/dev/null; then + cc="$(command -v musl-gcc)" + echo "CFLAGS=-pthread" >> "$GITHUB_ENV" +elif command -v clang >/dev/null; then + cc="$(command -v clang)" echo "CFLAGS=--target=${TARGET} -pthread" >> "$GITHUB_ENV" - if command -v clang >/dev/null; then - cc="$(command -v clang)" - echo "CC=${cc}" >> "$GITHUB_ENV" - echo "TARGET_CC=${cc}" >> "$GITHUB_ENV" - target_cc_var="CC_${TARGET}" - target_cc_var="${target_cc_var//-/_}" - echo "${target_cc_var}=${cc}" >> "$GITHUB_ENV" - fi -elif command -v "${arch}-linux-musl-g++" >/dev/null; then +else + echo "musl gcc not found after install; arch=${arch}" >&2 + exit 1 +fi + +echo "CC=${cc}" >> "$GITHUB_ENV" +echo "TARGET_CC=${cc}" >> "$GITHUB_ENV" +target_cc_var="CC_${TARGET}" +target_cc_var="${target_cc_var//-/_}" +echo "${target_cc_var}=${cc}" >> "$GITHUB_ENV" + +if command -v "${arch}-linux-musl-g++" >/dev/null; then cxx="$(command -v "${arch}-linux-musl-g++")" elif command -v musl-g++ >/dev/null; then cxx="$(command -v musl-g++)" -elif command -v musl-gcc >/dev/null; then - cxx="$(command -v musl-gcc)" - echo "CFLAGS=-pthread" >> "$GITHUB_ENV" +elif command -v clang++ >/dev/null; then + cxx="$(command -v clang++)" + echo "CXXFLAGS=--target=${TARGET} -stdlib=libc++ -pthread" >> "$GITHUB_ENV" else - echo "musl g++ not found after install; arch=${arch}" >&2 - exit 1 + cxx="${cc}" fi echo "CXX=${cxx}" >> "$GITHUB_ENV" From e2bd9311c9bdb6c7c055ac9128038be5d25803b0 Mon Sep 17 00:00:00 2001 From: Max Kong Date: Sat, 24 Jan 2026 13:23:37 -0500 Subject: [PATCH 25/36] fix(windows-sandbox): remove request files after read (#9316) ## Summary - Remove elevated runner request files after read (best-effort cleanup on errors) - Add a unit test to cover request file lifecycle ## Testing - `cargo test -p codex-windows-sandbox` (Windows) Fixes #9315 --- .../src/command_runner_win.rs | 28 ++++++++++++++++++- 1 file changed, 27 insertions(+), 1 deletion(-) diff --git a/codex-rs/windows-sandbox-rs/src/command_runner_win.rs b/codex-rs/windows-sandbox-rs/src/command_runner_win.rs index 1739399901..a016c00c75 100644 --- a/codex-rs/windows-sandbox-rs/src/command_runner_win.rs +++ b/codex-rs/windows-sandbox-rs/src/command_runner_win.rs @@ -16,6 +16,7 @@ use codex_windows_sandbox::SandboxPolicy; use serde::Deserialize; use std::collections::HashMap; use std::ffi::c_void; +use std::path::Path; use std::path::PathBuf; use windows_sys::Win32::Foundation::CloseHandle; use windows_sys::Win32::Foundation::GetLastError; @@ -78,13 +79,20 @@ unsafe fn create_job_kill_on_close() -> Result { Ok(h) } +fn read_request_file(req_path: &Path) -> Result { + let content = std::fs::read_to_string(req_path) + .with_context(|| format!("read request file {}", req_path.display())); + let _ = std::fs::remove_file(req_path); + content +} + pub fn main() -> Result<()> { let mut input = String::new(); let mut args = std::env::args().skip(1); if let Some(first) = args.next() { if let Some(rest) = first.strip_prefix("--request-file=") { let req_path = PathBuf::from(rest); - input = std::fs::read_to_string(&req_path).context("read request file")?; + input = read_request_file(&req_path)?; } } if input.is_empty() { @@ -265,3 +273,21 @@ pub fn main() -> Result<()> { } std::process::exit(exit_code); } + +#[cfg(test)] +mod tests { + use super::read_request_file; + use pretty_assertions::assert_eq; + use std::fs; + + #[test] + fn removes_request_file_after_read() { + let dir = tempfile::tempdir().expect("tempdir"); + let req_path = dir.path().join("request.json"); + fs::write(&req_path, "{\"ok\":true}").expect("write request"); + + let content = read_request_file(&req_path).expect("read request"); + assert_eq!(content, "{\"ok\":true}"); + assert!(!req_path.exists(), "request file should be removed"); + } +} From 279c9534a15c041d66a636ffafc6ea053bcc9eb3 Mon Sep 17 00:00:00 2001 From: Charley Cunningham Date: Sat, 24 Jan 2026 10:41:39 -0800 Subject: [PATCH 26/36] =?UTF-8?q?Prevent=20backspace=20from=20removing=20a?= =?UTF-8?q?=20text=20element=20when=20the=20cursor=20is=20at=20the=20eleme?= =?UTF-8?q?nt=E2=80=99s=20left=20edge=20(#9630)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **Summary** - Prevent backspace from removing a text element when the cursor is at the element’s left edge. - Instead just delete the char before the placeholder (moving it to the left). --- codex-rs/tui/src/bottom_pane/chat_composer.rs | 41 ++----------------- codex-rs/tui/src/bottom_pane/textarea.rs | 21 +++++++--- 2 files changed, 19 insertions(+), 43 deletions(-) diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index 47c2c46251..d1f8d691a2 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -2039,15 +2039,6 @@ impl ChatComposer { { self.handle_paste(pasted); } - // Backspace at the start of an image placeholder should delete that placeholder (rather - // than deleting content before it). Do this without scanning the full text by consulting - // the textarea's element list. - if matches!(input.code, KeyCode::Backspace) - && self.try_remove_image_element_at_cursor_start() - { - return (InputResult::None, true); - } - // For non-char inputs (or after flushing), handle normally. // Track element removals so we can drop any corresponding placeholders without scanning // the full text. (Placeholders are atomic elements; when deleted, the element disappears.) @@ -2086,29 +2077,6 @@ impl ChatComposer { (InputResult::None, true) } - fn try_remove_image_element_at_cursor_start(&mut self) -> bool { - if self.attached_images.is_empty() { - return false; - } - - let p = self.textarea.cursor(); - let Some(payload) = self.textarea.element_payload_starting_at(p) else { - return false; - }; - let Some(idx) = self - .attached_images - .iter() - .position(|img| img.placeholder == payload) - else { - return false; - }; - - self.textarea.replace_range(p..p + payload.len(), ""); - self.attached_images.remove(idx); - self.relabel_attached_images_and_update_placeholders(); - true - } - fn reconcile_deleted_elements(&mut self, elements_before: Vec) { let elements_after: HashSet = self.textarea.element_payloads().into_iter().collect(); @@ -4662,8 +4630,7 @@ mod tests { assert!(!composer.textarea.text().contains(&placeholder)); assert!(composer.attached_images.is_empty()); - // Re-add and test backspace in middle: should break the placeholder string - // and drop the image mapping (same as text placeholder behavior). + // Re-add and ensure backspace at element start does not delete the placeholder. composer.attach_image(path); let placeholder2 = composer.attached_images[0].placeholder.clone(); // Move cursor to roughly middle of placeholder @@ -4671,8 +4638,8 @@ mod tests { let mid_pos = start_pos + (placeholder2.len() / 2); composer.textarea.set_cursor(mid_pos); composer.handle_key_event(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE)); - assert!(!composer.textarea.text().contains(&placeholder2)); - assert!(composer.attached_images.is_empty()); + assert!(composer.textarea.text().contains(&placeholder2)); + assert_eq!(composer.attached_images.len(), 1); } else { panic!("Placeholder not found in textarea"); } @@ -4852,7 +4819,7 @@ mod tests { assert_eq!(composer.textarea.text(), "[Image #1][Image #2]"); assert_eq!(composer.attached_images.len(), 2); - // Delete the first element using normal textarea editing (Delete at cursor start). + // Delete the first element using normal textarea editing (forward Delete at cursor start). composer.textarea.set_cursor(0); composer.handle_key_event(KeyEvent::new(KeyCode::Delete, KeyModifiers::NONE)); diff --git a/codex-rs/tui/src/bottom_pane/textarea.rs b/codex-rs/tui/src/bottom_pane/textarea.rs index 3a849822b1..0235f7a9c1 100644 --- a/codex-rs/tui/src/bottom_pane/textarea.rs +++ b/codex-rs/tui/src/bottom_pane/textarea.rs @@ -766,12 +766,6 @@ impl TextArea { .collect() } - pub fn element_payload_starting_at(&self, pos: usize) -> Option { - let pos = pos.min(self.text.len()); - let elem = self.elements.iter().find(|e| e.range.start == pos)?; - self.text.get(elem.range.clone()).map(str::to_string) - } - /// Renames a single text element in-place, keeping it atomic. /// /// Use this when the element payload is an identifier (e.g. a placeholder) that must be @@ -1327,6 +1321,21 @@ mod tests { assert_eq!(t.text(), "b"); } + #[test] + fn delete_forward_deletes_element_at_left_edge() { + let mut t = TextArea::new(); + t.insert_str("a"); + t.insert_element(""); + t.insert_str("b"); + + let elem_start = t.elements[0].range.start; + t.set_cursor(elem_start); + t.delete_forward(1); + + assert_eq!(t.text(), "ab"); + assert_eq!(t.cursor(), elem_start); + } + #[test] fn delete_backward_word_and_kill_line_variants() { // delete backward word at end removes the whole previous word From 652f08e98fd95a55b8180e36d1e8afb0e96c4ce9 Mon Sep 17 00:00:00 2001 From: Ahmed Ibrahim Date: Sat, 24 Jan 2026 20:46:53 -0800 Subject: [PATCH 27/36] Revert "fix: musl build" (#9840) Reverts openai/codex#9820 --- .github/scripts/install-musl-build-tools.sh | 41 +++++++++------------ 1 file changed, 17 insertions(+), 24 deletions(-) diff --git a/.github/scripts/install-musl-build-tools.sh b/.github/scripts/install-musl-build-tools.sh index 527890dd6a..e2af7528d5 100644 --- a/.github/scripts/install-musl-build-tools.sh +++ b/.github/scripts/install-musl-build-tools.sh @@ -32,35 +32,28 @@ case "${TARGET}" in ;; esac -if command -v "${arch}-linux-musl-gcc" >/dev/null; then - cc="$(command -v "${arch}-linux-musl-gcc")" - echo "CFLAGS=-pthread" >> "$GITHUB_ENV" -elif command -v musl-gcc >/dev/null; then - cc="$(command -v musl-gcc)" - echo "CFLAGS=-pthread" >> "$GITHUB_ENV" -elif command -v clang >/dev/null; then - cc="$(command -v clang)" +if command -v clang++ >/dev/null; then + cxx="$(command -v clang++)" + echo "CXXFLAGS=--target=${TARGET} -stdlib=libc++ -pthread" >> "$GITHUB_ENV" echo "CFLAGS=--target=${TARGET} -pthread" >> "$GITHUB_ENV" -else - echo "musl gcc not found after install; arch=${arch}" >&2 - exit 1 -fi - -echo "CC=${cc}" >> "$GITHUB_ENV" -echo "TARGET_CC=${cc}" >> "$GITHUB_ENV" -target_cc_var="CC_${TARGET}" -target_cc_var="${target_cc_var//-/_}" -echo "${target_cc_var}=${cc}" >> "$GITHUB_ENV" - -if command -v "${arch}-linux-musl-g++" >/dev/null; then + if command -v clang >/dev/null; then + cc="$(command -v clang)" + echo "CC=${cc}" >> "$GITHUB_ENV" + echo "TARGET_CC=${cc}" >> "$GITHUB_ENV" + target_cc_var="CC_${TARGET}" + target_cc_var="${target_cc_var//-/_}" + echo "${target_cc_var}=${cc}" >> "$GITHUB_ENV" + fi +elif command -v "${arch}-linux-musl-g++" >/dev/null; then cxx="$(command -v "${arch}-linux-musl-g++")" elif command -v musl-g++ >/dev/null; then cxx="$(command -v musl-g++)" -elif command -v clang++ >/dev/null; then - cxx="$(command -v clang++)" - echo "CXXFLAGS=--target=${TARGET} -stdlib=libc++ -pthread" >> "$GITHUB_ENV" +elif command -v musl-gcc >/dev/null; then + cxx="$(command -v musl-gcc)" + echo "CFLAGS=-pthread" >> "$GITHUB_ENV" else - cxx="${cc}" + echo "musl g++ not found after install; arch=${arch}" >&2 + exit 1 fi echo "CXX=${cxx}" >> "$GITHUB_ENV" From 182000999c882a4424475981f1790e2311b9777c Mon Sep 17 00:00:00 2001 From: Matthew Zeng Date: Sat, 24 Jan 2026 21:50:35 -0800 Subject: [PATCH 28/36] Raise welcome animation breakpoint to 37 rows (#9778) ### Motivation - The large ASCII welcome animation can push onboarding content below the fold on default-height terminals, making the CLI appear unresponsive; raising the breakpoint prevents that. - The existing test measured an arbitrary row count rather than asserting the welcome line position relative to the animation frame, which made the intent unclear. ### Description - Increase `MIN_ANIMATION_HEIGHT` from `20` to `37` in `codex-rs/tui/src/onboarding/welcome.rs` so the animation is skipped unless there is enough vertical space. - Replace the brittle measurement logic in the welcome render test with a `row_containing` helper and assert the welcome row equals the frame height plus the spacer line (`frame_lines + 1`). - Add a regression test `welcome_skips_animation_below_height_breakpoint` that verifies the animation is not rendered when the viewport height is one row below the breakpoint. ### Testing - Ran formatting with `~/.cargo/bin/just fmt` which completed successfully. - Ran unit tests for the crate with `cargo test -p codex-tui --lib` and they passed (unit test suite succeeded). - Ran `cargo test -p codex-tui` which reported a failing integration test in this environment because the test cannot locate the `codex` binary, so full crate tests are blocked here (environment limitation). ------ [Codex Task](https://chatgpt.com/codex/tasks/task_i_6973b0a710d4832c9ff36fac26eb1519) --- .../tui/src/onboarding/onboarding_screen.rs | 3 + codex-rs/tui/src/onboarding/welcome.rs | 59 ++++++++++++------- 2 files changed, 41 insertions(+), 21 deletions(-) diff --git a/codex-rs/tui/src/onboarding/onboarding_screen.rs b/codex-rs/tui/src/onboarding/onboarding_screen.rs index 7e076ae160..78a7f0ce85 100644 --- a/codex-rs/tui/src/onboarding/onboarding_screen.rs +++ b/codex-rs/tui/src/onboarding/onboarding_screen.rs @@ -311,6 +311,9 @@ impl WidgetRef for &OnboardingScreen { } let scratch_area = Rect::new(0, 0, width, max_h); let mut scratch = Buffer::empty(scratch_area); + if let Step::Welcome(widget) = step { + widget.update_layout_area(scratch_area); + } step.render_ref(scratch_area, &mut scratch); let h = used_rows(&scratch, width, max_h).min(max_h); if h > 0 { diff --git a/codex-rs/tui/src/onboarding/welcome.rs b/codex-rs/tui/src/onboarding/welcome.rs index 8ff5e81982..bdf4f91408 100644 --- a/codex-rs/tui/src/onboarding/welcome.rs +++ b/codex-rs/tui/src/onboarding/welcome.rs @@ -11,6 +11,7 @@ use ratatui::widgets::Clear; use ratatui::widgets::Paragraph; use ratatui::widgets::WidgetRef; use ratatui::widgets::Wrap; +use std::cell::Cell; use crate::ascii_animation::AsciiAnimation; use crate::onboarding::onboarding_screen::KeyboardHandler; @@ -19,13 +20,14 @@ use crate::tui::FrameRequester; use super::onboarding_screen::StepState; -const MIN_ANIMATION_HEIGHT: u16 = 20; +const MIN_ANIMATION_HEIGHT: u16 = 37; const MIN_ANIMATION_WIDTH: u16 = 60; pub(crate) struct WelcomeWidget { pub is_logged_in: bool, animation: AsciiAnimation, animations_enabled: bool, + layout_area: Cell>, } impl KeyboardHandler for WelcomeWidget { @@ -53,8 +55,13 @@ impl WelcomeWidget { is_logged_in, animation: AsciiAnimation::new(request_frame), animations_enabled, + layout_area: Cell::new(None), } } + + pub(crate) fn update_layout_area(&self, area: Rect) { + self.layout_area.set(Some(area)); + } } impl WidgetRef for &WelcomeWidget { @@ -64,12 +71,14 @@ impl WidgetRef for &WelcomeWidget { self.animation.schedule_next_frame(); } + let layout_area = self.layout_area.get().unwrap_or(area); // Skip the animation entirely when the viewport is too small so we don't clip frames. - let show_animation = - area.height >= MIN_ANIMATION_HEIGHT && area.width >= MIN_ANIMATION_WIDTH; + let show_animation = self.animations_enabled + && layout_area.height >= MIN_ANIMATION_HEIGHT + && layout_area.width >= MIN_ANIMATION_WIDTH; let mut lines: Vec = Vec::new(); - if show_animation && self.animations_enabled { + if show_animation { let frame = self.animation.current_frame(); lines.extend(frame.lines().map(Into::into)); lines.push("".into()); @@ -99,6 +108,7 @@ impl StepStateProvider for WelcomeWidget { #[cfg(test)] mod tests { use super::*; + use pretty_assertions::assert_eq; use ratatui::buffer::Buffer; use ratatui::layout::Rect; @@ -106,31 +116,37 @@ mod tests { static VARIANT_B: [&str; 1] = ["frame-b"]; static VARIANTS: [&[&str]; 2] = [&VARIANT_A, &VARIANT_B]; + fn row_containing(buf: &Buffer, needle: &str) -> Option { + (0..buf.area.height).find(|&y| { + let mut row = String::new(); + for x in 0..buf.area.width { + row.push_str(buf[(x, y)].symbol()); + } + row.contains(needle) + }) + } + #[test] fn welcome_renders_animation_on_first_draw() { let widget = WelcomeWidget::new(false, FrameRequester::test_dummy(), true); let area = Rect::new(0, 0, MIN_ANIMATION_WIDTH, MIN_ANIMATION_HEIGHT); let mut buf = Buffer::empty(area); + let frame_lines = widget.animation.current_frame().lines().count() as u16; (&widget).render(area, &mut buf); - let mut found = false; - let mut last_non_empty: Option = None; - for y in 0..area.height { - for x in 0..area.width { - if !buf[(x, y)].symbol().trim().is_empty() { - found = true; - last_non_empty = Some(y); - break; - } - } - } + let welcome_row = row_containing(&buf, "Welcome"); + assert_eq!(welcome_row, Some(frame_lines + 1)); + } - assert!(found, "expected welcome animation to render characters"); - let measured_rows = last_non_empty.map(|v| v + 2).unwrap_or(0); - assert!( - measured_rows >= MIN_ANIMATION_HEIGHT, - "expected measurement to report at least {MIN_ANIMATION_HEIGHT} rows, got {measured_rows}" - ); + #[test] + fn welcome_skips_animation_below_height_breakpoint() { + let widget = WelcomeWidget::new(false, FrameRequester::test_dummy(), true); + let area = Rect::new(0, 0, MIN_ANIMATION_WIDTH, MIN_ANIMATION_HEIGHT - 1); + let mut buf = Buffer::empty(area); + (&widget).render(area, &mut buf); + + let welcome_row = row_containing(&buf, "Welcome"); + assert_eq!(welcome_row, Some(0)); } #[test] @@ -139,6 +155,7 @@ mod tests { is_logged_in: false, animation: AsciiAnimation::with_variants(FrameRequester::test_dummy(), &VARIANTS, 0), animations_enabled: true, + layout_area: Cell::new(None), }; let before = widget.animation.current_frame(); From 18acec09dfc45964cd567ccb011b785202273caf Mon Sep 17 00:00:00 2001 From: Charley Cunningham Date: Sat, 24 Jan 2026 21:57:19 -0800 Subject: [PATCH 29/36] Ask for cwd choice when resuming session from different cwd (#9731) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Summary - Fix resume/fork config rebuild so cwd changes inside the TUI produce a fully rebuilt Config (trust/approval/sandbox) instead of mutating only the cwd. - Preserve `--add-dir` behavior across resume/fork by normalizing relative roots to absolute paths once (based on the original cwd). - Prefer latest `TurnContext.cwd` for resume/fork prompts but fall back to `SessionMeta.cwd` if the latest cwd no longer exists. - Align resume/fork selection handling and ensure UI config matches the resumed thread config. - Fix Windows test TOML path escaping in trust-level test. # Details - Rebuild Config via `ConfigBuilder` when resuming into a different cwd; carry forward runtime approval/sandbox overrides. - Add `normalize_harness_overrides_for_cwd` to resolve relative `additional_writable_roots` against the initial cwd before reuse. - Guard `read_session_cwd` with filesystem existence check for the latest `TurnContext.cwd`. - Update naming/flow around cwd comparison and prompt selection. Screenshot 2026-01-23 at 5 42 13 PM And proof the model understands the new cwd: Screenshot 2026-01-22 at 5 36 45 PM --- codex-rs/tui/src/app.rs | 140 ++++++++- codex-rs/tui/src/cwd_prompt.rs | 286 +++++++++++++++++ codex-rs/tui/src/lib.rs | 295 +++++++++++++++++- ..._prompt__tests__cwd_prompt_fork_modal.snap | 14 + ...__cwd_prompt__tests__cwd_prompt_modal.snap | 14 + 5 files changed, 733 insertions(+), 16 deletions(-) create mode 100644 codex-rs/tui/src/cwd_prompt.rs create mode 100644 codex-rs/tui/src/snapshots/codex_tui__cwd_prompt__tests__cwd_prompt_fork_modal.snap create mode 100644 codex-rs/tui/src/snapshots/codex_tui__cwd_prompt__tests__cwd_prompt_modal.snap diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 1af15f9ae7..fb8240973a 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -12,6 +12,7 @@ use crate::bottom_pane::SelectionViewParams; use crate::bottom_pane::popup_consts::standard_popup_hint_line; use crate::chatwidget::ChatWidget; use crate::chatwidget::ExternalEditorState; +use crate::cwd_prompt::CwdPromptAction; use crate::diff_render::DiffSummary; use crate::exec_command::strip_bash_lc_and_escape; use crate::external_editor; @@ -36,6 +37,8 @@ use codex_core::AuthManager; use codex_core::CodexAuth; use codex_core::ThreadManager; use codex_core::config::Config; +use codex_core::config::ConfigBuilder; +use codex_core::config::ConfigOverrides; use codex_core::config::edit::ConfigEdit; use codex_core::config::edit::ConfigEditsBuilder; use codex_core::config_loader::ConfigLayerStackOrdering; @@ -44,12 +47,14 @@ use codex_core::features::Feature; use codex_core::models_manager::manager::RefreshStrategy; use codex_core::models_manager::model_presets::HIDE_GPT_5_1_CODEX_MAX_MIGRATION_PROMPT_CONFIG; use codex_core::models_manager::model_presets::HIDE_GPT5_1_MIGRATION_PROMPT_CONFIG; +use codex_core::protocol::AskForApproval; use codex_core::protocol::DeprecationNoticeEvent; use codex_core::protocol::Event; use codex_core::protocol::EventMsg; use codex_core::protocol::FinalOutput; use codex_core::protocol::ListSkillsResponseEvent; use codex_core::protocol::Op; +use codex_core::protocol::SandboxPolicy; use codex_core::protocol::SessionSource; use codex_core::protocol::SkillErrorInfo; use codex_core::protocol::TokenUsage; @@ -60,6 +65,7 @@ use codex_protocol::openai_models::ModelPreset; use codex_protocol::openai_models::ModelUpgrade; use codex_protocol::openai_models::ReasoningEffort as ReasoningEffortConfig; use codex_protocol::protocol::SessionConfiguredEvent; +use codex_utils_absolute_path::AbsolutePathBuf; use color_eyre::eyre::Result; use color_eyre::eyre::WrapErr; use crossterm::event::KeyCode; @@ -87,6 +93,7 @@ use tokio::sync::broadcast; use tokio::sync::mpsc; use tokio::sync::mpsc::error::TryRecvError; use tokio::sync::mpsc::unbounded_channel; +use toml::Value as TomlValue; const EXTERNAL_EDITOR_HINT: &str = "Save and close external editor to continue."; const THREAD_EVENT_CHANNEL_CAPACITY: usize = 1024; @@ -498,6 +505,10 @@ pub(crate) struct App { /// Config is stored here so we can recreate ChatWidgets as needed. pub(crate) config: Config, pub(crate) active_profile: Option, + cli_kv_overrides: Vec<(String, TomlValue)>, + harness_overrides: ConfigOverrides, + runtime_approval_policy_override: Option, + runtime_sandbox_policy_override: Option, pub(crate) file_search: FileSearchManager, @@ -545,6 +556,23 @@ struct WindowsSandboxState { skip_world_writable_scan_once: bool, } +fn normalize_harness_overrides_for_cwd( + mut overrides: ConfigOverrides, + base_cwd: &Path, +) -> Result { + if overrides.additional_writable_roots.is_empty() { + return Ok(overrides); + } + + let mut normalized = Vec::with_capacity(overrides.additional_writable_roots.len()); + for root in overrides.additional_writable_roots.drain(..) { + let absolute = AbsolutePathBuf::resolve_path_against_base(root, base_cwd)?; + normalized.push(absolute.into_path_buf()); + } + overrides.additional_writable_roots = normalized; + Ok(overrides) +} + impl App { pub fn chatwidget_init_for_forked_or_resumed_thread( &self, @@ -567,6 +595,38 @@ impl App { } } + async fn rebuild_config_for_cwd(&self, cwd: PathBuf) -> Result { + let mut overrides = self.harness_overrides.clone(); + overrides.cwd = Some(cwd.clone()); + let cwd_display = cwd.display().to_string(); + ConfigBuilder::default() + .codex_home(self.config.codex_home.clone()) + .cli_overrides(self.cli_kv_overrides.clone()) + .harness_overrides(overrides) + .build() + .await + .wrap_err_with(|| format!("Failed to rebuild config for cwd {cwd_display}")) + } + + fn apply_runtime_policy_overrides(&mut self, config: &mut Config) { + if let Some(policy) = self.runtime_approval_policy_override.as_ref() + && let Err(err) = config.approval_policy.set(*policy) + { + tracing::warn!(%err, "failed to carry forward approval policy override"); + self.chat_widget.add_error_message(format!( + "Failed to carry forward approval policy override: {err}" + )); + } + if let Some(policy) = self.runtime_sandbox_policy_override.as_ref() + && let Err(err) = config.sandbox_policy.set(policy.clone()) + { + tracing::warn!(%err, "failed to carry forward sandbox policy override"); + self.chat_widget.add_error_message(format!( + "Failed to carry forward sandbox policy override: {err}" + )); + } + } + async fn shutdown_current_thread(&mut self) { if let Some(thread_id) = self.chat_widget.thread_id() { // Clear any in-flight rollback guard when switching threads. @@ -824,6 +884,8 @@ impl App { tui: &mut tui::Tui, auth_manager: Arc, mut config: Config, + cli_kv_overrides: Vec<(String, TomlValue)>, + harness_overrides: ConfigOverrides, active_profile: Option, initial_prompt: Option, initial_images: Vec, @@ -838,6 +900,8 @@ impl App { emit_deprecation_notice(&app_event_tx, ollama_chat_support_notice); emit_project_config_warnings(&app_event_tx, &config); + let harness_overrides = + normalize_harness_overrides_for_cwd(harness_overrides, &config.cwd)?; let thread_manager = Arc::new(ThreadManager::new( config.codex_home.clone(), auth_manager.clone(), @@ -979,6 +1043,10 @@ impl App { auth_manager: auth_manager.clone(), config, active_profile, + cli_kv_overrides, + harness_overrides, + runtime_approval_policy_override: None, + runtime_sandbox_policy_override: None, file_search, enhanced_keys_supported, transcript_cells: Vec::new(), @@ -1203,6 +1271,34 @@ impl App { .await? { SessionSelection::Resume(path) => { + let current_cwd = self.config.cwd.clone(); + let resume_cwd = match crate::resolve_cwd_for_resume_or_fork( + tui, + ¤t_cwd, + &path, + CwdPromptAction::Resume, + true, + ) + .await? + { + Some(cwd) => cwd, + None => current_cwd.clone(), + }; + let mut resume_config = if crate::cwds_differ(¤t_cwd, &resume_cwd) { + match self.rebuild_config_for_cwd(resume_cwd).await { + Ok(cfg) => cfg, + Err(err) => { + self.chat_widget.add_error_message(format!( + "Failed to rebuild configuration for resume: {err}" + )); + return Ok(AppRunControl::Continue); + } + } + } else { + // No rebuild needed: current_cwd comes from self.config.cwd. + self.config.clone() + }; + self.apply_runtime_policy_overrides(&mut resume_config); let summary = session_summary( self.chat_widget.token_usage(), self.chat_widget.thread_id(), @@ -1210,7 +1306,7 @@ impl App { match self .server .resume_thread_from_rollout( - self.config.clone(), + resume_config.clone(), path.clone(), self.auth_manager.clone(), ) @@ -1218,6 +1314,11 @@ impl App { { Ok(resumed) => { self.shutdown_current_thread().await; + self.config = resume_config; + self.file_search = FileSearchManager::new( + self.config.cwd.clone(), + self.app_event_tx.clone(), + ); let init = self.chatwidget_init_for_forked_or_resumed_thread( tui, self.config.clone(), @@ -1660,6 +1761,13 @@ impl App { } } AppEvent::UpdateAskForApprovalPolicy(policy) => { + self.runtime_approval_policy_override = Some(policy); + if let Err(err) = self.config.approval_policy.set(policy) { + tracing::warn!(%err, "failed to set approval policy on app config"); + self.chat_widget + .add_error_message(format!("Failed to set approval policy: {err}")); + return Ok(AppRunControl::Continue); + } self.chat_widget.set_approval_policy(policy); } AppEvent::UpdateSandboxPolicy(policy) => { @@ -1688,6 +1796,8 @@ impl App { .add_error_message(format!("Failed to set sandbox policy: {err}")); return Ok(AppRunControl::Continue); } + self.runtime_sandbox_policy_override = + Some(self.config.sandbox_policy.get().clone()); // If sandbox policy becomes workspace-write or read-only, run the Windows world-writable scan. #[cfg(target_os = "windows")] @@ -2236,6 +2346,7 @@ mod tests { use codex_core::CodexAuth; use codex_core::ThreadManager; use codex_core::config::ConfigBuilder; + use codex_core::config::ConfigOverrides; use codex_core::models_manager::manager::ModelsManager; use codex_core::protocol::AskForApproval; use codex_core::protocol::Event; @@ -2254,6 +2365,25 @@ mod tests { use std::sync::atomic::AtomicBool; use tempfile::tempdir; + #[test] + fn normalize_harness_overrides_resolves_relative_add_dirs() -> Result<()> { + let temp_dir = tempdir()?; + let base_cwd = temp_dir.path().join("base"); + std::fs::create_dir_all(&base_cwd)?; + + let overrides = ConfigOverrides { + additional_writable_roots: vec![PathBuf::from("rel")], + ..Default::default() + }; + let normalized = normalize_harness_overrides_for_cwd(overrides, &base_cwd)?; + + assert_eq!( + normalized.additional_writable_roots, + vec![base_cwd.join("rel")] + ); + Ok(()) + } + async fn make_test_app() -> App { let (chat_widget, app_event_tx, _rx, _op_rx) = make_chatwidget_manual_with_sender().await; let config = chat_widget.config_ref().clone(); @@ -2275,6 +2405,10 @@ mod tests { auth_manager, config, active_profile: None, + cli_kv_overrides: Vec::new(), + harness_overrides: ConfigOverrides::default(), + runtime_approval_policy_override: None, + runtime_sandbox_policy_override: None, file_search, transcript_cells: Vec::new(), overlay: None, @@ -2323,6 +2457,10 @@ mod tests { auth_manager, config, active_profile: None, + cli_kv_overrides: Vec::new(), + harness_overrides: ConfigOverrides::default(), + runtime_approval_policy_override: None, + runtime_sandbox_policy_override: None, file_search, transcript_cells: Vec::new(), overlay: None, diff --git a/codex-rs/tui/src/cwd_prompt.rs b/codex-rs/tui/src/cwd_prompt.rs new file mode 100644 index 0000000000..2a9c016a1e --- /dev/null +++ b/codex-rs/tui/src/cwd_prompt.rs @@ -0,0 +1,286 @@ +use std::path::Path; + +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 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; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum CwdPromptAction { + Resume, + Fork, +} + +impl CwdPromptAction { + fn verb(self) -> &'static str { + match self { + CwdPromptAction::Resume => "resume", + CwdPromptAction::Fork => "fork", + } + } + + fn past_participle(self) -> &'static str { + match self { + CwdPromptAction::Resume => "resumed", + CwdPromptAction::Fork => "forked", + } + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum CwdSelection { + Current, + Session, +} + +impl CwdSelection { + fn next(self) -> Self { + match self { + CwdSelection::Current => CwdSelection::Session, + CwdSelection::Session => CwdSelection::Current, + } + } + + fn prev(self) -> Self { + match self { + CwdSelection::Current => CwdSelection::Session, + CwdSelection::Session => CwdSelection::Current, + } + } +} + +pub(crate) async fn run_cwd_selection_prompt( + tui: &mut Tui, + action: CwdPromptAction, + current_cwd: &Path, + session_cwd: &Path, +) -> Result { + let mut screen = CwdPromptScreen::new( + tui.frame_requester(), + action, + current_cwd.display().to_string(), + session_cwd.display().to_string(), + ); + 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; + } + } + + Ok(screen.selection().unwrap_or(CwdSelection::Session)) +} + +struct CwdPromptScreen { + request_frame: FrameRequester, + action: CwdPromptAction, + current_cwd: String, + session_cwd: String, + highlighted: CwdSelection, + selection: Option, +} + +impl CwdPromptScreen { + fn new( + request_frame: FrameRequester, + action: CwdPromptAction, + current_cwd: String, + session_cwd: String, + ) -> Self { + Self { + request_frame, + action, + current_cwd, + session_cwd, + highlighted: CwdSelection::Session, + 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(CwdSelection::Session); + 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(CwdSelection::Session), + KeyCode::Char('2') => self.select(CwdSelection::Current), + KeyCode::Enter => self.select(self.highlighted), + KeyCode::Esc => self.select(CwdSelection::Session), + _ => {} + } + } + + fn set_highlight(&mut self, highlight: CwdSelection) { + if self.highlighted != highlight { + self.highlighted = highlight; + self.request_frame.schedule_frame(); + } + } + + fn select(&mut self, selection: CwdSelection) { + 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 + } +} + +impl WidgetRef for &CwdPromptScreen { + fn render_ref(&self, area: Rect, buf: &mut Buffer) { + Clear.render(area, buf); + let mut column = ColumnRenderable::new(); + + let action_verb = self.action.verb(); + let action_past = self.action.past_participle(); + let current_cwd = self.current_cwd.as_str(); + let session_cwd = self.session_cwd.as_str(); + + column.push(""); + column.push(Line::from(vec![ + "Choose working directory to ".into(), + action_verb.bold(), + " this session".into(), + ])); + column.push(""); + column.push( + Line::from(format!( + "Session = latest cwd recorded in the {action_past} session" + )) + .dim() + .inset(Insets::tlbr(0, 2, 0, 0)), + ); + column.push( + Line::from("Current = your current working directory".dim()) + .inset(Insets::tlbr(0, 2, 0, 0)), + ); + column.push(""); + column.push(selection_option_row( + 0, + format!("Use session directory ({session_cwd})"), + self.highlighted == CwdSelection::Session, + )); + column.push(selection_option_row( + 1, + format!("Use current directory ({current_cwd})"), + self.highlighted == CwdSelection::Current, + )); + 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 crossterm::event::KeyEvent; + use crossterm::event::KeyModifiers; + use pretty_assertions::assert_eq; + use ratatui::Terminal; + + fn new_prompt() -> CwdPromptScreen { + CwdPromptScreen::new( + FrameRequester::test_dummy(), + CwdPromptAction::Resume, + "/Users/example/current".to_string(), + "/Users/example/session".to_string(), + ) + } + + #[test] + fn cwd_prompt_snapshot() { + let screen = new_prompt(); + let mut terminal = Terminal::new(VT100Backend::new(80, 14)).expect("terminal"); + terminal + .draw(|frame| frame.render_widget_ref(&screen, frame.area())) + .expect("render cwd prompt"); + insta::assert_snapshot!("cwd_prompt_modal", terminal.backend()); + } + + #[test] + fn cwd_prompt_fork_snapshot() { + let screen = CwdPromptScreen::new( + FrameRequester::test_dummy(), + CwdPromptAction::Fork, + "/Users/example/current".to_string(), + "/Users/example/session".to_string(), + ); + let mut terminal = Terminal::new(VT100Backend::new(80, 14)).expect("terminal"); + terminal + .draw(|frame| frame.render_widget_ref(&screen, frame.area())) + .expect("render cwd prompt"); + insta::assert_snapshot!("cwd_prompt_fork_modal", terminal.backend()); + } + + #[test] + fn cwd_prompt_selects_session_by_default() { + let mut screen = new_prompt(); + screen.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + assert_eq!(screen.selection(), Some(CwdSelection::Session)); + } + + #[test] + fn cwd_prompt_can_select_current() { + let mut screen = new_prompt(); + screen.handle_key(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE)); + screen.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + assert_eq!(screen.selection(), Some(CwdSelection::Current)); + } +} diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index e3ee2ed4e6..0fac506d32 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -27,13 +27,19 @@ use codex_core::config_loader::ConfigLoadError; use codex_core::config_loader::format_config_error_with_source; use codex_core::find_thread_path_by_id_str; use codex_core::get_platform_sandbox; +use codex_core::path_utils; use codex_core::protocol::AskForApproval; use codex_core::read_session_meta_line; use codex_core::terminal::Multiplexer; use codex_protocol::config_types::AltScreenMode; use codex_protocol::config_types::SandboxMode; +use codex_protocol::protocol::RolloutItem; +use codex_protocol::protocol::RolloutLine; use codex_utils_absolute_path::AbsolutePathBuf; +use cwd_prompt::CwdPromptAction; +use cwd_prompt::CwdSelection; use std::fs::OpenOptions; +use std::path::Path; use std::path::PathBuf; use tracing::error; use tracing_appender::non_blocking; @@ -54,6 +60,7 @@ mod collab; mod collaboration_modes; mod color; pub mod custom_terminal; +mod cwd_prompt; mod diff_render; mod exec_cell; mod exec_command; @@ -577,25 +584,27 @@ async fn run_ratatui_app( resume_picker::SessionSelection::StartFresh }; + let current_cwd = config.cwd.clone(); + let allow_prompt = cli.cwd.is_none(); + let action_and_path_if_resume_or_fork = match &session_selection { + resume_picker::SessionSelection::Resume(path) => Some((CwdPromptAction::Resume, path)), + resume_picker::SessionSelection::Fork(path) => Some((CwdPromptAction::Fork, path)), + _ => None, + }; + let fallback_cwd = match action_and_path_if_resume_or_fork { + Some((action, path)) => { + resolve_cwd_for_resume_or_fork(&mut tui, ¤t_cwd, path, action, allow_prompt) + .await? + } + None => None, + }; + let config = match &session_selection { - resume_picker::SessionSelection::Resume(path) - | resume_picker::SessionSelection::Fork(path) => { - let history_cwd = match read_session_meta_line(path).await { - Ok(meta_line) => Some(meta_line.meta.cwd), - Err(err) => { - let rollout_path = path.display().to_string(); - tracing::warn!( - %rollout_path, - %err, - "Failed to read session metadata from rollout" - ); - None - } - }; + resume_picker::SessionSelection::Resume(_) | resume_picker::SessionSelection::Fork(_) => { load_config_or_exit_with_fallback_cwd( cli_kv_overrides.clone(), overrides.clone(), - history_cwd, + fallback_cwd, ) .await } @@ -618,6 +627,8 @@ async fn run_ratatui_app( &mut tui, auth_manager, config, + cli_kv_overrides.clone(), + overrides.clone(), active_profile, prompt, images, @@ -635,6 +646,77 @@ async fn run_ratatui_app( app_result } +pub(crate) async fn read_session_cwd(path: &Path) -> Option { + // Prefer the latest TurnContext cwd so resume/fork reflects the most recent + // session directory (for the changed-cwd prompt). The alternative would be + // mutating the SessionMeta line when the session cwd changes, but the rollout + // is an append-only JSONL log and rewriting the head would be error-prone. + // When rollouts move to SQLite, we can drop this scan. + if let Some(cwd) = parse_latest_turn_context_cwd(path).await { + return Some(cwd); + } + match read_session_meta_line(path).await { + Ok(meta_line) => Some(meta_line.meta.cwd), + Err(err) => { + let rollout_path = path.display().to_string(); + tracing::warn!( + %rollout_path, + %err, + "Failed to read session metadata from rollout" + ); + None + } + } +} + +async fn parse_latest_turn_context_cwd(path: &Path) -> Option { + let text = tokio::fs::read_to_string(path).await.ok()?; + for line in text.lines().rev() { + let trimmed = line.trim(); + if trimmed.is_empty() { + continue; + } + let Ok(rollout_line) = serde_json::from_str::(trimmed) else { + continue; + }; + if let RolloutItem::TurnContext(item) = rollout_line.item { + return Some(item.cwd); + } + } + None +} + +pub(crate) fn cwds_differ(current_cwd: &Path, session_cwd: &Path) -> bool { + match ( + path_utils::normalize_for_path_comparison(current_cwd), + path_utils::normalize_for_path_comparison(session_cwd), + ) { + (Ok(current), Ok(session)) => current != session, + _ => current_cwd != session_cwd, + } +} + +pub(crate) async fn resolve_cwd_for_resume_or_fork( + tui: &mut Tui, + current_cwd: &Path, + path: &Path, + action: CwdPromptAction, + allow_prompt: bool, +) -> color_eyre::Result> { + let Some(history_cwd) = read_session_cwd(path).await else { + return Ok(None); + }; + if allow_prompt && cwds_differ(current_cwd, &history_cwd) { + let selection = + cwd_prompt::run_cwd_selection_prompt(tui, action, current_cwd, &history_cwd).await?; + return Ok(Some(match selection { + CwdSelection::Current => current_cwd.to_path_buf(), + CwdSelection::Session => history_cwd, + })); + } + Ok(Some(history_cwd)) +} + #[expect( clippy::print_stderr, reason = "TUI should no longer be displayed, so we can write to stderr." @@ -772,7 +854,14 @@ fn should_show_login_screen(login_status: LoginStatus, config: &Config) -> bool mod tests { use super::*; use codex_core::config::ConfigBuilder; + use codex_core::config::ConfigOverrides; use codex_core::config::ProjectConfig; + use codex_core::protocol::AskForApproval; + use codex_protocol::protocol::RolloutItem; + use codex_protocol::protocol::RolloutLine; + use codex_protocol::protocol::SessionMeta; + use codex_protocol::protocol::SessionMetaLine; + use codex_protocol::protocol::TurnContextItem; use serial_test::serial; use tempfile::TempDir; @@ -846,4 +935,180 @@ mod tests { ); Ok(()) } + + fn build_turn_context(config: &Config, cwd: PathBuf) -> TurnContextItem { + let model = config + .model + .clone() + .unwrap_or_else(|| "gpt-5.1".to_string()); + TurnContextItem { + cwd, + approval_policy: config.approval_policy.value(), + sandbox_policy: config.sandbox_policy.get().clone(), + model, + personality: None, + collaboration_mode: None, + effort: config.model_reasoning_effort, + summary: config.model_reasoning_summary, + user_instructions: None, + developer_instructions: None, + final_output_json_schema: None, + truncation_policy: None, + } + } + + #[tokio::test] + async fn read_session_cwd_prefers_latest_turn_context() -> std::io::Result<()> { + let temp_dir = TempDir::new()?; + let config = build_config(&temp_dir).await?; + let first = temp_dir.path().join("first"); + let second = temp_dir.path().join("second"); + std::fs::create_dir_all(&first)?; + std::fs::create_dir_all(&second)?; + + let rollout_path = temp_dir.path().join("rollout.jsonl"); + let lines = vec![ + RolloutLine { + timestamp: "t0".to_string(), + item: RolloutItem::TurnContext(build_turn_context(&config, first)), + }, + RolloutLine { + timestamp: "t1".to_string(), + item: RolloutItem::TurnContext(build_turn_context(&config, second.clone())), + }, + ]; + let mut text = String::new(); + for line in lines { + text.push_str(&serde_json::to_string(&line).expect("serialize rollout")); + text.push('\n'); + } + std::fs::write(&rollout_path, text)?; + + let cwd = read_session_cwd(&rollout_path).await.expect("expected cwd"); + assert_eq!(cwd, second); + Ok(()) + } + + #[tokio::test] + async fn should_prompt_when_meta_matches_current_but_latest_turn_differs() -> std::io::Result<()> + { + let temp_dir = TempDir::new()?; + let config = build_config(&temp_dir).await?; + let current = temp_dir.path().join("current"); + let latest = temp_dir.path().join("latest"); + std::fs::create_dir_all(¤t)?; + std::fs::create_dir_all(&latest)?; + + let rollout_path = temp_dir.path().join("rollout.jsonl"); + let session_meta = SessionMeta { + cwd: current.clone(), + ..SessionMeta::default() + }; + let lines = vec![ + RolloutLine { + timestamp: "t0".to_string(), + item: RolloutItem::SessionMeta(SessionMetaLine { + meta: session_meta, + git: None, + }), + }, + RolloutLine { + timestamp: "t1".to_string(), + item: RolloutItem::TurnContext(build_turn_context(&config, latest.clone())), + }, + ]; + let mut text = String::new(); + for line in lines { + text.push_str(&serde_json::to_string(&line).expect("serialize rollout")); + text.push('\n'); + } + std::fs::write(&rollout_path, text)?; + + let session_cwd = read_session_cwd(&rollout_path).await.expect("expected cwd"); + assert_eq!(session_cwd, latest); + assert!(cwds_differ(¤t, &session_cwd)); + Ok(()) + } + + #[tokio::test] + async fn config_rebuild_changes_trust_defaults_with_cwd() -> std::io::Result<()> { + let temp_dir = TempDir::new()?; + let codex_home = temp_dir.path().to_path_buf(); + let trusted = temp_dir.path().join("trusted"); + let untrusted = temp_dir.path().join("untrusted"); + std::fs::create_dir_all(&trusted)?; + std::fs::create_dir_all(&untrusted)?; + + // TOML keys need escaped backslashes on Windows paths. + let trusted_display = trusted.display().to_string().replace('\\', "\\\\"); + let untrusted_display = untrusted.display().to_string().replace('\\', "\\\\"); + let config_toml = format!( + r#"[projects."{trusted_display}"] +trust_level = "trusted" + +[projects."{untrusted_display}"] +trust_level = "untrusted" +"# + ); + std::fs::write(temp_dir.path().join("config.toml"), config_toml)?; + + let trusted_overrides = ConfigOverrides { + cwd: Some(trusted.clone()), + ..Default::default() + }; + let trusted_config = ConfigBuilder::default() + .codex_home(codex_home.clone()) + .harness_overrides(trusted_overrides.clone()) + .build() + .await?; + assert_eq!( + trusted_config.approval_policy.value(), + AskForApproval::OnRequest + ); + + let untrusted_overrides = ConfigOverrides { + cwd: Some(untrusted), + ..trusted_overrides + }; + let untrusted_config = ConfigBuilder::default() + .codex_home(codex_home) + .harness_overrides(untrusted_overrides) + .build() + .await?; + assert_eq!( + untrusted_config.approval_policy.value(), + AskForApproval::UnlessTrusted + ); + Ok(()) + } + + #[tokio::test] + async fn read_session_cwd_falls_back_to_session_meta() -> std::io::Result<()> { + let temp_dir = TempDir::new()?; + let _config = build_config(&temp_dir).await?; + let session_cwd = temp_dir.path().join("session"); + std::fs::create_dir_all(&session_cwd)?; + + let rollout_path = temp_dir.path().join("rollout.jsonl"); + let session_meta = SessionMeta { + cwd: session_cwd.clone(), + ..SessionMeta::default() + }; + let meta_line = RolloutLine { + timestamp: "t0".to_string(), + item: RolloutItem::SessionMeta(SessionMetaLine { + meta: session_meta, + git: None, + }), + }; + let text = format!( + "{}\n", + serde_json::to_string(&meta_line).expect("serialize meta") + ); + std::fs::write(&rollout_path, text)?; + + let cwd = read_session_cwd(&rollout_path).await.expect("expected cwd"); + assert_eq!(cwd, session_cwd); + Ok(()) + } } diff --git a/codex-rs/tui/src/snapshots/codex_tui__cwd_prompt__tests__cwd_prompt_fork_modal.snap b/codex-rs/tui/src/snapshots/codex_tui__cwd_prompt__tests__cwd_prompt_fork_modal.snap new file mode 100644 index 0000000000..38b712e872 --- /dev/null +++ b/codex-rs/tui/src/snapshots/codex_tui__cwd_prompt__tests__cwd_prompt_fork_modal.snap @@ -0,0 +1,14 @@ +--- +source: tui/src/cwd_prompt.rs +expression: terminal.backend() +--- + +Choose working directory to fork this session + + Session = latest cwd recorded in the forked session + Current = your current working directory + +› 1. Use session directory (/Users/example/session) + 2. Use current directory (/Users/example/current) + + Press enter to continue diff --git a/codex-rs/tui/src/snapshots/codex_tui__cwd_prompt__tests__cwd_prompt_modal.snap b/codex-rs/tui/src/snapshots/codex_tui__cwd_prompt__tests__cwd_prompt_modal.snap new file mode 100644 index 0000000000..4e87cfdfc7 --- /dev/null +++ b/codex-rs/tui/src/snapshots/codex_tui__cwd_prompt__tests__cwd_prompt_modal.snap @@ -0,0 +1,14 @@ +--- +source: tui/src/cwd_prompt.rs +expression: terminal.backend() +--- + +Choose working directory to resume this session + + Session = latest cwd recorded in the resumed session + Current = your current working directory + +› 1. Use session directory (/Users/example/session) + 2. Use current directory (/Users/example/current) + + Press enter to continue From 24230c066b98989a51ee3f1eb14960ed36f51166 Mon Sep 17 00:00:00 2001 From: Ahmed Ibrahim Date: Sat, 24 Jan 2026 22:58:56 -0800 Subject: [PATCH 30/36] Revert "fix: libcc link" (#9841) Reverts openai/codex#9819 --- codex-rs/Cargo.toml | 2 +- codex-rs/responses-api-proxy/Cargo.toml | 6 +----- 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/codex-rs/Cargo.toml b/codex-rs/Cargo.toml index ef16a94291..b39cfd5e15 100644 --- a/codex-rs/Cargo.toml +++ b/codex-rs/Cargo.toml @@ -181,7 +181,7 @@ ratatui = "0.29.0" ratatui-macros = "0.6.0" regex = "1.12.2" regex-lite = "0.1.8" -reqwest = { version = "0.12", default-features = false, features = ["rustls-tls"] } +reqwest = "0.12" rmcp = { version = "0.12.0", default-features = false } schemars = "0.8.22" seccompiler = "0.5.0" diff --git a/codex-rs/responses-api-proxy/Cargo.toml b/codex-rs/responses-api-proxy/Cargo.toml index 486e08c224..e0ea600033 100644 --- a/codex-rs/responses-api-proxy/Cargo.toml +++ b/codex-rs/responses-api-proxy/Cargo.toml @@ -21,11 +21,7 @@ clap = { workspace = true, features = ["derive"] } codex-process-hardening = { workspace = true } ctor = { workspace = true } libc = { workspace = true } -reqwest = { workspace = true, features = [ - "blocking", - "json", - "rustls-tls", -] } +reqwest = { workspace = true, features = ["blocking", "json", "rustls-tls"] } serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } tiny_http = { workspace = true } From 58450ba2a1d7c0d2d5405ae5f1f99fcf09e1cfe4 Mon Sep 17 00:00:00 2001 From: Ahmed Ibrahim Date: Sat, 24 Jan 2026 23:35:31 -0800 Subject: [PATCH 31/36] Use collaboration mode masks without mutating base settings (#9806) Keep an unmasked base collaboration mode and apply the active mask on demand. Simplify the TUI mask helpers and update tests/docs to match the mask contract. --- .../app-server-protocol/src/protocol/v2.rs | 3 +- .../tests/suite/v2/collaboration_mode_list.rs | 32 +- .../collaboration_mode_presets.rs | 67 ++-- codex-rs/core/src/models_manager/manager.rs | 4 +- codex-rs/core/src/thread_manager.rs | 4 +- codex-rs/docs/codex_mcp_interface.md | 3 +- codex-rs/protocol/src/config_types.rs | 72 +++- codex-rs/tui/src/app.rs | 6 +- codex-rs/tui/src/app_event.rs | 10 +- codex-rs/tui/src/chatwidget.rs | 337 ++++++++++-------- codex-rs/tui/src/chatwidget/tests.rs | 199 +++++++---- codex-rs/tui/src/collaboration_modes.rs | 45 +-- 12 files changed, 472 insertions(+), 310 deletions(-) diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index 9eddf28148..e282e924fc 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -5,6 +5,7 @@ use crate::protocol::common::AuthMode; use codex_protocol::account::PlanType; use codex_protocol::approvals::ExecPolicyAmendment as CoreExecPolicyAmendment; use codex_protocol::config_types::CollaborationMode; +use codex_protocol::config_types::CollaborationModeMask; use codex_protocol::config_types::ForcedLoginMethod; use codex_protocol::config_types::Personality; use codex_protocol::config_types::ReasoningSummary; @@ -937,7 +938,7 @@ pub struct CollaborationModeListParams {} #[serde(rename_all = "camelCase")] #[ts(export_to = "v2/")] pub struct CollaborationModeListResponse { - pub data: Vec, + pub data: Vec, } #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] diff --git a/codex-rs/app-server/tests/suite/v2/collaboration_mode_list.rs b/codex-rs/app-server/tests/suite/v2/collaboration_mode_list.rs index cb9ff3e18e..7d25810261 100644 --- a/codex-rs/app-server/tests/suite/v2/collaboration_mode_list.rs +++ b/codex-rs/app-server/tests/suite/v2/collaboration_mode_list.rs @@ -16,7 +16,7 @@ use codex_app_server_protocol::CollaborationModeListResponse; use codex_app_server_protocol::JSONRPCResponse; use codex_app_server_protocol::RequestId; use codex_core::models_manager::test_builtin_collaboration_mode_presets; -use codex_protocol::config_types::CollaborationMode; +use codex_protocol::config_types::CollaborationModeMask; use codex_protocol::config_types::ModeKind; use pretty_assertions::assert_eq; use tempfile::TempDir; @@ -45,13 +45,23 @@ async fn list_collaboration_modes_returns_presets() -> Result<()> { let CollaborationModeListResponse { data: items } = to_response::(response)?; - let expected = vec![ + let expected = [ plan_preset(), code_preset(), pair_programming_preset(), execute_preset(), ]; - assert_eq!(expected, items); + assert_eq!(expected.len(), items.len()); + for (expected_mask, actual_mask) in expected.iter().zip(items.iter()) { + assert_eq!(expected_mask.name, actual_mask.name); + assert_eq!(expected_mask.mode, actual_mask.mode); + assert_eq!(expected_mask.model, actual_mask.model); + assert_eq!(expected_mask.reasoning_effort, actual_mask.reasoning_effort); + assert_eq!( + expected_mask.developer_instructions, + actual_mask.developer_instructions + ); + } Ok(()) } @@ -59,11 +69,11 @@ async fn list_collaboration_modes_returns_presets() -> Result<()> { /// /// If the defaults change in the app server, this helper should be updated alongside the /// contract, or the test will fail in ways that imply a regression in the API. -fn plan_preset() -> CollaborationMode { +fn plan_preset() -> CollaborationModeMask { let presets = test_builtin_collaboration_mode_presets(); presets .into_iter() - .find(|p| p.mode == ModeKind::Plan) + .find(|p| p.mode == Some(ModeKind::Plan)) .unwrap() } @@ -71,20 +81,20 @@ fn plan_preset() -> CollaborationMode { /// /// The helper keeps the expected model and reasoning defaults co-located with the test /// so that mismatches point directly at the API contract being exercised. -fn pair_programming_preset() -> CollaborationMode { +fn pair_programming_preset() -> CollaborationModeMask { let presets = test_builtin_collaboration_mode_presets(); presets .into_iter() - .find(|p| p.mode == ModeKind::PairProgramming) + .find(|p| p.mode == Some(ModeKind::PairProgramming)) .unwrap() } /// Builds the code preset that the list response is expected to return. -fn code_preset() -> CollaborationMode { +fn code_preset() -> CollaborationModeMask { let presets = test_builtin_collaboration_mode_presets(); presets .into_iter() - .find(|p| p.mode == ModeKind::Code) + .find(|p| p.mode == Some(ModeKind::Code)) .unwrap() } @@ -92,10 +102,10 @@ fn code_preset() -> CollaborationMode { /// /// The execute preset uses a different reasoning effort to capture the higher-effort /// execution contract the server currently exposes. -fn execute_preset() -> CollaborationMode { +fn execute_preset() -> CollaborationModeMask { let presets = test_builtin_collaboration_mode_presets(); presets .into_iter() - .find(|p| p.mode == ModeKind::Execute) + .find(|p| p.mode == Some(ModeKind::Execute)) .unwrap() } diff --git a/codex-rs/core/src/models_manager/collaboration_mode_presets.rs b/codex-rs/core/src/models_manager/collaboration_mode_presets.rs index ac1334c56c..b7efe156f1 100644 --- a/codex-rs/core/src/models_manager/collaboration_mode_presets.rs +++ b/codex-rs/core/src/models_manager/collaboration_mode_presets.rs @@ -1,6 +1,5 @@ -use codex_protocol::config_types::CollaborationMode; +use codex_protocol::config_types::CollaborationModeMask; use codex_protocol::config_types::ModeKind; -use codex_protocol::config_types::Settings; use codex_protocol::openai_models::ReasoningEffort; const COLLABORATION_MODE_PLAN: &str = include_str!("../../templates/collaboration_mode/plan.md"); @@ -10,7 +9,7 @@ const COLLABORATION_MODE_PAIR_PROGRAMMING: &str = const COLLABORATION_MODE_EXECUTE: &str = include_str!("../../templates/collaboration_mode/execute.md"); -pub(super) fn builtin_collaboration_mode_presets() -> Vec { +pub(super) fn builtin_collaboration_mode_presets() -> Vec { vec![ plan_preset(), code_preset(), @@ -20,50 +19,46 @@ pub(super) fn builtin_collaboration_mode_presets() -> Vec { } #[cfg(any(test, feature = "test-support"))] -pub fn test_builtin_collaboration_mode_presets() -> Vec { +pub fn test_builtin_collaboration_mode_presets() -> Vec { builtin_collaboration_mode_presets() } -fn plan_preset() -> CollaborationMode { - CollaborationMode { - mode: ModeKind::Plan, - settings: Settings { - model: "gpt-5.2-codex".to_string(), - reasoning_effort: Some(ReasoningEffort::High), - developer_instructions: Some(COLLABORATION_MODE_PLAN.to_string()), - }, +fn plan_preset() -> CollaborationModeMask { + CollaborationModeMask { + name: "Plan".to_string(), + mode: Some(ModeKind::Plan), + model: Some("gpt-5.2-codex".to_string()), + reasoning_effort: Some(Some(ReasoningEffort::High)), + developer_instructions: Some(Some(COLLABORATION_MODE_PLAN.to_string())), } } -fn code_preset() -> CollaborationMode { - CollaborationMode { - mode: ModeKind::Code, - settings: Settings { - model: "gpt-5.2-codex".to_string(), - reasoning_effort: Some(ReasoningEffort::Medium), - developer_instructions: Some(COLLABORATION_MODE_CODE.to_string()), - }, +fn code_preset() -> CollaborationModeMask { + CollaborationModeMask { + name: "Code".to_string(), + mode: Some(ModeKind::Code), + model: Some("gpt-5.2-codex".to_string()), + reasoning_effort: Some(Some(ReasoningEffort::Medium)), + developer_instructions: Some(Some(COLLABORATION_MODE_CODE.to_string())), } } -fn pair_programming_preset() -> CollaborationMode { - CollaborationMode { - mode: ModeKind::PairProgramming, - settings: Settings { - model: "gpt-5.2-codex".to_string(), - reasoning_effort: Some(ReasoningEffort::Medium), - developer_instructions: Some(COLLABORATION_MODE_PAIR_PROGRAMMING.to_string()), - }, +fn pair_programming_preset() -> CollaborationModeMask { + CollaborationModeMask { + name: "Pair Programming".to_string(), + mode: Some(ModeKind::PairProgramming), + model: Some("gpt-5.2-codex".to_string()), + reasoning_effort: Some(Some(ReasoningEffort::Medium)), + developer_instructions: Some(Some(COLLABORATION_MODE_PAIR_PROGRAMMING.to_string())), } } -fn execute_preset() -> CollaborationMode { - CollaborationMode { - mode: ModeKind::Execute, - settings: Settings { - model: "gpt-5.2-codex".to_string(), - reasoning_effort: Some(ReasoningEffort::High), - developer_instructions: Some(COLLABORATION_MODE_EXECUTE.to_string()), - }, +fn execute_preset() -> CollaborationModeMask { + CollaborationModeMask { + name: "Execute".to_string(), + mode: Some(ModeKind::Execute), + model: Some("gpt-5.2-codex".to_string()), + reasoning_effort: Some(Some(ReasoningEffort::High)), + developer_instructions: Some(Some(COLLABORATION_MODE_EXECUTE.to_string())), } } diff --git a/codex-rs/core/src/models_manager/manager.rs b/codex-rs/core/src/models_manager/manager.rs index efa60507ea..58049b4747 100644 --- a/codex-rs/core/src/models_manager/manager.rs +++ b/codex-rs/core/src/models_manager/manager.rs @@ -14,7 +14,7 @@ use crate::models_manager::model_presets::builtin_model_presets; use codex_api::ModelsClient; use codex_api::ReqwestTransport; use codex_app_server_protocol::AuthMode; -use codex_protocol::config_types::CollaborationMode; +use codex_protocol::config_types::CollaborationModeMask; use codex_protocol::openai_models::ModelInfo; use codex_protocol::openai_models::ModelPreset; use codex_protocol::openai_models::ModelsResponse; @@ -91,7 +91,7 @@ impl ModelsManager { /// List collaboration mode presets. /// /// Returns a static set of presets seeded with the configured model. - pub fn list_collaboration_modes(&self) -> Vec { + pub fn list_collaboration_modes(&self) -> Vec { builtin_collaboration_mode_presets() } diff --git a/codex-rs/core/src/thread_manager.rs b/codex-rs/core/src/thread_manager.rs index fac08cb3f2..bc5cd4f19e 100644 --- a/codex-rs/core/src/thread_manager.rs +++ b/codex-rs/core/src/thread_manager.rs @@ -19,7 +19,7 @@ use crate::rollout::RolloutRecorder; use crate::rollout::truncation; use crate::skills::SkillsManager; use codex_protocol::ThreadId; -use codex_protocol::config_types::CollaborationMode; +use codex_protocol::config_types::CollaborationModeMask; use codex_protocol::openai_models::ModelPreset; use codex_protocol::protocol::InitialHistory; use codex_protocol::protocol::McpServerRefreshConfig; @@ -158,7 +158,7 @@ impl ThreadManager { .await } - pub fn list_collaboration_modes(&self) -> Vec { + pub fn list_collaboration_modes(&self) -> Vec { self.state.models_manager.list_collaboration_modes() } diff --git a/codex-rs/docs/codex_mcp_interface.md b/codex-rs/docs/codex_mcp_interface.md index 19e1d91581..1aa5aa12ae 100644 --- a/codex-rs/docs/codex_mcp_interface.md +++ b/codex-rs/docs/codex_mcp_interface.md @@ -101,7 +101,8 @@ Each response yields: Fetch the built-in collaboration mode presets with `collaborationMode/list`. This endpoint does not accept pagination and returns the full list in one response: -- `data` – ordered list of collaboration mode presets +- `data` – ordered list of collaboration mode masks (partial settings to apply on top of the base mode) + - For tri-state fields like `reasoning_effort` and `developer_instructions`, omit the field to keep the current value, set it to `null` to clear it, or set a concrete value to update it. ## Event stream diff --git a/codex-rs/protocol/src/config_types.rs b/codex-rs/protocol/src/config_types.rs index 444901bf75..ae956797ea 100644 --- a/codex-rs/protocol/src/config_types.rs +++ b/codex-rs/protocol/src/config_types.rs @@ -188,21 +188,21 @@ impl CollaborationMode { /// /// - `model`: `Some(s)` to update the model, `None` to keep the current model /// - `effort`: `Some(Some(e))` to set effort to `e`, `Some(None)` to clear effort, `None` to keep current effort - /// - `developer_instructions`: `Some(s)` to update developer instructions, `None` to keep current + /// - `developer_instructions`: `Some(Some(s))` to set instructions, `Some(None)` to clear them, `None` to keep current /// /// Returns a new `CollaborationMode` with updated values, preserving the mode. pub fn with_updates( &self, model: Option, effort: Option>, - developer_instructions: Option, + developer_instructions: Option>, ) -> Self { let settings = self.settings_ref(); let updated_settings = Settings { model: model.unwrap_or_else(|| settings.model.clone()), reasoning_effort: effort.unwrap_or(settings.reasoning_effort), developer_instructions: developer_instructions - .or_else(|| settings.developer_instructions.clone()), + .unwrap_or_else(|| settings.developer_instructions.clone()), }; CollaborationMode { @@ -210,6 +210,26 @@ impl CollaborationMode { settings: updated_settings, } } + + /// Applies a mask to this collaboration mode, returning a new collaboration mode + /// with the mask values applied. Fields in the mask that are `Some` will override + /// the corresponding fields, while `None` values will preserve the original values. + /// + /// The `name` field in the mask is ignored as it's metadata for the mask itself. + pub fn apply_mask(&self, mask: &CollaborationModeMask) -> Self { + let settings = self.settings_ref(); + CollaborationMode { + mode: mask.mode.unwrap_or(self.mode), + settings: Settings { + model: mask.model.clone().unwrap_or_else(|| settings.model.clone()), + reasoning_effort: mask.reasoning_effort.unwrap_or(settings.reasoning_effort), + developer_instructions: mask + .developer_instructions + .clone() + .unwrap_or_else(|| settings.developer_instructions.clone()), + }, + } + } } /// Settings for a collaboration mode. @@ -219,3 +239,49 @@ pub struct Settings { pub reasoning_effort: Option, pub developer_instructions: Option, } + +/// A mask for collaboration mode settings, allowing partial updates. +/// All fields except `name` are optional, enabling selective updates. +#[derive(Clone, PartialEq, Eq, Hash, Debug, Serialize, Deserialize, JsonSchema, TS)] +pub struct CollaborationModeMask { + pub name: String, + pub mode: Option, + pub model: Option, + pub reasoning_effort: Option>, + pub developer_instructions: Option>, +} + +#[cfg(test)] +mod tests { + use super::*; + use pretty_assertions::assert_eq; + + #[test] + fn apply_mask_can_clear_optional_fields() { + let mode = CollaborationMode { + mode: ModeKind::Code, + settings: Settings { + model: "gpt-5.2-codex".to_string(), + reasoning_effort: Some(ReasoningEffort::High), + developer_instructions: Some("stay focused".to_string()), + }, + }; + let mask = CollaborationModeMask { + name: "Clear".to_string(), + mode: None, + model: None, + reasoning_effort: Some(None), + developer_instructions: Some(None), + }; + + let expected = CollaborationMode { + mode: ModeKind::Code, + settings: Settings { + model: "gpt-5.2-codex".to_string(), + reasoning_effort: None, + developer_instructions: None, + }, + }; + assert_eq!(expected, mode.apply_mask(&mask)); + } +} diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index fb8240973a..1d53f5d70d 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -1502,10 +1502,8 @@ impl App { AppEvent::UpdateModel(model) => { self.chat_widget.set_model(&model); } - AppEvent::UpdateCollaborationMode(mode) => { - let model = mode.model().to_string(); - self.chat_widget.set_collaboration_mode(mode); - self.chat_widget.set_model(&model); + AppEvent::UpdateCollaborationMode(mask) => { + self.chat_widget.set_collaboration_mask(mask); } AppEvent::OpenReasoningPopup { model } => { self.chat_widget.open_reasoning_popup(model); diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs index f6ed6e8abc..9a2d979f1c 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -23,7 +23,7 @@ use crate::history_cell::HistoryCell; use codex_core::features::Feature; use codex_core::protocol::AskForApproval; use codex_core::protocol::SandboxPolicy; -use codex_protocol::config_types::CollaborationMode; +use codex_protocol::config_types::CollaborationModeMask; use codex_protocol::openai_models::ReasoningEffort; #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -103,8 +103,8 @@ pub(crate) enum AppEvent { /// Update the current model slug in the running app and widget. UpdateModel(String), - /// Update the current collaboration mode in the running app and widget. - UpdateCollaborationMode(CollaborationMode), + /// Update the active collaboration mask in the running app and widget. + UpdateCollaborationMode(CollaborationModeMask), /// Persist the selected model and reasoning effort to the appropriate config. PersistModelSelection { @@ -240,10 +240,10 @@ pub(crate) enum AppEvent { /// Open the custom prompt option from the review popup. OpenReviewCustomPrompt, - /// Submit a user message with an explicit collaboration mode. + /// Submit a user message with an explicit collaboration mask. SubmitUserMessageWithMode { text: String, - collaboration_mode: CollaborationMode, + collaboration_mode: CollaborationModeMask, }, /// Open the approval popup. diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index a0f5821d65..1252b792e3 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -93,6 +93,7 @@ use codex_protocol::ThreadId; use codex_protocol::account::PlanType; use codex_protocol::approvals::ElicitationRequestEvent; use codex_protocol::config_types::CollaborationMode; +use codex_protocol::config_types::CollaborationModeMask; use codex_protocol::config_types::ModeKind; use codex_protocol::config_types::Settings; use codex_protocol::models::local_image_label_text; @@ -415,12 +416,12 @@ pub(crate) struct ChatWidget { /// where the overlay may briefly treat new tail content as already cached. active_cell_revision: u64, config: Config, - /// Stored collaboration mode with model and reasoning effort. + /// The unmasked collaboration mode settings (always Custom mode). /// - /// When collaboration modes feature is enabled, this is initialized to the first preset. - /// When disabled, this is Custom. The model and reasoning effort are stored here instead of - /// being read from config or current_model. - stored_collaboration_mode: CollaborationMode, + /// Masks are applied on top of this base mode to derive the effective mode. + current_collaboration_mode: CollaborationMode, + /// The currently active collaboration mask, if any. + active_collaboration_mask: Option, auth_manager: Arc, models_manager: Arc, otel_manager: OtelManager, @@ -731,16 +732,12 @@ impl ChatWidget { let initial_messages = event.initial_messages.clone(); let model_for_header = event.model.clone(); self.session_header.set_model(&model_for_header); - // Only update stored collaboration settings when collaboration modes are disabled. - // When enabled, we preserve the selected variant (Plan/Pair/Execute/Custom) and its - // instructions as-is; the session configured event should not override it. - if !self.collaboration_modes_enabled() { - self.stored_collaboration_mode = self.stored_collaboration_mode.with_updates( - Some(model_for_header.clone()), - Some(event.reasoning_effort), - None, - ); - } + self.current_collaboration_mode = self.current_collaboration_mode.with_updates( + Some(model_for_header.clone()), + Some(event.reasoning_effort), + None, + ); + self.refresh_model_display(); let session_info_cell = history_cell::new_session_info( &self.config, &model_for_header, @@ -910,7 +907,7 @@ impl ChatWidget { if !self.queued_user_messages.is_empty() { return; } - if self.stored_collaboration_mode.mode != ModeKind::Plan { + if self.active_mode_kind() != ModeKind::Plan { return; } let has_message = last_agent_message.is_some_and(|message| !message.trim().is_empty()); @@ -932,14 +929,14 @@ impl ChatWidget { } fn open_plan_implementation_prompt(&mut self) { - let code_mode = collaboration_modes::code_mode(self.models_manager.as_ref()); - let (implement_actions, implement_disabled_reason) = match code_mode { - Some(collaboration_mode) => { + let code_mask = collaboration_modes::code_mask(self.models_manager.as_ref()); + let (implement_actions, implement_disabled_reason) = match code_mask { + Some(mask) => { let user_text = PLAN_IMPLEMENTATION_CODING_MESSAGE.to_string(); let actions: Vec = vec![Box::new(move |tx| { tx.send(AppEvent::SubmitUserMessageWithMode { text: user_text.clone(), - collaboration_mode: collaboration_mode.clone(), + collaboration_mode: mask.clone(), }); })]; (actions, None) @@ -1930,23 +1927,25 @@ impl ChatWidget { let placeholder = PLACEHOLDERS[rng.random_range(0..PLACEHOLDERS.len())].to_string(); let codex_op_tx = spawn_agent(config.clone(), app_event_tx.clone(), thread_manager); - let model_for_header = model.unwrap_or_else(|| DEFAULT_MODEL_DISPLAY_NAME.to_string()); + let model_override = model.as_deref(); + let model_for_header = model + .clone() + .unwrap_or_else(|| DEFAULT_MODEL_DISPLAY_NAME.to_string()); + let active_collaboration_mask = + Self::initial_collaboration_mask(&config, models_manager.as_ref(), model_override); + let header_model = active_collaboration_mask + .as_ref() + .and_then(|mask| mask.model.clone()) + .unwrap_or_else(|| model_for_header.clone()); let fallback_custom = Settings { - model: model_for_header.clone(), + model: header_model.clone(), reasoning_effort: None, developer_instructions: None, }; - let stored_collaboration_mode = if config.features.enabled(Feature::CollaborationModes) { - initial_collaboration_mode( - models_manager.as_ref(), - fallback_custom, - config.experimental_mode, - ) - } else { - CollaborationMode { - mode: ModeKind::Custom, - settings: fallback_custom, - } + // Collaboration modes start in Custom mode (not activated). + let current_collaboration_mode = CollaborationMode { + mode: ModeKind::Custom, + settings: fallback_custom, }; let active_cell = Some(Self::placeholder_session_header_cell(&config)); @@ -1970,11 +1969,12 @@ impl ChatWidget { config, skills_all: Vec::new(), skills_initial_state: None, - stored_collaboration_mode, + current_collaboration_mode, + active_collaboration_mask, auth_manager, models_manager, otel_manager, - session_header: SessionHeader::new(model_for_header), + session_header: SessionHeader::new(header_model), initial_user_message, token_info: None, rate_limit_snapshot: None, @@ -2051,23 +2051,25 @@ impl ChatWidget { let mut rng = rand::rng(); let placeholder = PLACEHOLDERS[rng.random_range(0..PLACEHOLDERS.len())].to_string(); - let model_for_header = model.unwrap_or_else(|| DEFAULT_MODEL_DISPLAY_NAME.to_string()); + let model_override = model.as_deref(); + let model_for_header = model + .clone() + .unwrap_or_else(|| DEFAULT_MODEL_DISPLAY_NAME.to_string()); + let active_collaboration_mask = + Self::initial_collaboration_mask(&config, models_manager.as_ref(), model_override); + let header_model = active_collaboration_mask + .as_ref() + .and_then(|mask| mask.model.clone()) + .unwrap_or_else(|| model_for_header.clone()); let fallback_custom = Settings { - model: model_for_header.clone(), + model: header_model.clone(), reasoning_effort: None, developer_instructions: None, }; - let stored_collaboration_mode = if config.features.enabled(Feature::CollaborationModes) { - initial_collaboration_mode( - models_manager.as_ref(), - fallback_custom, - config.experimental_mode, - ) - } else { - CollaborationMode { - mode: ModeKind::Custom, - settings: fallback_custom, - } + // Collaboration modes start in Custom mode (not activated). + let current_collaboration_mode = CollaborationMode { + mode: ModeKind::Custom, + settings: fallback_custom, }; let active_cell = Some(Self::placeholder_session_header_cell(&config)); @@ -2091,11 +2093,12 @@ impl ChatWidget { config, skills_all: Vec::new(), skills_initial_state: None, - stored_collaboration_mode, + current_collaboration_mode, + active_collaboration_mask, auth_manager, models_manager, otel_manager, - session_header: SessionHeader::new(model_for_header), + session_header: SessionHeader::new(header_model), initial_user_message, token_info: None, rate_limit_snapshot: None, @@ -2171,7 +2174,16 @@ impl ChatWidget { let mut rng = rand::rng(); let placeholder = PLACEHOLDERS[rng.random_range(0..PLACEHOLDERS.len())].to_string(); - let header_model = model.unwrap_or_else(|| session_configured.model.clone()); + let model_override = model.as_deref(); + let header_model = model + .clone() + .unwrap_or_else(|| session_configured.model.clone()); + let active_collaboration_mask = + Self::initial_collaboration_mask(&config, models_manager.as_ref(), model_override); + let header_model = active_collaboration_mask + .as_ref() + .and_then(|mask| mask.model.clone()) + .unwrap_or(header_model); let codex_op_tx = spawn_agent_from_existing(conversation, session_configured, app_event_tx.clone()); @@ -2181,17 +2193,10 @@ impl ChatWidget { reasoning_effort: None, developer_instructions: None, }; - let stored_collaboration_mode = if config.features.enabled(Feature::CollaborationModes) { - initial_collaboration_mode( - models_manager.as_ref(), - fallback_custom, - config.experimental_mode, - ) - } else { - CollaborationMode { - mode: ModeKind::Custom, - settings: fallback_custom, - } + // Collaboration modes start in Custom mode (not activated). + let current_collaboration_mode = CollaborationMode { + mode: ModeKind::Custom, + settings: fallback_custom, }; let mut widget = Self { @@ -2213,7 +2218,8 @@ impl ChatWidget { config, skills_all: Vec::new(), skills_initial_state: None, - stored_collaboration_mode, + current_collaboration_mode, + active_collaboration_mask, auth_manager, models_manager, otel_manager, @@ -2816,18 +2822,24 @@ impl ChatWidget { } } + let effective_mode = self.effective_collaboration_mode(); + let collaboration_mode = if self.collaboration_modes_enabled() { + self.active_collaboration_mask + .as_ref() + .map(|_| effective_mode.clone()) + } else { + None + }; let op = Op::UserTurn { items, cwd: self.config.cwd.clone(), approval_policy: self.config.approval_policy.value(), sandbox_policy: self.config.sandbox_policy.get().clone(), - model: self.stored_collaboration_mode.model().to_string(), - effort: self.stored_collaboration_mode.reasoning_effort(), + model: effective_mode.model().to_string(), + effort: effective_mode.reasoning_effort(), summary: self.config.model_reasoning_summary, final_output_json_schema: None, - collaboration_mode: self - .collaboration_modes_enabled() - .then(|| self.stored_collaboration_mode.clone()), + collaboration_mode, personality: None, }; @@ -3180,7 +3192,7 @@ impl ChatWidget { .map(|ti| &ti.total_token_usage) .unwrap_or(&default_usage); let collaboration_mode = self.collaboration_mode_label(); - let reasoning_effort_override = Some(self.stored_collaboration_mode.reasoning_effort()); + let reasoning_effort_override = Some(self.effective_reasoning_effort()); self.add_to_history(crate::status::new_status_output( &self.config, self.auth_manager.as_ref(), @@ -3558,23 +3570,24 @@ impl ChatWidget { return; } + let current_kind = self + .active_collaboration_mask + .as_ref() + .and_then(|mask| mask.mode) + .or_else(|| { + collaboration_modes::default_mask(self.models_manager.as_ref()) + .and_then(|mask| mask.mode) + }); let items: Vec = presets .into_iter() - .map(|preset| { - let name = match preset.mode { - ModeKind::Plan => "Plan", - ModeKind::Code => "Code", - ModeKind::PairProgramming => "Pair Programming", - ModeKind::Execute => "Execute", - ModeKind::Custom => "Custom", - }; - let is_current = - collaboration_modes::same_variant(&self.stored_collaboration_mode, &preset); + .map(|mask| { + let name = mask.name.clone(); + let is_current = current_kind == mask.mode; let actions: Vec = vec![Box::new(move |tx| { - tx.send(AppEvent::UpdateCollaborationMode(preset.clone())); + tx.send(AppEvent::UpdateCollaborationMode(mask.clone())); })]; SelectionItem { - name: name.to_string(), + name, is_current, actions, dismiss_on_select: true, @@ -3690,7 +3703,7 @@ impl ChatWidget { let model_slug = preset.model.to_string(); let is_current_model = self.current_model() == preset.model.as_str(); let highlight_choice = if is_current_model { - self.stored_collaboration_mode.reasoning_effort() + self.effective_reasoning_effort() } else { default_choice }; @@ -4548,21 +4561,15 @@ impl ChatWidget { } if feature == Feature::CollaborationModes { self.bottom_pane.set_collaboration_modes_enabled(enabled); - let settings = self.stored_collaboration_mode.settings.clone(); - let fallback_custom = settings.clone(); - self.stored_collaboration_mode = if enabled { - initial_collaboration_mode( - self.models_manager.as_ref(), - fallback_custom, - self.config.experimental_mode, - ) - } else { - CollaborationMode { - mode: ModeKind::Custom, - settings, - } + let settings = self.current_collaboration_mode.settings.clone(); + self.current_collaboration_mode = CollaborationMode { + mode: ModeKind::Custom, + settings, }; + self.active_collaboration_mask = None; self.update_collaboration_mode_indicator(); + self.refresh_model_display(); + self.request_redraw(); } } @@ -4591,31 +4598,52 @@ impl ChatWidget { /// Set the reasoning effort in the stored collaboration mode. pub(crate) fn set_reasoning_effort(&mut self, effort: Option) { - self.stored_collaboration_mode = - self.stored_collaboration_mode + self.current_collaboration_mode = + self.current_collaboration_mode .with_updates(None, Some(effort), None); + if self.collaboration_modes_enabled() + && let Some(mask) = self.active_collaboration_mask.as_mut() + { + mask.reasoning_effort = Some(effort); + } } /// Set the model in the widget's config copy and stored collaboration mode. pub(crate) fn set_model(&mut self, model: &str) { - self.session_header.set_model(model); - self.stored_collaboration_mode = - self.stored_collaboration_mode + self.current_collaboration_mode = + self.current_collaboration_mode .with_updates(Some(model.to_string()), None, None); + if self.collaboration_modes_enabled() + && let Some(mask) = self.active_collaboration_mask.as_mut() + { + mask.model = Some(model.to_string()); + } + self.refresh_model_display(); } pub(crate) fn current_model(&self) -> &str { - self.stored_collaboration_mode.model() + if !self.collaboration_modes_enabled() { + return self.current_collaboration_mode.model(); + } + self.active_collaboration_mask + .as_ref() + .and_then(|mask| mask.model.as_deref()) + .unwrap_or_else(|| self.current_collaboration_mode.model()) } #[allow(dead_code)] // Used in tests - pub(crate) fn stored_collaboration_mode(&self) -> &CollaborationMode { - &self.stored_collaboration_mode + pub(crate) fn current_collaboration_mode(&self) -> &CollaborationMode { + &self.current_collaboration_mode } #[cfg(test)] pub(crate) fn current_reasoning_effort(&self) -> Option { - self.stored_collaboration_mode.reasoning_effort() + self.effective_reasoning_effort() + } + + #[cfg(test)] + pub(crate) fn active_collaboration_mode_kind(&self) -> ModeKind { + self.active_mode_kind() } fn is_session_configured(&self) -> bool { @@ -4626,6 +4654,55 @@ impl ChatWidget { self.config.features.enabled(Feature::CollaborationModes) } + fn initial_collaboration_mask( + config: &Config, + models_manager: &ModelsManager, + model_override: Option<&str>, + ) -> Option { + if !config.features.enabled(Feature::CollaborationModes) { + return None; + } + let kind = config.experimental_mode?; + let mut mask = collaboration_modes::mask_for_kind(models_manager, kind)?; + if let Some(model_override) = model_override { + mask.model = Some(model_override.to_string()); + } + Some(mask) + } + + fn active_mode_kind(&self) -> ModeKind { + self.active_collaboration_mask + .as_ref() + .and_then(|mask| mask.mode) + .unwrap_or(ModeKind::Custom) + } + + fn effective_reasoning_effort(&self) -> Option { + if !self.collaboration_modes_enabled() { + return self.current_collaboration_mode.reasoning_effort(); + } + let current_effort = self.current_collaboration_mode.reasoning_effort(); + self.active_collaboration_mask + .as_ref() + .and_then(|mask| mask.reasoning_effort) + .unwrap_or(current_effort) + } + + fn effective_collaboration_mode(&self) -> CollaborationMode { + if !self.collaboration_modes_enabled() { + return self.current_collaboration_mode.clone(); + } + self.active_collaboration_mask.as_ref().map_or_else( + || self.current_collaboration_mode.clone(), + |mask| self.current_collaboration_mode.apply_mask(mask), + ) + } + + fn refresh_model_display(&mut self) { + let effective = self.effective_collaboration_mode(); + self.session_header.set_model(effective.model()); + } + fn model_display_name(&self) -> &str { let model = self.current_model(); if model.is_empty() { @@ -4640,7 +4717,7 @@ impl ChatWidget { if !self.collaboration_modes_enabled() { return None; } - match self.stored_collaboration_mode.mode { + match self.active_mode_kind() { ModeKind::Plan => Some("Plan"), ModeKind::Code => Some("Code"), ModeKind::PairProgramming => Some("Pair Programming"), @@ -4653,7 +4730,7 @@ impl ChatWidget { if !self.collaboration_modes_enabled() { return None; } - match self.stored_collaboration_mode.mode { + match self.active_mode_kind() { ModeKind::Plan => Some(CollaborationModeIndicator::Plan), ModeKind::Code => Some(CollaborationModeIndicator::Code), ModeKind::PairProgramming => Some(CollaborationModeIndicator::PairProgramming), @@ -4673,26 +4750,25 @@ impl ChatWidget { return; } - if let Some(next_mode) = collaboration_modes::next_mode( + if let Some(next_mask) = collaboration_modes::next_mask( self.models_manager.as_ref(), - &self.stored_collaboration_mode, + self.active_collaboration_mask.as_ref(), ) { - self.set_collaboration_mode(next_mode); + self.set_collaboration_mask(next_mask); } } - /// Update the stored collaboration mode. + /// Update the active collaboration mask. /// - /// When collaboration modes are enabled, the current mode is attached to *every* - /// submission as `Op::UserTurn { collaboration_mode: Some(...) }`. - pub(crate) fn set_collaboration_mode(&mut self, mode: CollaborationMode) { + /// When collaboration modes are enabled and a preset is selected (not Custom), + /// the current mode is attached to submissions as `Op::UserTurn { collaboration_mode: Some(...) }`. + pub(crate) fn set_collaboration_mask(&mut self, mask: CollaborationModeMask) { if !self.collaboration_modes_enabled() { return; } - let old_model = self.stored_collaboration_mode.model().to_string(); - let mode = mode.with_updates(Some(old_model), None, None); - self.stored_collaboration_mode = mode; + self.active_collaboration_mask = Some(mask); self.update_collaboration_mode_indicator(); + self.refresh_model_display(); self.request_redraw(); } @@ -4875,11 +4951,9 @@ impl ChatWidget { pub(crate) fn submit_user_message_with_mode( &mut self, text: String, - collaboration_mode: CollaborationMode, + collaboration_mode: CollaborationModeMask, ) { - let model = collaboration_mode.model().to_string(); - self.set_collaboration_mode(collaboration_mode); - self.set_model(&model); + self.set_collaboration_mask(collaboration_mode); self.submit_user_message(text.into()); } @@ -5306,29 +5380,6 @@ fn extract_first_bold(s: &str) -> Option { None } -fn initial_collaboration_mode( - models_manager: &ModelsManager, - fallback_custom: Settings, - desired_mode: Option, -) -> CollaborationMode { - if let Some(kind) = desired_mode { - if kind == ModeKind::Custom { - return CollaborationMode { - mode: ModeKind::Custom, - settings: fallback_custom, - }; - } - if let Some(mode) = collaboration_modes::mode_for_kind(models_manager, kind) { - return mode; - } - } - - collaboration_modes::default_mode(models_manager).unwrap_or(CollaborationMode { - mode: ModeKind::Custom, - settings: fallback_custom, - }) -} - async fn fetch_rate_limits(base_url: String, auth: CodexAuth) -> Option { match BackendClient::from_auth(base_url, &auth) { Ok(client) => match client.get_rate_limits().await { diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index f4ff932e20..9eff1b2f41 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -89,6 +89,7 @@ use tempfile::NamedTempFile; use tempfile::tempdir; use tokio::sync::mpsc::error::TryRecvError; use tokio::sync::mpsc::unbounded_channel; +use toml::Value as TomlValue; #[cfg(target_os = "windows")] fn set_windows_sandbox_enabled(enabled: bool) { @@ -777,29 +778,16 @@ async fn make_chatwidget_manual( let auth_manager = AuthManager::from_auth_for_testing(CodexAuth::from_api_key("test")); let codex_home = cfg.codex_home.clone(); let models_manager = Arc::new(ModelsManager::new(codex_home, auth_manager.clone())); - let collaboration_modes_enabled = cfg.features.enabled(Feature::CollaborationModes); let reasoning_effort = None; - let stored_collaboration_mode = if collaboration_modes_enabled { - collaboration_modes::default_mode(models_manager.as_ref()).unwrap_or_else(|| { - CollaborationMode { - mode: ModeKind::Custom, - settings: Settings { - model: resolved_model.clone(), - reasoning_effort, - developer_instructions: None, - }, - } - }) - } else { - CollaborationMode { - mode: ModeKind::Custom, - settings: Settings { - model: resolved_model.clone(), - reasoning_effort, - developer_instructions: None, - }, - } + let base_mode = CollaborationMode { + mode: ModeKind::Custom, + settings: Settings { + model: resolved_model.clone(), + reasoning_effort, + developer_instructions: None, + }, }; + let current_collaboration_mode = base_mode; let widget = ChatWidget { app_event_tx, codex_op_tx: op_tx, @@ -807,7 +795,8 @@ async fn make_chatwidget_manual( active_cell: None, active_cell_revision: 0, config: cfg, - stored_collaboration_mode, + current_collaboration_mode, + active_collaboration_mask: None, auth_manager, models_manager, otel_manager, @@ -1214,7 +1203,7 @@ async fn plan_implementation_popup_yes_emits_submit_message_event() { panic!("expected SubmitUserMessageWithMode, got {event:?}"); }; assert_eq!(text, PLAN_IMPLEMENTATION_CODING_MESSAGE); - assert_eq!(collaboration_mode.mode, ModeKind::Code); + assert_eq!(collaboration_mode.mode, Some(ModeKind::Code)); } #[tokio::test] @@ -1223,7 +1212,7 @@ async fn submit_user_message_with_mode_sets_coding_collaboration_mode() { chat.thread_id = Some(ThreadId::new()); chat.set_feature_enabled(Feature::CollaborationModes, true); - let code_mode = collaboration_modes::code_mode(chat.models_manager.as_ref()) + let code_mode = collaboration_modes::code_mask(chat.models_manager.as_ref()) .expect("expected code collaboration mode"); chat.submit_user_message_with_mode("Implement the plan.".to_string(), code_mode); @@ -1247,14 +1236,10 @@ async fn submit_user_message_with_mode_sets_coding_collaboration_mode() { async fn plan_implementation_popup_skips_replayed_turn_complete() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5")).await; chat.set_feature_enabled(Feature::CollaborationModes, true); - chat.stored_collaboration_mode = CollaborationMode { - mode: ModeKind::Plan, - settings: Settings { - model: chat.current_model().to_string(), - reasoning_effort: None, - developer_instructions: None, - }, - }; + let plan_mask = + collaboration_modes::mask_for_kind(chat.models_manager.as_ref(), ModeKind::Plan) + .expect("expected plan collaboration mask"); + chat.set_collaboration_mask(plan_mask); chat.replay_initial_messages(vec![EventMsg::TurnComplete(TurnCompleteEvent { last_agent_message: Some("Plan details".to_string()), @@ -1271,14 +1256,10 @@ async fn plan_implementation_popup_skips_replayed_turn_complete() { async fn plan_implementation_popup_skips_when_messages_queued() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5")).await; chat.set_feature_enabled(Feature::CollaborationModes, true); - chat.stored_collaboration_mode = CollaborationMode { - mode: ModeKind::Plan, - settings: Settings { - model: chat.current_model().to_string(), - reasoning_effort: None, - developer_instructions: None, - }, - }; + let plan_mask = + collaboration_modes::mask_for_kind(chat.models_manager.as_ref(), ModeKind::Plan) + .expect("expected plan collaboration mask"); + chat.set_collaboration_mask(plan_mask); chat.bottom_pane.set_task_running(true); chat.queue_user_message("Queued message".into()); @@ -1295,14 +1276,10 @@ async fn plan_implementation_popup_skips_when_messages_queued() { async fn plan_implementation_popup_shows_on_plan_update_without_message() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5")).await; chat.set_feature_enabled(Feature::CollaborationModes, true); - chat.stored_collaboration_mode = CollaborationMode { - mode: ModeKind::Plan, - settings: Settings { - model: chat.current_model().to_string(), - reasoning_effort: None, - developer_instructions: None, - }, - }; + let plan_mask = + collaboration_modes::mask_for_kind(chat.models_manager.as_ref(), ModeKind::Plan) + .expect("expected plan collaboration mask"); + chat.set_collaboration_mask(plan_mask); chat.on_task_started(); chat.on_plan_update(UpdatePlanArgs { @@ -1327,14 +1304,10 @@ async fn plan_implementation_popup_skips_when_rate_limit_prompt_pending() { chat.auth_manager = AuthManager::from_auth_for_testing(CodexAuth::create_dummy_chatgpt_auth_for_testing()); chat.set_feature_enabled(Feature::CollaborationModes, true); - chat.stored_collaboration_mode = CollaborationMode { - mode: ModeKind::Plan, - settings: Settings { - model: chat.current_model().to_string(), - reasoning_effort: None, - developer_instructions: None, - }, - }; + let plan_mask = + collaboration_modes::mask_for_kind(chat.models_manager.as_ref(), ModeKind::Plan) + .expect("expected plan collaboration mask"); + chat.set_collaboration_mask(plan_mask); chat.on_task_started(); chat.on_plan_update(UpdatePlanArgs { @@ -2222,22 +2195,25 @@ async fn collab_mode_shift_tab_cycles_only_when_enabled_and_idle() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; chat.set_feature_enabled(Feature::CollaborationModes, false); - let initial = chat.stored_collaboration_mode.clone(); + let initial = chat.current_collaboration_mode().clone(); chat.handle_key_event(KeyEvent::from(KeyCode::BackTab)); - assert_eq!(chat.stored_collaboration_mode, initial); + assert_eq!(chat.current_collaboration_mode(), &initial); + assert_eq!(chat.active_collaboration_mode_kind(), ModeKind::Custom); chat.set_feature_enabled(Feature::CollaborationModes, true); chat.handle_key_event(KeyEvent::from(KeyCode::BackTab)); - assert_eq!(chat.stored_collaboration_mode.mode, ModeKind::Plan); + assert_eq!(chat.active_collaboration_mode_kind(), ModeKind::Plan); + assert_eq!(chat.current_collaboration_mode(), &initial); chat.handle_key_event(KeyEvent::from(KeyCode::BackTab)); - assert_eq!(chat.stored_collaboration_mode.mode, ModeKind::Code); + assert_eq!(chat.active_collaboration_mode_kind(), ModeKind::Code); + assert_eq!(chat.current_collaboration_mode(), &initial); chat.on_task_started(); - let before = chat.stored_collaboration_mode.clone(); + let before = chat.active_collaboration_mode_kind(); chat.handle_key_event(KeyEvent::from(KeyCode::BackTab)); - assert_eq!(chat.stored_collaboration_mode, before); + assert_eq!(chat.active_collaboration_mode_kind(), before); } #[tokio::test] @@ -2254,11 +2230,11 @@ async fn collab_slash_command_opens_picker_and_updates_mode() { ); chat.handle_key_event(KeyEvent::from(KeyCode::Enter)); - let selected_mode = match rx.try_recv() { - Ok(AppEvent::UpdateCollaborationMode(mode)) => mode, + let selected_mask = match rx.try_recv() { + Ok(AppEvent::UpdateCollaborationMode(mask)) => mask, other => panic!("expected UpdateCollaborationMode event, got {other:?}"), }; - chat.set_collaboration_mode(selected_mode); + chat.set_collaboration_mask(selected_mask); chat.bottom_pane .set_composer_text("hello".to_string(), Vec::new(), Vec::new()); @@ -2298,7 +2274,81 @@ async fn collab_slash_command_opens_picker_and_updates_mode() { } #[tokio::test] -async fn collab_mode_defaults_to_coding_when_enabled() { +async fn experimental_mode_plan_applies_on_startup() { + let codex_home = tempdir().expect("tempdir"); + let cfg = ConfigBuilder::default() + .codex_home(codex_home.path().to_path_buf()) + .cli_overrides(vec![ + ( + "features.collaboration_modes".to_string(), + TomlValue::Boolean(true), + ), + ( + "tui.experimental_mode".to_string(), + TomlValue::String("plan".to_string()), + ), + ]) + .build() + .await + .expect("config"); + let resolved_model = ModelsManager::get_model_offline(cfg.model.as_deref()); + let otel_manager = test_otel_manager(&cfg, resolved_model.as_str()); + let thread_manager = Arc::new(ThreadManager::with_models_provider( + CodexAuth::from_api_key("test"), + cfg.model_provider.clone(), + )); + let auth_manager = AuthManager::from_auth_for_testing(CodexAuth::from_api_key("test")); + let init = ChatWidgetInit { + config: cfg, + frame_requester: FrameRequester::test_dummy(), + app_event_tx: AppEventSender::new(unbounded_channel::().0), + initial_user_message: None, + enhanced_keys_supported: false, + auth_manager, + models_manager: thread_manager.get_models_manager(), + feedback: codex_feedback::CodexFeedback::new(), + is_first_run: true, + model: Some(resolved_model.clone()), + otel_manager, + }; + + let chat = ChatWidget::new(init, thread_manager); + assert_eq!(chat.active_collaboration_mode_kind(), ModeKind::Plan); + assert_eq!(chat.current_model(), resolved_model); +} + +#[tokio::test] +async fn set_model_updates_active_collaboration_mask() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.1")).await; + chat.set_feature_enabled(Feature::CollaborationModes, true); + let plan_mask = + collaboration_modes::mask_for_kind(chat.models_manager.as_ref(), ModeKind::Plan) + .expect("expected plan collaboration mask"); + chat.set_collaboration_mask(plan_mask); + + chat.set_model("gpt-5.1-codex-mini"); + + assert_eq!(chat.current_model(), "gpt-5.1-codex-mini"); + assert_eq!(chat.active_collaboration_mode_kind(), ModeKind::Plan); +} + +#[tokio::test] +async fn set_reasoning_effort_updates_active_collaboration_mask() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.1")).await; + chat.set_feature_enabled(Feature::CollaborationModes, true); + let plan_mask = + collaboration_modes::mask_for_kind(chat.models_manager.as_ref(), ModeKind::Plan) + .expect("expected plan collaboration mask"); + chat.set_collaboration_mask(plan_mask); + + chat.set_reasoning_effort(None); + + assert_eq!(chat.current_reasoning_effort(), None); + assert_eq!(chat.active_collaboration_mode_kind(), ModeKind::Plan); +} + +#[tokio::test] +async fn collab_mode_is_not_sent_until_selected() { let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(None).await; chat.thread_id = Some(ThreadId::new()); chat.set_feature_enabled(Feature::CollaborationModes, true); @@ -2308,25 +2358,24 @@ async fn collab_mode_defaults_to_coding_when_enabled() { chat.handle_key_event(KeyEvent::from(KeyCode::Enter)); match next_submit_op(&mut op_rx) { Op::UserTurn { - collaboration_mode: - Some(CollaborationMode { - mode: ModeKind::Code, - .. - }), + collaboration_mode, personality: None, .. - } => {} + } => { + assert_eq!(collaboration_mode, None); + } other => { - panic!("expected Op::UserTurn with code collab mode, got {other:?}") + panic!("expected Op::UserTurn, got {other:?}") } } } #[tokio::test] -async fn collab_mode_enabling_sets_coding_default() { +async fn collab_mode_enabling_keeps_custom_until_selected() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; chat.set_feature_enabled(Feature::CollaborationModes, true); - assert_eq!(chat.stored_collaboration_mode.mode, ModeKind::Code); + assert_eq!(chat.active_collaboration_mode_kind(), ModeKind::Custom); + assert_eq!(chat.current_collaboration_mode().mode, ModeKind::Custom); } #[tokio::test] diff --git a/codex-rs/tui/src/collaboration_modes.rs b/codex-rs/tui/src/collaboration_modes.rs index c66010b57a..95908d8e90 100644 --- a/codex-rs/tui/src/collaboration_modes.rs +++ b/codex-rs/tui/src/collaboration_modes.rs @@ -1,70 +1,61 @@ use codex_core::models_manager::manager::ModelsManager; -use codex_protocol::config_types::CollaborationMode; +use codex_protocol::config_types::CollaborationModeMask; use codex_protocol::config_types::ModeKind; -fn mode_kind(mode: &CollaborationMode) -> ModeKind { - mode.mode -} - fn is_tui_mode(kind: ModeKind) -> bool { matches!(kind, ModeKind::Plan | ModeKind::Code) } -fn filtered_presets(models_manager: &ModelsManager) -> Vec { +fn filtered_presets(models_manager: &ModelsManager) -> Vec { models_manager .list_collaboration_modes() .into_iter() - .filter(|preset| is_tui_mode(mode_kind(preset))) + .filter(|mask| mask.mode.is_some_and(is_tui_mode)) .collect() } -pub(crate) fn presets_for_tui(models_manager: &ModelsManager) -> Vec { +pub(crate) fn presets_for_tui(models_manager: &ModelsManager) -> Vec { filtered_presets(models_manager) } -pub(crate) fn default_mode(models_manager: &ModelsManager) -> Option { +pub(crate) fn default_mask(models_manager: &ModelsManager) -> Option { let presets = filtered_presets(models_manager); presets .iter() - .find(|preset| preset.mode == ModeKind::Code) + .find(|mask| mask.mode == Some(ModeKind::Code)) .cloned() .or_else(|| presets.into_iter().next()) } -pub(crate) fn mode_for_kind( +pub(crate) fn mask_for_kind( models_manager: &ModelsManager, kind: ModeKind, -) -> Option { +) -> Option { if !is_tui_mode(kind) { return None; } - let presets = filtered_presets(models_manager); - presets.into_iter().find(|preset| mode_kind(preset) == kind) -} - -pub(crate) fn same_variant(a: &CollaborationMode, b: &CollaborationMode) -> bool { - mode_kind(a) == mode_kind(b) + filtered_presets(models_manager) + .into_iter() + .find(|mask| mask.mode == Some(kind)) } /// Cycle to the next collaboration mode preset in list order. -pub(crate) fn next_mode( +pub(crate) fn next_mask( models_manager: &ModelsManager, - current: &CollaborationMode, -) -> Option { + current: Option<&CollaborationModeMask>, +) -> Option { let presets = filtered_presets(models_manager); if presets.is_empty() { return None; } - let current_kind = mode_kind(current); + let current_kind = current.and_then(|mask| mask.mode); let next_index = presets .iter() - .position(|preset| mode_kind(preset) == current_kind) + .position(|mask| mask.mode == current_kind) .map_or(0, |idx| (idx + 1) % presets.len()); presets.get(next_index).cloned() } -pub(crate) fn code_mode(models_manager: &ModelsManager) -> Option { - filtered_presets(models_manager) - .into_iter() - .find(|preset| mode_kind(preset) == ModeKind::Code) +pub(crate) fn code_mask(models_manager: &ModelsManager) -> Option { + mask_for_kind(models_manager, ModeKind::Code) } From b332482eb1a183166d13bc8b58fbb2137fd3f446 Mon Sep 17 00:00:00 2001 From: pakrym-oai Date: Sun, 25 Jan 2026 02:13:21 -0800 Subject: [PATCH 32/36] Mark collab as beta (#9834) Co-authored-by: jif-oai --- codex-rs/core/src/features.rs | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/codex-rs/core/src/features.rs b/codex-rs/core/src/features.rs index 9a9527aa7a..5e2a0d5d50 100644 --- a/codex-rs/core/src/features.rs +++ b/codex-rs/core/src/features.rs @@ -434,11 +434,7 @@ pub const FEATURES: &[FeatureSpec] = &[ FeatureSpec { id: Feature::Collab, key: "collab", - stage: Stage::Experimental { - name: "Multi-agents", - menu_description: "Allow Codex to spawn and collaborate with other agents on request (formerly named `collab`).", - announcement: "NEW! Codex can now spawn other agents and work with them to solve your problems. Enable in /experimental!", - }, + stage: Stage::Beta, default_enabled: false, }, FeatureSpec { From a748600c42b911ef4f4af08807c19b24a43d70e5 Mon Sep 17 00:00:00 2001 From: jif-oai Date: Sun, 25 Jan 2026 14:50:31 +0100 Subject: [PATCH 33/36] Revert "Revert "fix: musl build"" (#9847) Fix for https://github.com/openai/codex/commit/77222492f9a648106495c7741716c93b2174855b --- .github/scripts/install-musl-build-tools.sh | 140 +++++++++++++++++--- .github/workflows/rust-ci.yml | 6 + .github/workflows/rust-release.yml | 6 + .github/workflows/shell-tool-mcp.yml | 6 + 4 files changed, 139 insertions(+), 19 deletions(-) diff --git a/.github/scripts/install-musl-build-tools.sh b/.github/scripts/install-musl-build-tools.sh index e2af7528d5..634fe04d71 100644 --- a/.github/scripts/install-musl-build-tools.sh +++ b/.github/scripts/install-musl-build-tools.sh @@ -32,30 +32,132 @@ case "${TARGET}" in ;; esac -if command -v clang++ >/dev/null; then - cxx="$(command -v clang++)" - echo "CXXFLAGS=--target=${TARGET} -stdlib=libc++ -pthread" >> "$GITHUB_ENV" - echo "CFLAGS=--target=${TARGET} -pthread" >> "$GITHUB_ENV" - if command -v clang >/dev/null; then - cc="$(command -v clang)" - echo "CC=${cc}" >> "$GITHUB_ENV" - echo "TARGET_CC=${cc}" >> "$GITHUB_ENV" - target_cc_var="CC_${TARGET}" - target_cc_var="${target_cc_var//-/_}" - echo "${target_cc_var}=${cc}" >> "$GITHUB_ENV" - fi -elif command -v "${arch}-linux-musl-g++" >/dev/null; then - cxx="$(command -v "${arch}-linux-musl-g++")" -elif command -v musl-g++ >/dev/null; then - cxx="$(command -v musl-g++)" +# Use the musl toolchain as the Rust linker to avoid Zig injecting its own CRT. +if command -v "${arch}-linux-musl-gcc" >/dev/null; then + musl_linker="$(command -v "${arch}-linux-musl-gcc")" elif command -v musl-gcc >/dev/null; then - cxx="$(command -v musl-gcc)" - echo "CFLAGS=-pthread" >> "$GITHUB_ENV" + musl_linker="$(command -v musl-gcc)" else - echo "musl g++ not found after install; arch=${arch}" >&2 + echo "musl gcc not found after install; arch=${arch}" >&2 exit 1 fi +zig_target="${TARGET/-unknown-linux-musl/-linux-musl}" +runner_temp="${RUNNER_TEMP:-/tmp}" +tool_root="${runner_temp}/codex-musl-tools-${TARGET}" +mkdir -p "${tool_root}" + +sysroot="" +if command -v zig >/dev/null; then + zig_bin="$(command -v zig)" + cc="${tool_root}/zigcc" + cxx="${tool_root}/zigcxx" + + cat >"${cc}" <"${cxx}" </dev/null || true)" +else + cc="${musl_linker}" + + if command -v "${arch}-linux-musl-g++" >/dev/null; then + cxx="$(command -v "${arch}-linux-musl-g++")" + elif command -v musl-g++ >/dev/null; then + cxx="$(command -v musl-g++)" + else + cxx="${cc}" + fi +fi + +if [[ -n "${sysroot}" && "${sysroot}" != "/" ]]; then + echo "BORING_BSSL_SYSROOT=${sysroot}" >> "$GITHUB_ENV" + boring_sysroot_var="BORING_BSSL_SYSROOT_${TARGET}" + boring_sysroot_var="${boring_sysroot_var//-/_}" + echo "${boring_sysroot_var}=${sysroot}" >> "$GITHUB_ENV" +fi + +cflags="-pthread" +cxxflags="-pthread" +if [[ "${TARGET}" == "aarch64-unknown-linux-musl" ]]; then + # BoringSSL enables -Wframe-larger-than=25344 under clang and treats warnings as errors. + cflags="${cflags} -Wno-error=frame-larger-than" + cxxflags="${cxxflags} -Wno-error=frame-larger-than" +fi + +echo "CFLAGS=${cflags}" >> "$GITHUB_ENV" +echo "CXXFLAGS=${cxxflags}" >> "$GITHUB_ENV" +echo "CC=${cc}" >> "$GITHUB_ENV" +echo "TARGET_CC=${cc}" >> "$GITHUB_ENV" +target_cc_var="CC_${TARGET}" +target_cc_var="${target_cc_var//-/_}" +echo "${target_cc_var}=${cc}" >> "$GITHUB_ENV" echo "CXX=${cxx}" >> "$GITHUB_ENV" +echo "TARGET_CXX=${cxx}" >> "$GITHUB_ENV" +target_cxx_var="CXX_${TARGET}" +target_cxx_var="${target_cxx_var//-/_}" +echo "${target_cxx_var}=${cxx}" >> "$GITHUB_ENV" + +cargo_linker_var="CARGO_TARGET_${TARGET^^}_LINKER" +cargo_linker_var="${cargo_linker_var//-/_}" +echo "${cargo_linker_var}=${musl_linker}" >> "$GITHUB_ENV" + +echo "CMAKE_C_COMPILER=${cc}" >> "$GITHUB_ENV" echo "CMAKE_CXX_COMPILER=${cxx}" >> "$GITHUB_ENV" echo "CMAKE_ARGS=-DCMAKE_HAVE_THREADS_LIBRARY=1 -DCMAKE_USE_PTHREADS_INIT=1 -DCMAKE_THREAD_LIBS_INIT=-pthread -DTHREADS_PREFER_PTHREAD_FLAG=ON" >> "$GITHUB_ENV" diff --git a/.github/workflows/rust-ci.yml b/.github/workflows/rust-ci.yml index a6bf51369b..a8efec1089 100644 --- a/.github/workflows/rust-ci.yml +++ b/.github/workflows/rust-ci.yml @@ -261,6 +261,12 @@ jobs: /var/cache/apt key: apt-${{ matrix.runner }}-${{ matrix.target }}-v1 + - if: ${{ matrix.target == 'x86_64-unknown-linux-musl' || matrix.target == 'aarch64-unknown-linux-musl'}} + name: Install Zig + uses: mlugg/setup-zig@v2 + with: + version: 0.14.0 + - if: ${{ matrix.target == 'x86_64-unknown-linux-musl' || matrix.target == 'aarch64-unknown-linux-musl'}} name: Install musl build tools env: diff --git a/.github/workflows/rust-release.yml b/.github/workflows/rust-release.yml index 9033d058d7..3625c830dd 100644 --- a/.github/workflows/rust-release.yml +++ b/.github/workflows/rust-release.yml @@ -104,6 +104,12 @@ jobs: ${{ github.workspace }}/codex-rs/target/ key: cargo-${{ matrix.runner }}-${{ matrix.target }}-release-${{ hashFiles('**/Cargo.lock') }} + - if: ${{ matrix.target == 'x86_64-unknown-linux-musl' || matrix.target == 'aarch64-unknown-linux-musl'}} + name: Install Zig + uses: mlugg/setup-zig@v2 + with: + version: 0.14.0 + - if: ${{ matrix.target == 'x86_64-unknown-linux-musl' || matrix.target == 'aarch64-unknown-linux-musl'}} name: Install musl build tools env: diff --git a/.github/workflows/shell-tool-mcp.yml b/.github/workflows/shell-tool-mcp.yml index 0e03fea43c..a8a01b5f6a 100644 --- a/.github/workflows/shell-tool-mcp.yml +++ b/.github/workflows/shell-tool-mcp.yml @@ -97,6 +97,12 @@ jobs: with: targets: ${{ matrix.target }} + - if: ${{ matrix.install_musl }} + name: Install Zig + uses: mlugg/setup-zig@v2 + with: + version: 0.14.0 + - if: ${{ matrix.install_musl }} name: Install musl build dependencies env: From 73b5274443cd3ef70ee8d30d707f8fdf805b7ad2 Mon Sep 17 00:00:00 2001 From: jif-oai Date: Sun, 25 Jan 2026 15:57:22 +0100 Subject: [PATCH 34/36] feat: cap number of agents (#9855) Adding more guards to agent: * Max depth or 1 (i.e. a sub-agent can't spawn another one) * Max 12 sub-agents in total --- codex-rs/core/src/agent/guards.rs | 45 ++++++++++++ codex-rs/core/src/agent/mod.rs | 4 +- codex-rs/core/src/config/mod.rs | 10 +-- codex-rs/core/src/tools/handlers/collab.rs | 84 +++++++++++++++++++++- codex-rs/protocol/src/protocol.rs | 12 +++- 5 files changed, 143 insertions(+), 12 deletions(-) diff --git a/codex-rs/core/src/agent/guards.rs b/codex-rs/core/src/agent/guards.rs index c384ed7cd8..2f146f2f80 100644 --- a/codex-rs/core/src/agent/guards.rs +++ b/codex-rs/core/src/agent/guards.rs @@ -1,6 +1,8 @@ use crate::error::CodexErr; use crate::error::Result; use codex_protocol::ThreadId; +use codex_protocol::protocol::SessionSource; +use codex_protocol::protocol::SubAgentSource; use std::collections::HashSet; use std::sync::Arc; use std::sync::Mutex; @@ -19,6 +21,25 @@ pub(crate) struct Guards { total_count: AtomicUsize, } +/// Initial agent is depth 0. +pub(crate) const MAX_THREAD_SPAWN_DEPTH: i32 = 1; + +fn session_depth(session_source: &SessionSource) -> i32 { + match session_source { + SessionSource::SubAgent(SubAgentSource::ThreadSpawn { depth, .. }) => *depth, + SessionSource::SubAgent(_) => 0, + _ => 0, + } +} + +pub(crate) fn next_thread_spawn_depth(session_source: &SessionSource) -> i32 { + session_depth(session_source).saturating_add(1) +} + +pub(crate) fn exceeds_thread_spawn_depth_limit(depth: i32) -> bool { + depth > MAX_THREAD_SPAWN_DEPTH +} + impl Guards { pub(crate) fn reserve_spawn_slot( self: &Arc, @@ -102,6 +123,30 @@ mod tests { use super::*; use pretty_assertions::assert_eq; + #[test] + fn session_depth_defaults_to_zero_for_root_sources() { + assert_eq!(session_depth(&SessionSource::Cli), 0); + } + + #[test] + fn thread_spawn_depth_increments_and_enforces_limit() { + let session_source = SessionSource::SubAgent(SubAgentSource::ThreadSpawn { + parent_thread_id: ThreadId::new(), + depth: 1, + }); + let child_depth = next_thread_spawn_depth(&session_source); + assert_eq!(child_depth, 2); + assert!(exceeds_thread_spawn_depth_limit(child_depth)); + } + + #[test] + fn non_thread_spawn_subagents_default_to_depth_zero() { + let session_source = SessionSource::SubAgent(SubAgentSource::Review); + assert_eq!(session_depth(&session_source), 0); + assert_eq!(next_thread_spawn_depth(&session_source), 1); + assert!(!exceeds_thread_spawn_depth_limit(1)); + } + #[test] fn reservation_drop_releases_slot() { let guards = Arc::new(Guards::default()); diff --git a/codex-rs/core/src/agent/mod.rs b/codex-rs/core/src/agent/mod.rs index 180f70dbe6..03652e43e5 100644 --- a/codex-rs/core/src/agent/mod.rs +++ b/codex-rs/core/src/agent/mod.rs @@ -1,10 +1,12 @@ pub(crate) mod control; -// Do not put in `pub` or `pub(crate)`. This code should not be used somewhere else. mod guards; pub(crate) mod role; pub(crate) mod status; pub(crate) use codex_protocol::protocol::AgentStatus; pub(crate) use control::AgentControl; +pub(crate) use guards::MAX_THREAD_SPAWN_DEPTH; +pub(crate) use guards::exceeds_thread_spawn_depth_limit; +pub(crate) use guards::next_thread_spawn_depth; pub(crate) use role::AgentRole; pub(crate) use status::agent_status_from_event; diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index b3fb84c5fd..02fc0121f6 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -89,7 +89,7 @@ pub use codex_git::GhostSnapshotConfig; /// files are *silently truncated* to this size so we do not take up too much of /// the context window. pub(crate) const PROJECT_DOC_MAX_BYTES: usize = 32 * 1024; // 32 KiB -pub(crate) const DEFAULT_AGENT_MAX_THREADS: Option = None; +pub(crate) const DEFAULT_AGENT_MAX_THREADS: Option = Some(12); pub const CONFIG_TOML_FILE: &str = "config.toml"; @@ -3693,7 +3693,7 @@ model_verbosity = "high" project_doc_max_bytes: PROJECT_DOC_MAX_BYTES, project_doc_fallback_filenames: Vec::new(), tool_output_token_limit: None, - agent_max_threads: None, + agent_max_threads: DEFAULT_AGENT_MAX_THREADS, codex_home: fixture.codex_home(), config_layer_stack: Default::default(), history: History::default(), @@ -3775,7 +3775,7 @@ model_verbosity = "high" project_doc_max_bytes: PROJECT_DOC_MAX_BYTES, project_doc_fallback_filenames: Vec::new(), tool_output_token_limit: None, - agent_max_threads: None, + agent_max_threads: DEFAULT_AGENT_MAX_THREADS, codex_home: fixture.codex_home(), config_layer_stack: Default::default(), history: History::default(), @@ -3872,7 +3872,7 @@ model_verbosity = "high" project_doc_max_bytes: PROJECT_DOC_MAX_BYTES, project_doc_fallback_filenames: Vec::new(), tool_output_token_limit: None, - agent_max_threads: None, + agent_max_threads: DEFAULT_AGENT_MAX_THREADS, codex_home: fixture.codex_home(), config_layer_stack: Default::default(), history: History::default(), @@ -3955,7 +3955,7 @@ model_verbosity = "high" project_doc_max_bytes: PROJECT_DOC_MAX_BYTES, project_doc_fallback_filenames: Vec::new(), tool_output_token_limit: None, - agent_max_threads: None, + agent_max_threads: DEFAULT_AGENT_MAX_THREADS, codex_home: fixture.codex_home(), config_layer_stack: Default::default(), history: History::default(), diff --git a/codex-rs/core/src/tools/handlers/collab.rs b/codex-rs/core/src/tools/handlers/collab.rs index 6bdebf6a21..83ee53905b 100644 --- a/codex-rs/core/src/tools/handlers/collab.rs +++ b/codex-rs/core/src/tools/handlers/collab.rs @@ -78,6 +78,9 @@ impl ToolHandler for CollabHandler { mod spawn { use super::*; use crate::agent::AgentRole; + use crate::agent::MAX_THREAD_SPAWN_DEPTH; + use crate::agent::exceeds_thread_spawn_depth_limit; + use crate::agent::next_thread_spawn_depth; use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SubAgentSource; use std::sync::Arc; @@ -107,6 +110,13 @@ mod spawn { "Empty message can't be sent to an agent".to_string(), )); } + let session_source = turn.client.get_session_source(); + let child_depth = next_thread_spawn_depth(&session_source); + if exceeds_thread_spawn_depth_limit(child_depth) { + return Err(FunctionCallError::RespondToModel(format!( + "agent depth limit reached: max depth is {MAX_THREAD_SPAWN_DEPTH}" + ))); + } session .send_event( &turn, @@ -132,6 +142,7 @@ mod spawn { prompt.clone(), Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn { parent_thread_id: session.conversation_id, + depth: child_depth, })), ) .await @@ -581,7 +592,6 @@ fn build_agent_spawn_config( config.model_reasoning_summary = turn.client.get_reasoning_summary(); config.developer_instructions = turn.developer_instructions.clone(); config.compact_prompt = turn.compact_prompt.clone(); - config.user_instructions = turn.user_instructions.clone(); config.shell_environment_policy = turn.shell_environment_policy.clone(); config.codex_linux_sandbox_exe = turn.codex_linux_sandbox_exe.clone(); config.cwd = turn.cwd.clone(); @@ -605,13 +615,17 @@ mod tests { use super::*; use crate::CodexAuth; use crate::ThreadManager; + use crate::agent::MAX_THREAD_SPAWN_DEPTH; use crate::built_in_model_providers; + use crate::client::ModelClient; use crate::codex::make_session_and_context; use crate::config::types::ShellEnvironmentPolicy; use crate::function_tool::FunctionCallError; use crate::protocol::AskForApproval; use crate::protocol::Op; use crate::protocol::SandboxPolicy; + use crate::protocol::SessionSource; + use crate::protocol::SubAgentSource; use crate::turn_diff_tracker::TurnDiffTracker; use codex_protocol::ThreadId; use pretty_assertions::assert_eq; @@ -731,6 +745,45 @@ mod tests { ); } + #[tokio::test] + async fn spawn_agent_rejects_when_depth_limit_exceeded() { + let (mut session, mut turn) = make_session_and_context().await; + let manager = thread_manager(); + session.services.agent_control = manager.agent_control(); + + let session_source = SessionSource::SubAgent(SubAgentSource::ThreadSpawn { + parent_thread_id: session.conversation_id, + depth: MAX_THREAD_SPAWN_DEPTH, + }); + turn.client = ModelClient::new( + turn.client.config(), + Some(session.services.auth_manager.clone()), + turn.client.get_model_info(), + turn.client.get_otel_manager(), + turn.client.get_provider(), + turn.client.get_reasoning_effort(), + turn.client.get_reasoning_summary(), + session.conversation_id, + session_source, + ); + + let invocation = invocation( + Arc::new(session), + Arc::new(turn), + "spawn_agent", + function_payload(json!({"message": "hello"})), + ); + let Err(err) = CollabHandler.handle(invocation).await else { + panic!("spawn should fail when depth limit exceeded"); + }; + assert_eq!( + err, + FunctionCallError::RespondToModel(format!( + "agent depth limit reached: max depth is {MAX_THREAD_SPAWN_DEPTH}" + )) + ); + } + #[tokio::test] async fn send_input_rejects_empty_message() { let (session, turn) = make_session_and_context().await; @@ -1081,7 +1134,6 @@ mod tests { }; turn.developer_instructions = Some("dev".to_string()); turn.compact_prompt = Some("compact".to_string()); - turn.user_instructions = Some("user".to_string()); turn.shell_environment_policy = ShellEnvironmentPolicy { use_profile: true, ..ShellEnvironmentPolicy::default() @@ -1101,7 +1153,6 @@ mod tests { expected.model_reasoning_summary = turn.client.get_reasoning_summary(); expected.developer_instructions = turn.developer_instructions.clone(); expected.compact_prompt = turn.compact_prompt.clone(); - expected.user_instructions = turn.user_instructions.clone(); expected.shell_environment_policy = turn.shell_environment_policy.clone(); expected.codex_linux_sandbox_exe = turn.codex_linux_sandbox_exe.clone(); expected.cwd = turn.cwd.clone(); @@ -1115,4 +1166,31 @@ mod tests { .expect("sandbox policy set"); assert_eq!(config, expected); } + + #[tokio::test] + async fn build_agent_spawn_config_preserves_base_user_instructions() { + let (session, mut turn) = make_session_and_context().await; + let session_source = turn.client.get_session_source(); + let mut base_config = (*turn.client.config()).clone(); + base_config.user_instructions = Some("base-user".to_string()); + turn.user_instructions = Some("resolved-user".to_string()); + turn.client = ModelClient::new( + Arc::new(base_config.clone()), + Some(session.services.auth_manager.clone()), + turn.client.get_model_info(), + turn.client.get_otel_manager(), + turn.client.get_provider(), + turn.client.get_reasoning_effort(), + turn.client.get_reasoning_summary(), + session.conversation_id, + session_source, + ); + let base_instructions = BaseInstructions { + text: "base".to_string(), + }; + + let config = build_agent_spawn_config(&base_instructions, &turn).expect("spawn config"); + + assert_eq!(config.user_instructions, base_config.user_instructions); + } } diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index 7b1c39d061..7bf3c90eab 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -1518,7 +1518,10 @@ pub enum SessionSource { pub enum SubAgentSource { Review, Compact, - ThreadSpawn { parent_thread_id: ThreadId }, + ThreadSpawn { + parent_thread_id: ThreadId, + depth: i32, + }, Other(String), } @@ -1540,8 +1543,11 @@ impl fmt::Display for SubAgentSource { match self { SubAgentSource::Review => f.write_str("review"), SubAgentSource::Compact => f.write_str("compact"), - SubAgentSource::ThreadSpawn { parent_thread_id } => { - write!(f, "thread_spawn_{parent_thread_id}") + SubAgentSource::ThreadSpawn { + parent_thread_id, + depth, + } => { + write!(f, "thread_spawn_{parent_thread_id}_d{depth}") } SubAgentSource::Other(other) => f.write_str(other), } From 8fea8f73d6c0dc77ffaf5e24d739ca348f30d5c0 Mon Sep 17 00:00:00 2001 From: jif-oai Date: Sun, 25 Jan 2026 17:51:55 +0100 Subject: [PATCH 35/36] chore: half max number of sub-agents (#9861) https://openai.slack.com/archives/C095U48JNL9/p1769359138786499?thread_ts=1769190766.962719&cid=C095U48JNL9 --- codex-rs/core/src/config/mod.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index 02fc0121f6..ca3ccc1ffd 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -89,7 +89,7 @@ pub use codex_git::GhostSnapshotConfig; /// files are *silently truncated* to this size so we do not take up too much of /// the context window. pub(crate) const PROJECT_DOC_MAX_BYTES: usize = 32 * 1024; // 32 KiB -pub(crate) const DEFAULT_AGENT_MAX_THREADS: Option = Some(12); +pub(crate) const DEFAULT_AGENT_MAX_THREADS: Option = Some(6); pub const CONFIG_TOML_FILE: &str = "config.toml"; From 8400b3bd97186e20bd5e9c60a16a9fe0fd97d7c9 Mon Sep 17 00:00:00 2001 From: Michael Bolin Date: Sun, 25 Jan 2026 10:29:20 -0800 Subject: [PATCH 36/36] =?UTF-8?q?feat:=20This=20PR=20adds=20a=20first-part?= =?UTF-8?q?y=20curl-based=20installer=20that=20downloads=20only=20the=20bi?= =?UTF-8?q?naries=20needed=20for=20the=20current=20platform=20and=20wires?= =?UTF-8?q?=20it=20into=20Codex=E2=80=99s=20existing=20update=20UX.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit It introduces a dedicated `installer/` folder for non-Rust install/update assets, a `CODEX_HOME`-aware on-disk layout, and a wrapper that makes helper CLIs available to Codex without globally polluting the user’s shell. ## Why Today the recommended install path is `npm i -g @openai/codex`, but the npm package bundles native binaries for all platforms (and `rg`), leading to very large installs (hundreds of MB unpacked) even though a single platform binary is much smaller. Homebrew avoids this on macOS by downloading only the matching artifact. This PR brings that same property to a cross-platform one-liner: ```sh curl -fsSL https://raw.githubusercontent.com/openai/codex/main/installer/install.sh | bash ``` Key goals: - Download only the user’s platform artifacts. - Keep curl-managed installs isolated under `CODEX_HOME`. - Preserve compatibility with npm/brew installs (shadowing is fine, breaking is not). - Provide a clean place to add helper CLIs that should be available when Codex runs. ## How It Works ### Install root and layout All curl-managed artifacts live under `CODEX_HOME` (default: `~/.codex`). The layout is: - `CODEX_HOME/bin/` - `CODEX_HOME/versions//bin/` - `CODEX_HOME/versions/current` (symlink) - `CODEX_HOME/tools/bin/` This design supports atomic version switches and helper CLIs: - The user’s global `PATH` only needs `CODEX_HOME/bin`. - The runtime `PATH` is extended inside the wrapper to include helper CLIs. See `installer/README.md:1`. ### Installer scripts #### `installer/install.sh` `installer/install.sh:1` is the public entrypoint: - Requires Bash explicitly (we use Bash features like `mapfile`). - Loads `installer/lib.sh` locally when run from a checkout, or downloads it when piped from curl. - Detects `arch`/`os`, resolves the version (via `CODEX_VERSION` or GitHub Releases), downloads the correct tarball, installs it, activates it, and updates the user’s rc file. #### `installer/lib.sh` `installer/lib.sh:10` centralizes the mechanics: - Resolves `CODEX_HOME` with a fallback to `~/.codex`. - Detects OS/arch and builds release URLs. - Idempotently updates the user rc file via a marker block that respects non-default `CODEX_HOME` values: `installer/lib.sh:106`. - Installs a wrapper at `CODEX_HOME/bin/codex` that: - Sets `CODEX_MANAGED_BY_CURL=1`. - Extends `PATH` at runtime to include: - `CODEX_HOME/bin` - `CODEX_HOME/tools/bin` - `CODEX_HOME/versions/current/bin` - Execs the active versioned binary: `installer/lib.sh:154`. - Installs the tarball into a versioned `bin/` directory and preserves any additional CLIs present in the tarball (not just `codex`): `installer/lib.sh:181`. - Keeps the last two installed versions and avoids deleting the `current` symlink: `installer/lib.sh:239`. #### `installer/update.sh` `installer/update.sh:1` mirrors the installer flow but always targets the latest release. ### Update UX integration in the CLI Codex’s update prompt already knows how to run a package-manager-specific update command after the TUI exits. This PR adds a curl-managed update action in `codex-rs/tui/src/update_action.rs:3`: - Adds `UpdateAction::CurlInstallerUpdate`. - Detects curl-managed installs via `CODEX_MANAGED_BY_CURL` (set by the wrapper): `codex-rs/tui/src/update_action.rs:40`. - Uses the curl updater one-liner for the update command: `codex-rs/tui/src/update_action.rs:21`. This keeps update behavior consistent with npm/brew while avoiding fragile path heuristics. ## Docs updates - The curl installer is now the first recommended install path in `README.md:1` and `README.md:18`. - `docs/install.md:11` adds a curl install section and points to `installer/README.md` for the detailed mechanics. ## Compatibility and safety notes - This does not modify npm or Homebrew installs. - The rc-file change only adds `CODEX_HOME/bin` to `PATH`. - Additional helper CLIs are available to Codex via the wrapper’s runtime `PATH`, rather than being forced into the user’s global shell. - The installer honors `CODEX_HOME` everywhere, falling back to `~/.codex`. ## Testing Commands run: ```sh cd codex-rs just fmt cargo test -p codex-tui ``` Notes: - In this environment, `cargo test -p codex-tui` required running outside the sandbox due to a macOS `SystemConfiguration` panic; it passed once run with escalated permissions. - No new VS Code diagnostics were reported. ## Follow-ups (explicitly not in this PR) - Ensure the release tarballs include any helper CLIs we expect to be available at runtime (for example, Windows sandbox helpers). - Add an explicit `codex self-update` command that delegates to `installer/update.sh`. - Decide how we want to manage third-party tools like `rg` under `CODEX_HOME/tools/` and whether the CLI should probe a managed fallback path. --- README.md | 9 +- codex-rs/tui/src/update_action.rs | 36 ++++- docs/install.md | 9 ++ installer/README.md | 104 ++++++++++++ installer/install.sh | 73 +++++++++ installer/lib.sh | 256 ++++++++++++++++++++++++++++++ installer/update.sh | 66 ++++++++ 7 files changed, 547 insertions(+), 6 deletions(-) create mode 100644 installer/README.md create mode 100755 installer/install.sh create mode 100755 installer/lib.sh create mode 100755 installer/update.sh diff --git a/README.md b/README.md index eb4ace74f2..733c915d43 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -

npm i -g @openai/codex
or brew install --cask codex

+

curl -fsSL https://raw.githubusercontent.com/openai/codex/main/installer/install.sh | bash
or brew install --cask codex
or npm i -g @openai/codex

Codex CLI is a coding agent from OpenAI that runs locally on your computer.

Codex CLI splash @@ -15,6 +15,13 @@ If you want Codex in your code editor (VS Code, Cursor, Windsurf), ("bun", &["install", "-g", "@openai/codex"]), UpdateAction::BrewUpgrade => ("brew", &["upgrade", "codex"]), + UpdateAction::CurlInstallerUpdate => ( + "bash", + &[ + "-c", + "curl -fsSL https://raw.githubusercontent.com/openai/codex/main/installer/update.sh | bash", + ], + ), } } @@ -32,12 +41,14 @@ 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(); + let managed_by_curl = std::env::var_os("CODEX_MANAGED_BY_CURL").is_some(); detect_update_action( cfg!(target_os = "macos"), &exe, managed_by_npm, managed_by_bun, + managed_by_curl, ) } @@ -47,11 +58,14 @@ fn detect_update_action( current_exe: &std::path::Path, managed_by_npm: bool, managed_by_bun: bool, + managed_by_curl: bool, ) -> Option { if managed_by_npm { Some(UpdateAction::NpmGlobalLatest) } else if managed_by_bun { Some(UpdateAction::BunGlobalLatest) + } else if managed_by_curl { + Some(UpdateAction::CurlInstallerUpdate) } else if is_macos && (current_exe.starts_with("/opt/homebrew") || current_exe.starts_with("/usr/local")) { @@ -68,15 +82,21 @@ mod tests { #[test] fn detects_update_action_without_env_mutation() { assert_eq!( - detect_update_action(false, std::path::Path::new("/any/path"), false, false), + detect_update_action( + false, + std::path::Path::new("/any/path"), + false, + false, + false + ), None ); assert_eq!( - detect_update_action(false, std::path::Path::new("/any/path"), true, false), + detect_update_action(false, std::path::Path::new("/any/path"), true, false, false), Some(UpdateAction::NpmGlobalLatest) ); assert_eq!( - detect_update_action(false, std::path::Path::new("/any/path"), false, true), + detect_update_action(false, std::path::Path::new("/any/path"), false, true, false), Some(UpdateAction::BunGlobalLatest) ); assert_eq!( @@ -84,7 +104,8 @@ mod tests { true, std::path::Path::new("/opt/homebrew/bin/codex"), false, - false + false, + false, ), Some(UpdateAction::BrewUpgrade) ); @@ -93,9 +114,14 @@ mod tests { true, std::path::Path::new("/usr/local/bin/codex"), false, - false + false, + false, ), Some(UpdateAction::BrewUpgrade) ); + assert_eq!( + detect_update_action(false, std::path::Path::new("/any/path"), false, false, true), + Some(UpdateAction::CurlInstallerUpdate) + ); } } diff --git a/docs/install.md b/docs/install.md index 20d8b54a54..d0306ccbf3 100644 --- a/docs/install.md +++ b/docs/install.md @@ -8,6 +8,15 @@ | Git (optional, recommended) | 2.23+ for built-in PR helpers | | RAM | 4-GB minimum (8-GB recommended) | +### Install via curl + +```bash +curl -fsSL https://raw.githubusercontent.com/openai/codex/main/installer/install.sh | bash +``` + +The curl installer writes under `CODEX_HOME` (default: `~/.codex`). See +`installer/README.md` for the detailed layout and mechanics. + ### DotSlash The GitHub Release also contains a [DotSlash](https://dotslash-cli.com/) file for the Codex CLI named `codex`. Using a DotSlash file makes it possible to make a lightweight commit to source control to ensure all contributors use the same version of an executable, regardless of what platform they use for development. diff --git a/installer/README.md b/installer/README.md new file mode 100644 index 0000000000..34ac29a040 --- /dev/null +++ b/installer/README.md @@ -0,0 +1,104 @@ +# Codex Curl Installer + +This folder contains the non-Rust assets for the curl-based Codex installer +and update flow. Rust code lives under `codex-rs/`. + +## Goals + +- Download only the binaries needed for the current platform. +- Keep the install isolated under `CODEX_HOME` (default: `~/.codex`). +- Avoid breaking npm/brew installs; shadowing is acceptable. +- Support additional helper CLIs without globally polluting `PATH`. + +## Install Root (`CODEX_HOME`) + +The installer treats `CODEX_HOME` as the install root: + +- If `CODEX_HOME` is set, it is used as-is. +- Otherwise, the default is `~/.codex`. + +All curl-managed artifacts should live under this root. + +## On-Disk Layout + +The layout is designed to support multiple versions, helper binaries, and +atomic updates: + +- `CODEX_HOME/bin/` +- `CODEX_HOME/versions//` +- `CODEX_HOME/versions//bin/` +- `CODEX_HOME/tools///` +- `CODEX_HOME/tools/bin/` + +Key conventions: + +- The user-facing entrypoint is `CODEX_HOME/bin/codex`. +- `CODEX_HOME/versions/current` is a symlink to the active version directory. +- Helper CLIs that ship with Codex (for example, Windows sandbox helpers) live + in `CODEX_HOME/versions//bin/`. +- Third-party tools we fetch (for example, `rg`) live under + `CODEX_HOME/tools/...`, with optional shims in `CODEX_HOME/tools/bin/`. + +## PATH Strategy + +We separate the user's global `PATH` from Codex's runtime `PATH`: + +1. The installer ensures `CODEX_HOME/bin` is on the user's `PATH`. +2. The `codex` wrapper augments `PATH` at runtime to include: + - `CODEX_HOME/bin` + - `CODEX_HOME/tools/bin` + - `CODEX_HOME/versions/current/bin` + +This keeps helper CLIs available to Codex without exposing them as global +commands in every shell session. + +## Versioning And Atomic Updates + +Curl-managed installs should be versioned: + +1. Download into a versioned directory: + - `CODEX_HOME/versions//` +2. Link `CODEX_HOME/versions/current` to the new version atomically. +3. Keep a small number of prior versions for rollback. + +Because the wrapper resolves through `versions/current`, repointing the +symlink updates the effective version without editing shell rc files again. + +## Helper CLI Placement + +Any additional CLIs that Codex needs at runtime should follow these rules: + +- Bundled CLIs that are version-coupled to Codex: + - Place in `CODEX_HOME/versions//bin/` +- Third-party tools that may be shared across versions: + - Place in `CODEX_HOME/tools///` + - Optionally add a stable shim in `CODEX_HOME/tools/bin/` + +The wrapper then makes them available during execution. + +## Ripgrep (`rg`) + +The preferred approach is: + +- Use a system `rg` when available. +- Otherwise, allow curl-managed installs to place `rg` under + `CODEX_HOME/tools/rg//` with a shim in `CODEX_HOME/tools/bin/rg`. + +Codex CLI can optionally honor an explicit `CODEX_RG_PATH` to point directly +to a managed `rg`. + +## Scripts + +Planned/expected scripts in this folder: + +- `installer/install.sh` +- `installer/lib.sh` +- `installer/update.sh` + +The public one-liner should look like: + +```sh +curl -fsSL https://raw.githubusercontent.com/openai/codex/main/installer/install.sh | bash +``` + +All scripts must honor `CODEX_HOME` with a fallback to `~/.codex`. diff --git a/installer/install.sh b/installer/install.sh new file mode 100755 index 0000000000..7005392c07 --- /dev/null +++ b/installer/install.sh @@ -0,0 +1,73 @@ +#!/usr/bin/env bash + +if [ -z "${BASH_VERSION:-}" ]; then + echo "This installer requires bash." >&2 + echo "Re-run with: curl -fsSL https://raw.githubusercontent.com/openai/codex/main/installer/install.sh | bash" >&2 + exit 1 +fi + +set -euo pipefail + +INSTALLER_BASE_URL="${CODEX_INSTALLER_BASE_URL:-https://raw.githubusercontent.com/openai/codex/main/installer}" + +load_lib() { + local script_dir lib_path tmp_lib + script_dir="$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")" && pwd)" + lib_path="${script_dir}/lib.sh" + + if [ -f "$lib_path" ]; then + # Running from a repo checkout. + # shellcheck disable=SC1090 + source "$lib_path" + return + fi + + tmp_lib="$(mktemp)" + curl -fsSL "${INSTALLER_BASE_URL}/lib.sh" -o "$tmp_lib" + # shellcheck disable=SC1090 + source "$tmp_lib" + rm -f "$tmp_lib" +} + +load_lib + +main() { + ensure_dirs + + local arch os tag version url tarball rc_file resolved_home + arch="$(detect_arch)" + os="$(detect_os)" + + if [ -n "${CODEX_VERSION:-}" ]; then + version="$(normalize_version "$CODEX_VERSION")" + tag="$(release_tag_for_version "$version")" + else + tag="$(latest_tag)" + if [ -z "$tag" ]; then + echo "Failed to determine the latest Codex release tag." >&2 + exit 1 + fi + version="$(normalize_version "$tag")" + fi + + url="$(release_url "$version" "$arch" "$os")" + tarball="$(mktemp)" + curl -fsSL "$url" -o "$tarball" + + install_version_from_tarball "$version" "$tarball" "$arch" "$os" + activate_version "$version" + install_wrapper + cleanup_old_versions 2 + + rc_file="$(choose_rc_file)" + resolved_home="$(codex_home)" + ensure_path_block "$rc_file" "$resolved_home" + + rm -f "$tarball" + + echo "Codex ${version} installed to ${resolved_home}" + echo "Updated PATH in ${rc_file}" + echo "Open a new shell or run: source ${rc_file}" +} + +main "$@" diff --git a/installer/lib.sh b/installer/lib.sh new file mode 100755 index 0000000000..d55682c05f --- /dev/null +++ b/installer/lib.sh @@ -0,0 +1,256 @@ +#!/usr/bin/env bash + +if [ -z "${BASH_VERSION:-}" ]; then + echo "Codex installer requires bash." >&2 + exit 1 +fi + +set -euo pipefail + +CODEX_HOME_DEFAULT="${HOME}/.codex" +CODEX_HOME_RESOLVED="${CODEX_HOME:-$CODEX_HOME_DEFAULT}" + +codex_home() { + printf '%s\n' "$CODEX_HOME_RESOLVED" +} + +codex_bin_dir() { + printf '%s/bin\n' "$(codex_home)" +} + +codex_versions_dir() { + printf '%s/versions\n' "$(codex_home)" +} + +codex_tools_dir() { + printf '%s/tools\n' "$(codex_home)" +} + +codex_tools_bin_dir() { + printf '%s/bin\n' "$(codex_tools_dir)" +} + +current_version_link() { + printf '%s/current\n' "$(codex_versions_dir)" +} + +detect_os() { + local uname_s + uname_s="$(uname -s)" + case "$uname_s" in + Darwin) printf '%s\n' "apple-darwin" ;; + Linux) printf '%s\n' "unknown-linux-musl" ;; + *) + echo "Unsupported OS: $uname_s" >&2 + exit 1 + ;; + esac +} + +detect_arch() { + local uname_m + uname_m="$(uname -m)" + case "$uname_m" in + arm64|aarch64) printf '%s\n' "aarch64" ;; + x86_64|amd64) printf '%s\n' "x86_64" ;; + *) + echo "Unsupported architecture: $uname_m" >&2 + exit 1 + ;; + esac +} + +github_api() { + local path="$1" + curl -fsSL \ + -H "Accept: application/vnd.github+json" \ + -H "X-GitHub-Api-Version: 2022-11-28" \ + "https://api.github.com/repos/openai/codex/${path}" +} + +latest_tag() { + github_api "releases/latest" | sed -n 's/.*"tag_name"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | head -n1 +} + +normalize_version() { + local tag="$1" + tag="${tag#rust-v}" + tag="${tag#v}" + printf '%s\n' "$tag" +} + +release_tag_for_version() { + local version="$1" + printf '%s\n' "rust-v${version}" +} + +expected_binary_name() { + local arch="$1" + local os="$2" + printf '%s\n' "codex-${arch}-${os}" +} + +release_url() { + local version="$1" + local arch="$2" + local os="$3" + local tag + tag="$(release_tag_for_version "$version")" + printf '%s\n' "https://github.com/openai/codex/releases/download/${tag}/codex-${arch}-${os}.tar.gz" +} + +ensure_dirs() { + mkdir -p "$(codex_bin_dir)" "$(codex_versions_dir)" "$(codex_tools_bin_dir)" +} + +ensure_path_block() { + local rc_file="$1" + local resolved_home="$2" + local start_marker="# >>> codex >>>" + + if [ ! -f "$rc_file" ]; then + touch "$rc_file" + fi + + if grep -Fq "$start_marker" "$rc_file"; then + return + fi + + if [ "$resolved_home" = "${HOME}/.codex" ]; then + cat >>"$rc_file" <<'EOF' +# >>> codex >>> +export CODEX_HOME="${CODEX_HOME:-$HOME/.codex}" +export PATH="$CODEX_HOME/bin:$PATH" +# <<< codex <<< +EOF + else + cat >>"$rc_file" <>> codex >>> +export CODEX_HOME="${resolved_home}" +export PATH="\$CODEX_HOME/bin:\$PATH" +# <<< codex <<< +EOF + fi +} + +choose_rc_file() { + local shell_name + shell_name="$(basename "${SHELL:-}")" + case "$shell_name" in + zsh) printf '%s\n' "${HOME}/.zshrc" ;; + bash) + if [ -f "${HOME}/.bashrc" ]; then + printf '%s\n' "${HOME}/.bashrc" + else + printf '%s\n' "${HOME}/.bash_profile" + fi + ;; + *) + printf '%s\n' "${HOME}/.profile" + ;; + esac +} + +install_wrapper() { + local wrapper_path + wrapper_path="$(codex_bin_dir)/codex" + + cat >"$wrapper_path" <<'EOF' +#!/usr/bin/env bash +set -euo pipefail + +CODEX_HOME="${CODEX_HOME:-$HOME/.codex}" +CURRENT_LINK="$CODEX_HOME/versions/current" +TARGET="$CURRENT_LINK/bin/codex" + +if [ ! -x "$TARGET" ]; then + echo "Codex is not installed under $CODEX_HOME." >&2 + echo "Run the installer again to repair the installation." >&2 + exit 1 +fi + +export CODEX_MANAGED_BY_CURL=1 +export PATH="$CODEX_HOME/bin:$CODEX_HOME/tools/bin:$CURRENT_LINK/bin:$PATH" + +exec "$TARGET" "$@" +EOF + + chmod +x "$wrapper_path" +} + +install_version_from_tarball() { + local version="$1" + local tarball="$2" + local arch="$3" + local os="$4" + + local versions_dir version_dir bin_dir expected_name tmpdir main_candidate + versions_dir="$(codex_versions_dir)" + version_dir="${versions_dir}/${version}" + bin_dir="${version_dir}/bin" + expected_name="$(expected_binary_name "$arch" "$os")" + + rm -rf "$version_dir" + mkdir -p "$bin_dir" + + tmpdir="$(mktemp -d)" + tar -xzf "$tarball" -C "$tmpdir" + + if [ -f "$tmpdir/$expected_name" ]; then + main_candidate="$tmpdir/$expected_name" + elif [ -f "$tmpdir/codex" ]; then + main_candidate="$tmpdir/codex" + else + main_candidate="$(find "$tmpdir" -type f -name 'codex*' | head -n1 || true)" + fi + if [ -z "$main_candidate" ]; then + echo "Failed to locate codex binary in tarball." >&2 + exit 1 + fi + + local files file base dest + mapfile -t files < <(find "$tmpdir" -type f) + for file in "${files[@]}"; do + base="$(basename "$file")" + if [ "$file" = "$main_candidate" ]; then + dest="$bin_dir/codex" + else + dest="$bin_dir/$base" + fi + mv "$file" "$dest" + chmod +x "$dest" 2>/dev/null || true + done + + if [ ! -x "$bin_dir/codex" ]; then + echo "Failed to install codex binary." >&2 + exit 1 + fi + + rm -rf "$tmpdir" +} + +activate_version() { + local version="$1" + local link + link="$(current_version_link)" + ln -sfn "$(codex_versions_dir)/$version" "$link" +} + +cleanup_old_versions() { + local keep="${1:-2}" + local versions_dir + versions_dir="$(codex_versions_dir)" + if [ ! -d "$versions_dir" ]; then + return + fi + if ! compgen -G "$versions_dir/*" >/dev/null; then + return + fi + + # Remove older versions while keeping the most recent N directories. + # We intentionally ignore errors here so cleanup never blocks install. + ls -1dt "$versions_dir"/* 2>/dev/null \ + | grep -v '/current$' \ + | tail -n +"$((keep + 1))" \ + | xargs -I{} rm -rf "{}" 2>/dev/null || true +} diff --git a/installer/update.sh b/installer/update.sh new file mode 100755 index 0000000000..3cdee7e81f --- /dev/null +++ b/installer/update.sh @@ -0,0 +1,66 @@ +#!/usr/bin/env bash + +if [ -z "${BASH_VERSION:-}" ]; then + echo "This updater requires bash." >&2 + echo "Re-run with: curl -fsSL https://raw.githubusercontent.com/openai/codex/main/installer/update.sh | bash" >&2 + exit 1 +fi + +set -euo pipefail + +INSTALLER_BASE_URL="${CODEX_INSTALLER_BASE_URL:-https://raw.githubusercontent.com/openai/codex/main/installer}" + +load_lib() { + local script_dir lib_path tmp_lib + script_dir="$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")" && pwd)" + lib_path="${script_dir}/lib.sh" + + if [ -f "$lib_path" ]; then + # Running from a repo checkout. + # shellcheck disable=SC1090 + source "$lib_path" + return + fi + + tmp_lib="$(mktemp)" + curl -fsSL "${INSTALLER_BASE_URL}/lib.sh" -o "$tmp_lib" + # shellcheck disable=SC1090 + source "$tmp_lib" + rm -f "$tmp_lib" +} + +load_lib + +main() { + ensure_dirs + + local arch os tag version url tarball rc_file resolved_home + arch="$(detect_arch)" + os="$(detect_os)" + + tag="$(latest_tag)" + if [ -z "$tag" ]; then + echo "Failed to determine the latest Codex release tag." >&2 + exit 1 + fi + version="$(normalize_version "$tag")" + + url="$(release_url "$version" "$arch" "$os")" + tarball="$(mktemp)" + curl -fsSL "$url" -o "$tarball" + + install_version_from_tarball "$version" "$tarball" "$arch" "$os" + activate_version "$version" + install_wrapper + cleanup_old_versions 2 + + rc_file="$(choose_rc_file)" + resolved_home="$(codex_home)" + ensure_path_block "$rc_file" "$resolved_home" + + rm -f "$tarball" + + echo "Codex updated to ${version}" +} + +main "$@"